From ab5ecc4e13a92c28980deabdae579b69c252dffe Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 12 Oct 2021 00:07:16 -0500 Subject: [PATCH 01/68] [nit][pre-req] Split EPM Home into components (#114431) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/package_list_grid.stories.tsx | 4 +- .../epm/components/package_list_grid.tsx | 12 +- .../epm/screens/home/available_packages.tsx | 212 +++++++++++ .../epm/screens/home/category_facets.tsx | 16 +- .../sections/epm/screens/home/index.tsx | 360 +----------------- .../epm/screens/home/installed_packages.tsx | 159 ++++++++ 6 files changed, 408 insertions(+), 355 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx index c355eb8607f45..e4bd1da842867 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; -import type { ListProps } from './package_list_grid'; +import type { Props } from './package_list_grid'; import { PackageListGrid } from './package_list_grid'; export default { @@ -17,7 +17,7 @@ export default { title: 'Sections/EPM/Package List Grid', }; -type Args = Pick; +type Args = Pick; const args: Args = { title: 'Installed integrations', 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 109f7500f160b..1cffd5292b6a2 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 @@ -14,7 +14,6 @@ import { EuiLink, EuiSpacer, EuiTitle, - // @ts-ignore EuiSearchBar, EuiText, } from '@elastic/eui'; @@ -29,7 +28,7 @@ import type { IntegrationCardItem } from '../../../../../../common/types/models' import { PackageCard } from './package_card'; -export interface ListProps { +export interface Props { isLoading?: boolean; controls?: ReactNode; title: string; @@ -51,7 +50,7 @@ export function PackageListGrid({ setSelectedCategory, showMissingIntegrationMessage = false, callout, -}: ListProps) { +}: Props) { const [searchTerm, setSearchTerm] = useState(initialSearch || ''); const localSearchRef = useLocalSearch(list); @@ -107,7 +106,12 @@ export function PackageListGrid({ }} onChange={onQueryChange} /> - {callout ? callout : null} + {callout ? ( + <> + + {callout} + + ) : null} {gridContent} {showMissingIntegrationMessage && ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx new file mode 100644 index 0000000000000..8aef9121bf67d --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { useLocation, useHistory, useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + +import { pagePathGetters } from '../../../../constants'; +import { + useGetCategories, + useGetPackages, + useBreadcrumbs, + useGetAppendCustomIntegrations, + useGetReplacementCustomIntegrations, + useLink, +} from '../../../../hooks'; +import { doesPackageHaveIntegrations } from '../../../../services'; +import type { PackageList } from '../../../../types'; +import { PackageListGrid } from '../../components/package_list_grid'; + +import type { CustomIntegration } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +import type { PackageListItem } from '../../../../types'; + +import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +import { useMergeEprPackagesWithReplacements } from '../../../../../../hooks/use_merge_epr_with_replacements'; + +import { mergeAndReplaceCategoryCounts } from './util'; +import { CategoryFacets } from './category_facets'; +import type { CategoryFacet } from './category_facets'; + +import type { CategoryParams } from '.'; +import { getParams, categoryExists, mapToCard } from '.'; + +// Packages can export multiple integrations, aka `policy_templates` +// In the case where packages ship >1 `policy_templates`, we flatten out the +// list of packages by bringing all integrations to top-level so that +// each integration is displayed as its own tile +const packageListToIntegrationsList = (packages: PackageList): PackageList => { + return packages.reduce((acc: PackageList, pkg) => { + const { policy_templates: policyTemplates = [], ...restOfPackage } = pkg; + return [ + ...acc, + restOfPackage, + ...(doesPackageHaveIntegrations(pkg) + ? policyTemplates.map((integration) => { + const { name, title, description, icons } = integration; + return { + ...restOfPackage, + id: `${restOfPackage}-${name}`, + integration: name, + title, + description, + icons: icons || restOfPackage.icons, + }; + }) + : []), + ]; + }, []); +}; + +const title = i18n.translate('xpack.fleet.epmList.allTitle', { + defaultMessage: 'Browse by category', +}); + +// 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(() => { + useBreadcrumbs('integrations_all'); + + const { selectedCategory, searchParam } = getParams( + useParams(), + useLocation().search + ); + + const history = useHistory(); + + const { getHref, getAbsolutePath } = useLink(); + + function setSelectedCategory(categoryId: string) { + const url = pagePathGetters.integrations_all({ + category: categoryId, + searchTerm: searchParam, + })[1]; + history.push(url); + } + + function setSearchTerm(search: string) { + // Use .replace so the browser's back button is not tied to single keystroke + history.replace( + pagePathGetters.integrations_all({ category: selectedCategory, searchTerm: search })[1] + ); + } + + const { data: allCategoryPackagesRes, isLoading: isLoadingAllPackages } = useGetPackages({ + category: '', + }); + + const { data: categoryPackagesRes, isLoading: isLoadingCategoryPackages } = useGetPackages({ + category: selectedCategory, + }); + + const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({ + include_policy_templates: true, + }); + + const eprPackages = useMemo( + () => packageListToIntegrationsList(categoryPackagesRes?.response || []), + [categoryPackagesRes] + ); + + const allEprPackages = useMemo( + () => packageListToIntegrationsList(allCategoryPackagesRes?.response || []), + [allCategoryPackagesRes] + ); + + const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations(); + + const mergedEprPackages: Array = + useMergeEprPackagesWithReplacements( + eprPackages || [], + replacementCustomIntegrations || [], + selectedCategory as IntegrationCategory + ); + + const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } = + useGetAppendCustomIntegrations(); + + const filteredAddableIntegrations = appendCustomIntegrations + ? appendCustomIntegrations.filter((integration: CustomIntegration) => { + if (!selectedCategory) { + return true; + } + return integration.categories.indexOf(selectedCategory as IntegrationCategory) >= 0; + }) + : []; + + const eprAndCustomPackages: Array = [ + ...mergedEprPackages, + ...filteredAddableIntegrations, + ]; + + eprAndCustomPackages.sort((a, b) => { + return a.title.localeCompare(b.title); + }); + + const categories = useMemo(() => { + const eprAndCustomCategories: CategoryFacet[] = + isLoadingCategories || + isLoadingAppendCustomIntegrations || + !appendCustomIntegrations || + !categoriesRes + ? [] + : mergeAndReplaceCategoryCounts( + categoriesRes.response as CategoryFacet[], + appendCustomIntegrations + ); + + return [ + { + id: '', + count: (allEprPackages?.length || 0) + (appendCustomIntegrations?.length || 0), + }, + ...(eprAndCustomCategories ? eprAndCustomCategories : []), + ] as CategoryFacet[]; + }, [ + allEprPackages?.length, + appendCustomIntegrations, + categoriesRes, + isLoadingAppendCustomIntegrations, + isLoadingCategories, + ]); + + if (!isLoadingCategories && !categoryExists(selectedCategory, categories)) { + history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]); + return null; + } + + const controls = categories ? ( + { + setSelectedCategory(id); + }} + /> + ) : null; + + const cards = eprAndCustomPackages.map((item) => { + return mapToCard(getAbsolutePath, getHref, item); + }); + + return ( + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx index d60ccd93b8db1..3eba17d6627a1 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx @@ -21,19 +21,21 @@ interface ALL_CATEGORY { export type CategoryFacet = IntegrationCategoryCount | ALL_CATEGORY; +export interface Props { + showCounts: boolean; + isLoading?: boolean; + categories: CategoryFacet[]; + selectedCategory: string; + onCategoryChange: (category: CategoryFacet) => unknown; +} + export function CategoryFacets({ showCounts, isLoading, categories, selectedCategory, onCategoryChange, -}: { - showCounts: boolean; - isLoading?: boolean; - categories: CategoryFacet[]; - selectedCategory: string; - onCategoryChange: (category: CategoryFacet) => unknown; -}) { +}: Props) { const controls = ( {isLoading ? ( 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 06cf85699bf67..bbebf9e90b16c 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,34 +5,12 @@ * 2.0. */ -import React, { memo, useMemo, Fragment } from 'react'; -import { Switch, Route, useLocation, useHistory, useParams } from 'react-router-dom'; -import semverLt from 'semver/functions/lt'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { memo } from 'react'; +import { Switch, Route } from 'react-router-dom'; -import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; - -import { installationStatuses } from '../../../../../../../common/constants'; import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../../constants'; -import { - INTEGRATIONS_ROUTING_PATHS, - INTEGRATIONS_SEARCH_QUERYPARAM, - pagePathGetters, -} from '../../../../constants'; -import { - useGetCategories, - useGetPackages, - useBreadcrumbs, - useGetAppendCustomIntegrations, - useGetReplacementCustomIntegrations, - useLink, - useStartServices, -} from '../../../../hooks'; -import { doesPackageHaveIntegrations } from '../../../../services'; +import { INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_SEARCH_QUERYPARAM } from '../../../../constants'; import { DefaultLayout } from '../../../../layouts'; -import type { PackageList } from '../../../../types'; -import { PackageListGrid } from '../../components/package_list_grid'; import type { CustomIntegration } from '../../../../../../../../../../src/plugins/custom_integrations/common'; @@ -40,47 +18,47 @@ import type { PackageListItem } from '../../../../types'; import type { IntegrationCardItem } from '../../../../../../../common/types/models'; -import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common'; - -import { useMergeEprPackagesWithReplacements } from '../../../../../../hooks/use_merge_epr_with_replacements'; - -import { mergeAndReplaceCategoryCounts } from './util'; -import { CategoryFacets } from './category_facets'; import type { CategoryFacet } from './category_facets'; +import { InstalledPackages } from './installed_packages'; +import { AvailablePackages } from './available_packages'; export interface CategoryParams { category?: string; } -function getParams(params: CategoryParams, search: string) { +export const getParams = (params: CategoryParams, search: string) => { const { category } = params; const selectedCategory = category || ''; const queryParams = new URLSearchParams(search); const searchParam = queryParams.get(INTEGRATIONS_SEARCH_QUERYPARAM) || ''; return { selectedCategory, searchParam }; -} +}; -function categoryExists(category: string, categories: CategoryFacet[]) { +export const categoryExists = (category: string, categories: CategoryFacet[]) => { return categories.some((c) => c.id === category); -} +}; -function mapToCard( +export const mapToCard = ( getAbsolutePath: (p: string) => string, getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => string, item: CustomIntegration | PackageListItem -): IntegrationCardItem { +): IntegrationCardItem => { let uiInternalPathUrl; + if (item.type === 'ui_link') { uiInternalPathUrl = getAbsolutePath(item.uiInternalPath); } else { let urlVersion = item.version; + if ('savedObject' in item) { urlVersion = item.savedObject.attributes.version || item.version; } + const url = getHref('integration_details_overview', { pkgkey: `${item.name}-${urlVersion}`, ...(item.integration ? { integration: item.integration } : {}), }); + uiInternalPathUrl = url; } @@ -88,14 +66,14 @@ function mapToCard( id: `${item.type === 'ui_link' ? 'ui_link' : 'epr'}-${item.id}`, description: item.description, icons: !item.icons || !item.icons.length ? [] : item.icons, + title: item.title, + url: uiInternalPathUrl, integration: 'integration' in item ? item.integration || '' : '', name: 'name' in item ? item.name || '' : '', - title: item.title, version: 'version' in item ? item.version || '' : '', release: 'release' in item ? item.release : undefined, - url: uiInternalPathUrl, }; -} +}; export const EPMHomePage: React.FC = memo(() => { return ( @@ -113,305 +91,3 @@ export const EPMHomePage: React.FC = memo(() => { ); }); - -// Packages can export multiple integrations, aka `policy_templates` -// In the case where packages ship >1 `policy_templates`, we flatten out the -// list of packages by bringing all integrations to top-level so that -// each integration is displayed as its own tile -const packageListToIntegrationsList = (packages: PackageList): PackageList => { - return packages.reduce((acc: PackageList, pkg) => { - const { policy_templates: policyTemplates = [], ...restOfPackage } = pkg; - return [ - ...acc, - restOfPackage, - ...(doesPackageHaveIntegrations(pkg) - ? policyTemplates.map((integration) => { - const { name, title, description, icons } = integration; - return { - ...restOfPackage, - id: `${restOfPackage}-${name}`, - integration: name, - title, - description, - icons: icons || restOfPackage.icons, - }; - }) - : []), - ]; - }, []); -}; - -const InstalledPackages: React.FC = memo(() => { - useBreadcrumbs('integrations_installed'); - const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages({ - experimental: true, - }); - const { getHref, getAbsolutePath } = useLink(); - const { docLinks } = useStartServices(); - - const { selectedCategory, searchParam } = getParams( - useParams(), - useLocation().search - ); - const history = useHistory(); - function setSelectedCategory(categoryId: string) { - const url = pagePathGetters.integrations_installed({ - category: categoryId, - searchTerm: searchParam, - })[1]; - history.push(url); - } - function setSearchTerm(search: string) { - // Use .replace so the browser's back button is not tied to single keystroke - history.replace( - pagePathGetters.integrations_installed({ - category: selectedCategory, - searchTerm: search, - })[1] - ); - } - - const allInstalledPackages = useMemo( - () => - (allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed), - [allPackages?.response] - ); - - const updatablePackages = useMemo( - () => - allInstalledPackages.filter( - (item) => - 'savedObject' in item && semverLt(item.savedObject.attributes.version, item.version) - ), - [allInstalledPackages] - ); - - const title = useMemo( - () => - i18n.translate('xpack.fleet.epmList.installedTitle', { - defaultMessage: 'Installed integrations', - }), - [] - ); - - const categories: CategoryFacet[] = useMemo( - () => [ - { - id: '', - count: allInstalledPackages.length, - }, - { - id: 'updates_available', - count: updatablePackages.length, - }, - ], - [allInstalledPackages.length, updatablePackages.length] - ); - - if (!categoryExists(selectedCategory, categories)) { - history.replace( - pagePathGetters.integrations_installed({ category: '', searchTerm: searchParam })[1] - ); - return null; - } - - const controls = ( - setSelectedCategory(id)} - /> - ); - - const cards = ( - selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages - ).map((item) => { - return mapToCard(getAbsolutePath, getHref, item); - }); - - const link = ( - - {i18n.translate('xpack.fleet.epmList.availableCalloutBlogText', { - defaultMessage: 'announcement blog post', - })} - - ); - const calloutMessage = ( - - ); - - const callout = - selectedCategory === 'updates_available' ? null : ( - - - -

{calloutMessage}

-
-
- ); - - return ( - - ); -}); - -const AvailablePackages: React.FC = memo(() => { - useBreadcrumbs('integrations_all'); - const { selectedCategory, searchParam } = getParams( - useParams(), - useLocation().search - ); - const history = useHistory(); - const { getHref, getAbsolutePath } = useLink(); - - function setSelectedCategory(categoryId: string) { - const url = pagePathGetters.integrations_all({ - category: categoryId, - searchTerm: searchParam, - })[1]; - history.push(url); - } - function setSearchTerm(search: string) { - // Use .replace so the browser's back button is not tied to single keystroke - history.replace( - pagePathGetters.integrations_all({ category: selectedCategory, searchTerm: search })[1] - ); - } - - const { data: allCategoryPackagesRes, isLoading: isLoadingAllPackages } = useGetPackages({ - category: '', - }); - const { data: categoryPackagesRes, isLoading: isLoadingCategoryPackages } = useGetPackages({ - category: selectedCategory, - }); - const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({ - include_policy_templates: true, - }); - - const eprPackages = useMemo( - () => packageListToIntegrationsList(categoryPackagesRes?.response || []), - [categoryPackagesRes] - ); - - const allEprPackages = useMemo( - () => packageListToIntegrationsList(allCategoryPackagesRes?.response || []), - [allCategoryPackagesRes] - ); - - const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations(); - - const mergedEprPackages: Array = - useMergeEprPackagesWithReplacements( - eprPackages || [], - replacementCustomIntegrations || [], - selectedCategory as IntegrationCategory - ); - - const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } = - useGetAppendCustomIntegrations(); - const filteredAddableIntegrations = appendCustomIntegrations - ? appendCustomIntegrations.filter((integration: CustomIntegration) => { - if (!selectedCategory) { - return true; - } - return integration.categories.indexOf(selectedCategory as IntegrationCategory) >= 0; - }) - : []; - - const title = useMemo( - () => - i18n.translate('xpack.fleet.epmList.allTitle', { - defaultMessage: 'Browse by category', - }), - [] - ); - - const eprAndCustomPackages: Array = [ - ...mergedEprPackages, - ...filteredAddableIntegrations, - ]; - eprAndCustomPackages.sort((a, b) => { - return a.title.localeCompare(b.title); - }); - - const categories = useMemo(() => { - const eprAndCustomCategories: CategoryFacet[] = - isLoadingCategories || - isLoadingAppendCustomIntegrations || - !appendCustomIntegrations || - !categoriesRes - ? [] - : mergeAndReplaceCategoryCounts( - categoriesRes.response as CategoryFacet[], - appendCustomIntegrations - ); - return [ - { - id: '', - count: (allEprPackages?.length || 0) + (appendCustomIntegrations?.length || 0), - }, - ...(eprAndCustomCategories ? eprAndCustomCategories : []), - ] as CategoryFacet[]; - }, [ - allEprPackages?.length, - appendCustomIntegrations, - categoriesRes, - isLoadingAppendCustomIntegrations, - isLoadingCategories, - ]); - - if (!isLoadingCategories && !categoryExists(selectedCategory, categories)) { - history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]); - return null; - } - - const controls = categories ? ( - { - setSelectedCategory(id); - }} - /> - ) : null; - - const cards = eprAndCustomPackages.map((item) => { - return mapToCard(getAbsolutePath, getHref, item); - }); - - 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 new file mode 100644 index 0000000000000..404e8820f90b7 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { useLocation, useHistory, useParams } from 'react-router-dom'; +import semverLt from 'semver/functions/lt'; +import { i18n } from '@kbn/i18n'; +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 { PackageListGrid } from '../../components/package_list_grid'; + +import type { CategoryFacet } from './category_facets'; +import { CategoryFacets } from './category_facets'; + +import type { CategoryParams } from '.'; +import { getParams, categoryExists, mapToCard } from '.'; + +const AnnouncementLink = () => { + const { docLinks } = useStartServices(); + + return ( + + {i18n.translate('xpack.fleet.epmList.availableCalloutBlogText', { + defaultMessage: 'announcement blog post', + })} + + ); +}; + +const Callout = () => ( + +

+ , + }} + /> +

+
+); + +const title = i18n.translate('xpack.fleet.epmList.installedTitle', { + defaultMessage: 'Installed integrations', +}); + +// 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(() => { + useBreadcrumbs('integrations_installed'); + + const { data: allPackages, isLoading } = useGetPackages({ + experimental: true, + }); + + const { getHref, getAbsolutePath } = useLink(); + + const { selectedCategory, searchParam } = getParams( + useParams(), + useLocation().search + ); + + const history = useHistory(); + + function setSelectedCategory(categoryId: string) { + const url = pagePathGetters.integrations_installed({ + category: categoryId, + searchTerm: searchParam, + })[1]; + + history.push(url); + } + + function setSearchTerm(search: string) { + // Use .replace so the browser's back button is not tied to single keystroke + history.replace( + pagePathGetters.integrations_installed({ + category: selectedCategory, + searchTerm: search, + })[1] + ); + } + + const allInstalledPackages = useMemo( + () => + (allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed), + [allPackages?.response] + ); + + const updatablePackages = useMemo( + () => + allInstalledPackages.filter( + (item) => + 'savedObject' in item && semverLt(item.savedObject.attributes.version, item.version) + ), + [allInstalledPackages] + ); + + const categories: CategoryFacet[] = useMemo( + () => [ + { + id: '', + count: allInstalledPackages.length, + }, + { + id: 'updates_available', + count: updatablePackages.length, + }, + ], + [allInstalledPackages.length, updatablePackages.length] + ); + + if (!categoryExists(selectedCategory, categories)) { + history.replace( + pagePathGetters.integrations_installed({ category: '', searchTerm: searchParam })[1] + ); + + return null; + } + + const controls = ( + setSelectedCategory(id)} + /> + ); + + const cards = ( + selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages + ).map((item) => mapToCard(getAbsolutePath, getHref, item)); + + const callout = selectedCategory === 'updates_available' ? null : ; + + return ( + + ); +}); From 8ea719ca8a25d5bd421d5fadc92698cb6545f98b Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Tue, 12 Oct 2021 08:46:48 +0200 Subject: [PATCH 02/68] [Logs UI][Metrics UI] Replace usage of deprecated IndexPattern types (#114448) * [Logs UI][Metrics UI] Replace usage of deprecated IndexPattern types (#107887) --- .../common/dependency_mocks/index_patterns.ts | 18 ++++++---------- .../resolved_log_source_configuration.ts | 12 +++++------ .../components/expression_chart.test.tsx | 4 ++-- .../components/expression_chart.tsx | 4 ++-- .../hooks/use_metrics_explorer_chart_data.ts | 4 ++-- .../logs/log_filter/log_filter_state.ts | 5 +++-- .../containers/logs/log_source/log_source.ts | 4 ++-- .../containers/with_kuery_autocompletion.tsx | 6 ++++-- .../containers/with_source/with_source.tsx | 4 ++-- .../hooks/use_kibana_index_patterns.mock.tsx | 21 +++++++------------ .../indices_configuration_panel.stories.tsx | 3 +++ .../pages/logs/settings/validation_errors.ts | 13 ++++++------ .../infra/public/pages/metrics/index.tsx | 4 ++-- .../components/toolbars/toolbar.tsx | 4 ++-- .../metrics_explorer/components/kuery_bar.tsx | 9 +++----- .../metrics_explorer/components/toolbar.tsx | 5 +++-- .../hooks/use_metric_explorer_state.ts | 4 ++-- .../hooks/use_metrics_explorer_data.test.tsx | 4 ++-- .../hooks/use_metrics_explorer_data.ts | 4 ++-- .../pages/metrics/metrics_explorer/index.tsx | 4 ++-- x-pack/plugins/infra/public/utils/kuery.ts | 5 +++-- .../utils/logs_overview_fetches.test.ts | 1 + .../log_entries_search_strategy.test.ts | 1 + .../log_entry_search_strategy.test.ts | 1 + 24 files changed, 69 insertions(+), 75 deletions(-) diff --git a/x-pack/plugins/infra/common/dependency_mocks/index_patterns.ts b/x-pack/plugins/infra/common/dependency_mocks/index_patterns.ts index 14215c1539473..03d3ec757bf55 100644 --- a/x-pack/plugins/infra/common/dependency_mocks/index_patterns.ts +++ b/x-pack/plugins/infra/common/dependency_mocks/index_patterns.ts @@ -7,17 +7,11 @@ import { from, of } from 'rxjs'; import { delay } from 'rxjs/operators'; -import { - fieldList, - FieldSpec, - IIndexPattern, - IndexPattern, - IndexPatternsContract, - RuntimeField, -} from 'src/plugins/data/common'; +import { DataView, DataViewsContract } from '../../../../../src/plugins/data_views/common'; +import { fieldList, FieldSpec, RuntimeField } from '../../../../../src/plugins/data/common'; type IndexPatternMock = Pick< - IndexPattern, + DataView, | 'fields' | 'getComputedFields' | 'getFieldByName' @@ -27,7 +21,7 @@ type IndexPatternMock = Pick< | 'title' | 'type' >; -type IndexPatternMockSpec = Pick & { +type IndexPatternMockSpec = Pick & { fields: FieldSpec[]; }; @@ -71,8 +65,8 @@ export const createIndexPatternsMock = ( asyncDelay: number, indexPatterns: IndexPatternMock[] ): { - getIdsWithTitle: IndexPatternsContract['getIdsWithTitle']; - get: (...args: Parameters) => Promise; + getIdsWithTitle: DataViewsContract['getIdsWithTitle']; + get: (...args: Parameters) => Promise; } => { return { async getIdsWithTitle(_refresh?: boolean) { diff --git a/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts b/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts index 9c41a216c7f92..567acf1fc4134 100644 --- a/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts +++ b/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts @@ -6,7 +6,7 @@ */ import { estypes } from '@elastic/elasticsearch'; -import { IndexPattern, IndexPatternsContract } from '../../../../../src/plugins/data/common'; +import { DataView, DataViewsContract } from '../../../../../src/plugins/data_views/common'; import { ObjectEntries } from '../utility_types'; import { ResolveLogSourceConfigurationError } from './errors'; import { @@ -21,14 +21,14 @@ export interface ResolvedLogSourceConfiguration { timestampField: string; tiebreakerField: string; messageField: string[]; - fields: IndexPattern['fields']; + fields: DataView['fields']; runtimeMappings: estypes.MappingRuntimeFields; columns: LogSourceColumnConfiguration[]; } export const resolveLogSourceConfiguration = async ( sourceConfiguration: LogSourceConfigurationProperties, - indexPatternsService: IndexPatternsContract + indexPatternsService: DataViewsContract ): Promise => { if (sourceConfiguration.logIndices.type === 'index_name') { return await resolveLegacyReference(sourceConfiguration, indexPatternsService); @@ -39,7 +39,7 @@ export const resolveLogSourceConfiguration = async ( const resolveLegacyReference = async ( sourceConfiguration: LogSourceConfigurationProperties, - indexPatternsService: IndexPatternsContract + indexPatternsService: DataViewsContract ): Promise => { if (sourceConfiguration.logIndices.type !== 'index_name') { throw new Error('This function can only resolve legacy references'); @@ -74,7 +74,7 @@ const resolveLegacyReference = async ( const resolveKibanaIndexPatternReference = async ( sourceConfiguration: LogSourceConfigurationProperties, - indexPatternsService: IndexPatternsContract + indexPatternsService: DataViewsContract ): Promise => { if (sourceConfiguration.logIndices.type !== 'index_pattern') { throw new Error('This function can only resolve Kibana Index Pattern references'); @@ -103,7 +103,7 @@ const resolveKibanaIndexPatternReference = async ( }; // this might take other sources of runtime fields into account in the future -const resolveRuntimeMappings = (indexPattern: IndexPattern): estypes.MappingRuntimeFields => { +const resolveRuntimeMappings = (indexPattern: DataView): estypes.MappingRuntimeFields => { const { runtimeFields } = indexPattern.getComputedFields(); const runtimeMappingsFromIndexPattern = ( diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx index caf8e32814fe5..5aafd9b613d99 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx @@ -9,7 +9,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; // We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` import { coreMock as mockCoreMock } from 'src/core/public/mocks'; import { MetricExpression } from '../types'; -import { IIndexPattern } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import React from 'react'; import { ExpressionChart } from './expression_chart'; @@ -40,7 +40,7 @@ jest.mock('../hooks/use_metrics_explorer_chart_data', () => ({ describe('ExpressionChart', () => { async function setup(expression: MetricExpression, filterQuery?: string, groupBy?: string) { - const derivedIndexPattern: IIndexPattern = { + const derivedIndexPattern: DataViewBase = { title: 'metricbeat-*', fields: [], }; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index e5558b961ab20..b176e3907228c 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -10,7 +10,7 @@ import { Axis, Chart, niceTimeFormatter, Position, Settings } from '@elastic/cha import { first, last } from 'lodash'; import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { IIndexPattern } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import { Color } from '../../../../common/color_palette'; import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; @@ -34,7 +34,7 @@ import { ThresholdAnnotations } from '../../common/criterion_preview_chart/thres interface Props { expression: MetricExpression; - derivedIndexPattern: IIndexPattern; + derivedIndexPattern: DataViewBase; source: MetricsSourceConfiguration | null; filterQuery?: string; groupBy?: string | string[]; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts index e3006993216ae..1ae1bacfed42e 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IIndexPattern } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { useMemo } from 'react'; import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import { MetricExpression } from '../types'; @@ -14,7 +14,7 @@ import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/ export const useMetricsExplorerChartData = ( expression: MetricExpression, - derivedIndexPattern: IIndexPattern, + derivedIndexPattern: DataViewBase, source: MetricsSourceConfiguration | null, filterQuery?: string, groupBy?: string | string[] diff --git a/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts b/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts index d4cb7ca90541f..f4576158b9a25 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_filter/log_filter_state.ts @@ -8,7 +8,8 @@ import createContainer from 'constate'; import { useCallback, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; -import { esQuery, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; +import { esQuery, Query } from '../../../../../../../src/plugins/data/public'; type ParsedQuery = ReturnType; @@ -33,7 +34,7 @@ const initialLogFilterState: ILogFilterState = { const validationDebounceTimeout = 1000; // milliseconds -export const useLogFilterState = ({ indexPattern }: { indexPattern: IIndexPattern }) => { +export const useLogFilterState = ({ indexPattern }: { indexPattern: DataViewBase }) => { const [logFilterState, setLogFilterState] = useState(initialLogFilterState); const parseQuery = useCallback( diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts index 198d0d2efe44c..8f744a1d6df6d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts @@ -8,7 +8,7 @@ import createContainer from 'constate'; import { useCallback, useMemo, useState } from 'react'; import type { HttpHandler } from 'src/core/public'; -import { IndexPatternsContract } from '../../../../../../../src/plugins/data/common'; +import { DataViewsContract } from '../../../../../../../src/plugins/data_views/public'; import { LogIndexField, LogSourceConfigurationPropertiesPatch, @@ -42,7 +42,7 @@ export const useLogSource = ({ }: { sourceId: string; fetch: HttpHandler; - indexPatternsService: IndexPatternsContract; + indexPatternsService: DataViewsContract; }) => { const [sourceConfiguration, setSourceConfiguration] = useState< LogSourceConfiguration | undefined diff --git a/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx b/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx index f50c629d521e5..0e5ef1ca78033 100644 --- a/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx +++ b/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; -import { QuerySuggestion, IIndexPattern, DataPublicPluginStart } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; +import { QuerySuggestion, DataPublicPluginStart } from 'src/plugins/data/public'; import { withKibana, KibanaReactContextValue, @@ -21,7 +22,7 @@ interface WithKueryAutocompletionLifecycleProps { loadSuggestions: (expression: string, cursorPosition: number, maxSuggestions?: number) => void; suggestions: QuerySuggestion[]; }>; - indexPattern: IIndexPattern; + indexPattern: DataViewBase; } interface WithKueryAutocompletionLifecycleState { @@ -82,6 +83,7 @@ class WithKueryAutocompletionComponent extends React.Component< query: expression, selectionStart: cursorPosition, selectionEnd: cursorPosition, + // @ts-expect-error (until data service updates to new types) indexPatterns: [indexPattern], boolFilter: [], })) || []; diff --git a/x-pack/plugins/infra/public/containers/with_source/with_source.tsx b/x-pack/plugins/infra/public/containers/with_source/with_source.tsx index f3ca57a40c4c7..aa7ab18271d99 100644 --- a/x-pack/plugins/infra/public/containers/with_source/with_source.tsx +++ b/x-pack/plugins/infra/public/containers/with_source/with_source.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; -import { IIndexPattern } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { MetricsSourceConfigurationProperties, PartialMetricsSourceConfigurationProperties, @@ -21,7 +21,7 @@ interface WithSourceProps { create: ( sourceProperties: PartialMetricsSourceConfigurationProperties ) => Promise | undefined; - createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; + createDerivedIndexPattern: (type: 'metrics') => DataViewBase; exists?: boolean; hasFailed: boolean; isLoading: boolean; diff --git a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx index 9d3a611cff88d..99cd094b50e1e 100644 --- a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx +++ b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx @@ -10,23 +10,16 @@ import { from, of } from 'rxjs'; import { delay } from 'rxjs/operators'; import { CoreStart } from '../../../../../src/core/public'; import { FieldSpec } from '../../../../../src/plugins/data/common'; -import { - IIndexPattern, - IndexPattern, - IndexPatternField, - IndexPatternsContract, -} from '../../../../../src/plugins/data/public'; +import { DataView, DataViewsContract } from '../../../../../src/plugins/data_views/public'; +import { DataViewField } from '../../../../../src/plugins/data_views/common'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { Pick2 } from '../../common/utility_types'; type MockIndexPattern = Pick< - IndexPattern, + DataView, 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName' | 'getComputedFields' >; -export type MockIndexPatternSpec = Pick< - IIndexPattern, - 'id' | 'title' | 'type' | 'timeFieldName' -> & { +export type MockIndexPatternSpec = Pick & { fields: FieldSpec[]; }; @@ -59,8 +52,8 @@ export const createIndexPatternsMock = ( asyncDelay: number, indexPatterns: MockIndexPattern[] ): { - getIdsWithTitle: IndexPatternsContract['getIdsWithTitle']; - get: (...args: Parameters) => Promise; + getIdsWithTitle: DataViewsContract['getIdsWithTitle']; + get: (...args: Parameters) => Promise; } => { return { async getIdsWithTitle(_refresh?: boolean) { @@ -85,7 +78,7 @@ export const createIndexPatternMock = ({ fields, timeFieldName, }: MockIndexPatternSpec): MockIndexPattern => { - const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec)); + const indexPatternFields = fields.map((fieldSpec) => new DataViewField(fieldSpec)); return { id, diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.stories.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.stories.tsx index 8cc9f5b4357ef..546bb9aab0f33 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.stories.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.stories.tsx @@ -131,6 +131,7 @@ const defaultArgs: IndicesConfigurationPanelStoryArgs = { id: 'INDEX_PATTERN_A', title: 'pattern-a-*', timeFieldName: '@timestamp', + type: undefined, fields: [ { name: '@timestamp', @@ -149,6 +150,8 @@ const defaultArgs: IndicesConfigurationPanelStoryArgs = { { id: 'INDEX_PATTERN_B', title: 'pattern-b-*', + timeFieldName: '@timestamp', + type: undefined, fields: [], }, ], diff --git a/x-pack/plugins/infra/public/pages/logs/settings/validation_errors.ts b/x-pack/plugins/infra/public/pages/logs/settings/validation_errors.ts index 81b9297f8a70b..896fecc8d70dd 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/validation_errors.ts +++ b/x-pack/plugins/infra/public/pages/logs/settings/validation_errors.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { IndexPattern, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../src/plugins/data_views/public'; +import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; export interface GenericValidationError { type: 'generic'; @@ -67,7 +68,7 @@ export const validateStringNotEmpty = (fieldName: string, value: string): FormVa export const validateColumnListNotEmpty = (columns: unknown[]): FormValidationError[] => columns.length <= 0 ? [{ type: 'empty_column_list' }] : []; -export const validateIndexPattern = (indexPattern: IndexPattern): FormValidationError[] => { +export const validateIndexPattern = (indexPattern: DataView): FormValidationError[] => { return [ ...validateIndexPatternIsTimeBased(indexPattern), ...validateIndexPatternHasStringMessageField(indexPattern), @@ -75,9 +76,7 @@ export const validateIndexPattern = (indexPattern: IndexPattern): FormValidation ]; }; -export const validateIndexPatternIsTimeBased = ( - indexPattern: IndexPattern -): FormValidationError[] => +export const validateIndexPatternIsTimeBased = (indexPattern: DataView): FormValidationError[] => indexPattern.isTimeBased() ? [] : [ @@ -88,7 +87,7 @@ export const validateIndexPatternIsTimeBased = ( ]; export const validateIndexPatternHasStringMessageField = ( - indexPattern: IndexPattern + indexPattern: DataView ): FormValidationError[] => { const messageField = indexPattern.getFieldByName('message'); @@ -111,7 +110,7 @@ export const validateIndexPatternHasStringMessageField = ( } }; -export const validateIndexPatternIsntRollup = (indexPattern: IndexPattern): FormValidationError[] => +export const validateIndexPatternIsntRollup = (indexPattern: DataView): FormValidationError[] => indexPattern.type != null ? [ { diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index d4845a4dd9e44..ae375dc504e7a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -11,7 +11,7 @@ import React, { useContext } from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import { EuiErrorBoundary, EuiHeaderLinks, EuiHeaderLink } from '@elastic/eui'; -import { IIndexPattern } from 'src/plugins/data/common'; +import { DataViewBase } from '@kbn/es-query'; import { MetricsSourceConfigurationProperties } from '../../../common/metrics_sources'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; @@ -141,7 +141,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { const PageContent = (props: { configuration: MetricsSourceConfigurationProperties; - createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; + createDerivedIndexPattern: (type: 'metrics') => DataViewBase; }) => { const { createDerivedIndexPattern, configuration } = props; const { options } = useContext(MetricsExplorerOptionsContainer.Context); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx index 1c79807f139c3..14f4177798be8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar.tsx @@ -7,6 +7,7 @@ import React, { FunctionComponent } from 'react'; import { EuiFlexItem } from '@elastic/eui'; +import { DataViewBase } from '@kbn/es-query'; import { useSourceContext } from '../../../../../containers/metrics_source'; import { SnapshotMetricInput, @@ -18,13 +19,12 @@ import { findToolbar } from '../../../../../../common/inventory_models/toolbars' import { ToolbarWrapper } from './toolbar_wrapper'; import { InfraGroupByOptions } from '../../../../../lib/lib'; -import { IIndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { InventoryItemType } from '../../../../../../common/inventory_models/types'; import { WaffleOptionsState, WaffleSortOption } from '../../hooks/use_waffle_options'; import { useInventoryMeta } from '../../hooks/use_inventory_meta'; export interface ToolbarProps extends Omit { - createDerivedIndexPattern: (type: 'metrics') => IIndexPattern; + createDerivedIndexPattern: (type: 'metrics') => DataViewBase; changeMetric: (payload: SnapshotMetricInput) => void; changeGroupBy: (payload: SnapshotGroupBy) => void; changeCustomOptions: (payload: InfraGroupByOptions[]) => void; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx index cf201c9a57f7c..2be5f14005b26 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx @@ -8,13 +8,10 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; +import { DataViewBase } from '@kbn/es-query'; import { WithKueryAutocompletion } from '../../../../containers/with_kuery_autocompletion'; import { AutocompleteField } from '../../../../components/autocomplete_field'; -import { - esKuery, - IIndexPattern, - QuerySuggestion, -} from '../../../../../../../../src/plugins/data/public'; +import { esKuery, QuerySuggestion } from '../../../../../../../../src/plugins/data/public'; type LoadSuggestionsFn = ( e: string, @@ -25,7 +22,7 @@ type LoadSuggestionsFn = ( export type CurryLoadSuggestionsType = (loadSuggestions: LoadSuggestionsFn) => LoadSuggestionsFn; interface Props { - derivedIndexPattern: IIndexPattern; + derivedIndexPattern: DataViewBase; onSubmit: (query: string) => void; onChange?: (query: string) => void; value?: string | null; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx index 64da554dee690..01b2d129a1f9e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx @@ -8,7 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { IIndexPattern, UI_SETTINGS } from '../../../../../../../../src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; +import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/public'; import { MetricsExplorerMetric, MetricsExplorerAggregation, @@ -27,7 +28,7 @@ import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting'; import { mapKibanaQuickRangesToDatePickerRanges } from '../../../../utils/map_timepicker_quickranges_to_datepicker_ranges'; interface Props { - derivedIndexPattern: IIndexPattern; + derivedIndexPattern: DataViewBase; timeRange: MetricsExplorerTimeOptions; options: MetricsExplorerOptions; chartOptions: MetricsExplorerChartOptions; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index a304c81ca1298..39eb7c928997b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -6,7 +6,7 @@ */ import { useState, useCallback, useContext } from 'react'; -import { IIndexPattern } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerMetric, @@ -29,7 +29,7 @@ export interface MetricExplorerViewState { export const useMetricsExplorerState = ( source: MetricsSourceConfigurationProperties, - derivedIndexPattern: IIndexPattern, + derivedIndexPattern: DataViewBase, shouldLoadImmediately = true ) => { const [refreshSignal, setRefreshSignal] = useState(0); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx index 9a5e5fcf39ce4..0f903e4c4553a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.test.tsx @@ -20,7 +20,7 @@ import { createSeries, } from '../../../../utils/fixtures/metrics_explorer'; import { MetricsExplorerOptions, MetricsExplorerTimeOptions } from './use_metrics_explorer_options'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { HttpHandler } from 'kibana/public'; import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; @@ -39,7 +39,7 @@ const renderUseMetricsExplorerDataHook = () => { (props: { options: MetricsExplorerOptions; source: MetricsSourceConfigurationProperties | undefined; - derivedIndexPattern: IIndexPattern; + derivedIndexPattern: DataViewBase; timeRange: MetricsExplorerTimeOptions; afterKey: string | null | Record; signal: any; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index 95f98e172541a..6b7e98912fd49 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -8,7 +8,7 @@ import DateMath from '@elastic/datemath'; import { isEqual } from 'lodash'; import { useEffect, useState } from 'react'; -import { IIndexPattern } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { MetricsSourceConfigurationProperties } from '../../../../../common/metrics_sources'; import { MetricsExplorerResponse, @@ -27,7 +27,7 @@ function isSameOptions(current: MetricsExplorerOptions, next: MetricsExplorerOpt export function useMetricsExplorerData( options: MetricsExplorerOptions, source: MetricsSourceConfigurationProperties | undefined, - derivedIndexPattern: IIndexPattern, + derivedIndexPattern: DataViewBase, timerange: MetricsExplorerTimeOptions, afterKey: string | null | Record, signal: any, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index f2345b0b8e020..dabf5db9afe92 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -8,7 +8,7 @@ import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useContext } from 'react'; -import { IIndexPattern } from 'src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; import { MetricsSourceConfigurationProperties } from '../../../../common/metrics_sources'; import { useTrackPageview } from '../../../../../observability/public'; import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; @@ -25,7 +25,7 @@ import { SavedViewsToolbarControls } from '../../../components/saved_views/toolb interface MetricsExplorerPageProps { source: MetricsSourceConfigurationProperties; - derivedIndexPattern: IIndexPattern; + derivedIndexPattern: DataViewBase; } export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExplorerPageProps) => { diff --git a/x-pack/plugins/infra/public/utils/kuery.ts b/x-pack/plugins/infra/public/utils/kuery.ts index 19706d7664c22..c7528b237cce5 100644 --- a/x-pack/plugins/infra/public/utils/kuery.ts +++ b/x-pack/plugins/infra/public/utils/kuery.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { esKuery, IIndexPattern } from '../../../../../src/plugins/data/public'; +import { DataViewBase } from '@kbn/es-query'; +import { esKuery } from '../../../../../src/plugins/data/public'; export const convertKueryToElasticSearchQuery = ( kueryExpression: string, - indexPattern: IIndexPattern + indexPattern: DataViewBase ) => { try { return kueryExpression diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts index 6dfb400567717..4fcb83fd02754 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts @@ -48,6 +48,7 @@ function setup() { id: 'test-index-pattern', title: 'log-indices-*', timeFieldName: '@timestamp', + type: undefined, fields: [ { name: 'event.dataset', diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts index 876aad4ef8d02..85a1b95cf70aa 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entries_search_strategy.test.ts @@ -342,6 +342,7 @@ const createDataPluginMock = (esSearchStrategyMock: ISearchStrategy): any => ({ id: 'test-index-pattern', title: 'log-indices-*', timeFieldName: '@timestamp', + type: undefined, fields: [ { name: 'event.dataset', diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts index 88c9d956dbe0a..ec3d4aa52a6b5 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts @@ -297,6 +297,7 @@ const createDataPluginMock = (esSearchStrategyMock: ISearchStrategy): any => ({ id: 'test-index-pattern', title: 'log-indices-*', timeFieldName: '@timestamp', + type: undefined, fields: [ { name: 'event.dataset', From b5771c81ba190031a1dca1876b57faf46d4c99a1 Mon Sep 17 00:00:00 2001 From: Kevin Lacabane Date: Tue, 12 Oct 2021 10:55:53 +0200 Subject: [PATCH 03/68] [Stack Monitoring] functional tests - properly set test-subj attribute (#114491) * properly set test-subj attribute * fix prettier error * Fixing ESLinting issue Co-authored-by: Chris Cowan Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/application/pages/kibana/instance.tsx | 9 ++------- .../public/application/pages/kibana/instances.tsx | 7 +------ .../public/application/pages/kibana/kibana_template.tsx | 2 ++ .../public/application/pages/kibana/overview.tsx | 7 +------ 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx index 444794d118b0f..2d2fe99758ff7 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx @@ -164,13 +164,8 @@ export const KibanaInstancePage: React.FC = ({ clusters }) => { }, [ccs, clusterUuid, instance, services.data?.query.timefilter.timefilter, services.http]); return ( - -
+ +
diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx index ae0237ea40472..076e9413216fb 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx @@ -87,12 +87,7 @@ export const KibanaInstancesPage: React.FC = ({ clusters }) => { ]); return ( - +
= ({ ...props }) => { defaultMessage: 'Overview', }), route: '/kibana', + testSubj: 'kibanaOverviewPage', }, { id: 'instances', @@ -23,6 +24,7 @@ export const KibanaTemplate: React.FC = ({ ...props }) => { defaultMessage: 'Instances', }), route: '/kibana/instances', + testSubj: 'kibanaInstancesPage', }, ]; diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx index a47da048e1936..4c480bf1fbb33 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx @@ -107,12 +107,7 @@ export const KibanaOverviewPage: React.FC = ({ clusters }) => { }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); return ( - + ); From a0d36e7a699bc212a5de0b1ad305f33d05b3f73a Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 12 Oct 2021 11:24:55 +0200 Subject: [PATCH 04/68] [Discover/Reporting] Fix potential time drift with relative time when requesting a report (#114274) * updated Discover getSharingData to accept flag for absolute time * update snapshots * simplify fallback title * more appropriate title? * remove old translations * implement PR feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/top_nav/get_top_nav_links.ts | 4 +-- .../apps/main/utils/get_sharing_data.test.ts | 36 +++---------------- .../apps/main/utils/get_sharing_data.ts | 16 ++++++--- .../panel_actions/get_csv_panel_action.tsx | 4 +-- .../register_csv_reporting.tsx | 13 +++++-- .../reporting_panel_content.tsx | 4 +-- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 8 files changed, 32 insertions(+), 47 deletions(-) diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts index ba4cd8c3cd524..81be662470306 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts @@ -7,7 +7,6 @@ */ import { i18n } from '@kbn/i18n'; -import moment from 'moment'; import type { IndexPattern, ISearchSource } from 'src/plugins/data/common'; import { showOpenSearchPanel } from './show_open_search_panel'; import { getSharingData, showPublicUrlSwitch } from '../../utils/get_sharing_data'; @@ -128,8 +127,7 @@ export const getTopNavLinks = ({ title: savedSearch.title || i18n.translate('discover.localMenu.fallbackReportTitle', { - defaultMessage: 'Discover search [{date}]', - values: { date: moment().toISOString(true) }, + defaultMessage: 'Untitled discover search', }), }, isDirty: !savedSearch.id || state.isAppStateDirty(), diff --git a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts index e7205c3f9bc69..9b518c23a5f89 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.test.ts @@ -41,14 +41,7 @@ describe('getSharingData', () => { expect(result).toMatchInlineSnapshot(` Object { "columns": Array [], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_score": "desc", - }, - ], - }, + "getSearchSource": [Function], } `); }); @@ -66,14 +59,7 @@ describe('getSharingData', () => { "column_a", "column_b", ], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_score": "desc", - }, - ], - }, + "getSearchSource": [Function], } `); }); @@ -108,14 +94,7 @@ describe('getSharingData', () => { "cool-field-5", "cool-field-6", ], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_doc": "desc", - }, - ], - }, + "getSearchSource": [Function], } `); }); @@ -158,14 +137,7 @@ describe('getSharingData', () => { "cool-field-5", "cool-field-6", ], - "searchSource": Object { - "index": "the-index-pattern-id", - "sort": Array [ - Object { - "_doc": false, - }, - ], - }, + "getSearchSource": [Function], } `); }); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts index 420ff0fa11eeb..21292fabdd13f 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts @@ -9,7 +9,7 @@ import type { Capabilities } from 'kibana/public'; import type { IUiSettingsClient } from 'src/core/public'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; -import type { ISearchSource } from 'src/plugins/data/common'; +import type { ISearchSource, SearchSourceFields } from 'src/plugins/data/common'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import type { SavedSearch, SortOrder } from '../../../../saved_searches/types'; import { getSortForSearchSource } from '../components/doc_table'; @@ -31,8 +31,8 @@ export async function getSharingData( 'sort', getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING)) ); - // When sharing externally we preserve relative time values - searchSource.setField('filter', data.query.timefilter.timefilter.createRelativeFilter(index)); + + searchSource.removeField('filter'); searchSource.removeField('highlight'); searchSource.removeField('highlightAll'); searchSource.removeField('aggs'); @@ -54,7 +54,15 @@ export async function getSharingData( } return { - searchSource: searchSource.getSerializedFields(true), + getSearchSource: (absoluteTime?: boolean): SearchSourceFields => { + const filter = absoluteTime + ? data.query.timefilter.timefilter.createFilter(index) + : data.query.timefilter.timefilter.createRelativeFilter(index); + + searchSource.setField('filter', filter); + + return searchSource.getSerializedFields(true); + }, columns, }; } diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index ef32e64741765..115d7599c6bc8 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -117,10 +117,10 @@ export class ReportingCsvPanelAction implements ActionDefinition } const savedSearch = embeddable.getSavedSearch(); - const { columns, searchSource } = await this.getSearchSource(savedSearch, embeddable); + const { columns, getSearchSource } = await this.getSearchSource(savedSearch, embeddable); const immediateJobParams = this.apiClient.getDecoratedJobParams({ - searchSource, + searchSource: getSearchSource(true), columns, title: savedSearch.title, objectType: 'downloadCsv', // FIXME: added for typescript, but immediate download job does not need objectType diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 040a1646ec1ba..8859d01e4fe9a 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -48,14 +48,23 @@ export const ReportingCsvShareProvider = ({ return []; } + const getSearchSource = sharingData.getSearchSource as ( + absoluteTime?: boolean + ) => SearchSourceFields; + const jobParams = { title: sharingData.title as string, objectType, - searchSource: sharingData.searchSource as SearchSourceFields, columns: sharingData.columns as string[] | undefined, }; - const getJobParams = () => jobParams; + const getJobParams = (forShareUrl?: boolean) => { + const absoluteTime = !forShareUrl; + return { + ...jobParams, + searchSource: getSearchSource(absoluteTime), + }; + }; const shareActions = []; diff --git a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx index 59afa91aaa9c3..6ed6f2d0c5f49 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx @@ -40,7 +40,7 @@ export interface ReportingPanelProps { requiresSavedState: boolean; // Whether the report to be generated requires saved state that is not captured in the URL submitted to the report generator. layoutId?: string; objectId?: string; - getJobParams: () => Omit; + getJobParams: (forShareUrl?: boolean) => Omit; options?: ReactElement | null; isDirty?: boolean; onClose?: () => void; @@ -75,7 +75,7 @@ class ReportingPanelContentUi extends Component { private getAbsoluteReportGenerationUrl = (props: Props) => { const relativePath = this.props.apiClient.getReportingJobPath( props.reportType, - this.props.apiClient.getDecoratedJobParams(this.props.getJobParams()) + this.props.apiClient.getDecoratedJobParams(this.props.getJobParams(true)) ); return url.resolve(window.location.href, relativePath); // FIXME: '(from: string, to: string): string' is deprecated }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fbd9352fb427f..642668bcecf34 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2522,7 +2522,6 @@ "discover.loadingDocuments": "ドキュメントを読み込み中", "discover.loadingJSON": "JSONを読み込んでいます", "discover.loadingResults": "結果を読み込み中", - "discover.localMenu.fallbackReportTitle": "Discover検索[{date}]", "discover.localMenu.inspectTitle": "検査", "discover.localMenu.localMenu.newSearchTitle": "新規", "discover.localMenu.localMenu.optionsTitle": "オプション", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9408bb85db879..4d86994c0fb84 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2548,7 +2548,6 @@ "discover.loadingDocuments": "正在加载文档", "discover.loadingJSON": "正在加载 JSON", "discover.loadingResults": "正在加载结果", - "discover.localMenu.fallbackReportTitle": "Discover 搜索 [{date}]", "discover.localMenu.inspectTitle": "检查", "discover.localMenu.localMenu.newSearchTitle": "新建", "discover.localMenu.localMenu.optionsTitle": "选项", From 14cde79e8327430b0a16a7f266f3d6bbfc971226 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 12 Oct 2021 12:23:46 +0200 Subject: [PATCH 05/68] [ML] APM Correlations: Show trace samples even when overall histogram chart fails to load. (#114247) In the trace samples tab, the trace samples will not be shown if the overall histogram chart fails to load. This PR removes that limitation and will show the trace samples even when the chart fails to load. --- .../distribution/index.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx index ca162be92cdae..97f38a8123a4e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx @@ -222,18 +222,14 @@ export function TransactionDistribution({ status={status} /> - {hasData && ( - <> - - - - - )} + + +
); } From 7d6aba5188de1ac2e2608982399c68ee56cd8de1 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 12 Oct 2021 12:28:21 +0200 Subject: [PATCH 06/68] [Uptime] Make uptime settings saved object exportable (#114235) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/uptime/server/lib/saved_objects.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/x-pack/plugins/uptime/server/lib/saved_objects.ts b/x-pack/plugins/uptime/server/lib/saved_objects.ts index 3cd246175d0be..3e9888df55aa0 100644 --- a/x-pack/plugins/uptime/server/lib/saved_objects.ts +++ b/x-pack/plugins/uptime/server/lib/saved_objects.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../common/constants'; import { DynamicSettings } from '../../common/runtime_types'; import { SavedObjectsType, SavedObjectsErrorHelpers } from '../../../../../src/core/server'; @@ -43,6 +44,14 @@ export const umDynamicSettings: SavedObjectsType = { */ }, }, + management: { + importableAndExportable: true, + icon: 'uptimeApp', + getTitle: () => + i18n.translate('xpack.uptime.uptimeSettings.index', { + defaultMessage: 'Uptime Settings - Index', + }), + }, }; export const savedObjectsAdapter: UMSavedObjectsAdapter = { From 7617dc118514ffcfa0db583f00c755cd2dca3e91 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 12 Oct 2021 12:47:43 +0100 Subject: [PATCH 07/68] skip flaky suite (#114396) --- .../public/components/common/uptime_date_picker.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx b/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx index 997831442faa2..cd122eb5d5fc5 100644 --- a/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx @@ -12,7 +12,8 @@ import { createMemoryHistory } from 'history'; import { render } from '../../lib/helper/rtl_helpers'; import { fireEvent } from '@testing-library/dom'; -describe('UptimeDatePicker component', () => { +// FLAKY: https://github.com/elastic/kibana/issues/114396 +describe.skip('UptimeDatePicker component', () => { it('renders properly with mock data', async () => { const { findByText } = render(); expect(await findByText('Last 15 minutes')).toBeInTheDocument(); From a11c3374c44144624916f360636d1af23df8faa8 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 12 Oct 2021 07:53:26 -0400 Subject: [PATCH 08/68] Handle deletes (#114545) --- .../curation_suggestion_logic.test.ts | 72 +++++++++++++++++++ .../curation_suggestion_logic.ts | 47 +++++++++--- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts index 2ace55133d6fd..7a91171cc2cc7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.test.ts @@ -357,6 +357,42 @@ describe('CurationSuggestionLogic', () => { ); }); + describe('when a suggestion is a "delete" suggestion', () => { + const deleteSuggestion = { + ...suggestion, + operation: 'delete', + promoted: [], + curation_id: 'cur-6155e69c7a2f2e4f756303fd', + }; + + it('will show a confirm message before applying, and redirect a user back to the curations page, rather than the curation details page', async () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(true); + http.put.mockReturnValueOnce( + Promise.resolve({ + results: [{ ...suggestion, status: 'accepted', curation_id: undefined }], + }) + ); + mountLogic({ + suggestion: deleteSuggestion, + }); + CurationSuggestionLogic.actions.acceptSuggestion(); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations'); + }); + + it('will do nothing if the user does not confirm', async () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(false); + mountLogic({ + suggestion: deleteSuggestion, + }); + CurationSuggestionLogic.actions.acceptSuggestion(); + await nextTick(); + expect(http.put).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + }); + }); + itHandlesErrors(http.put, () => { CurationSuggestionLogic.actions.acceptSuggestion(); }); @@ -404,6 +440,42 @@ describe('CurationSuggestionLogic', () => { ); }); + describe('when a suggestion is a "delete" suggestion', () => { + const deleteSuggestion = { + ...suggestion, + operation: 'delete', + promoted: [], + curation_id: 'cur-6155e69c7a2f2e4f756303fd', + }; + + it('will show a confirm message before applying, and redirect a user back to the curations page, rather than the curation details page', async () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(true); + http.put.mockReturnValueOnce( + Promise.resolve({ + results: [{ ...suggestion, status: 'accepted', curation_id: undefined }], + }) + ); + mountLogic({ + suggestion: deleteSuggestion, + }); + CurationSuggestionLogic.actions.acceptAndAutomateSuggestion(); + await nextTick(); + + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations'); + }); + + it('will do nothing if the user does not confirm', async () => { + jest.spyOn(global, 'confirm').mockReturnValueOnce(false); + mountLogic({ + suggestion: deleteSuggestion, + }); + CurationSuggestionLogic.actions.acceptAndAutomateSuggestion(); + await nextTick(); + expect(http.put).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + }); + }); + itHandlesErrors(http.put, () => { CurationSuggestionLogic.actions.acceptAndAutomateSuggestion(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts index 6749b510edeba..4ca1b0adb7814 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_suggestion/curation_suggestion_logic.ts @@ -141,6 +141,11 @@ export const CurationSuggestionLogic = kea< const { engineName } = EngineLogic.values; const { suggestion } = values; + if (suggestion!.operation === 'delete') { + const confirmed = await confirmDialog('Are you sure you want to delete this curation?'); + if (!confirmed) return; + } + try { const updatedSuggestion = await updateSuggestion( http, @@ -155,11 +160,16 @@ export const CurationSuggestionLogic = kea< { defaultMessage: 'Suggestion was succefully applied.' } ) ); - KibanaLogic.values.navigateToUrl( - generateEnginePath(ENGINE_CURATION_PATH, { - curationId: updatedSuggestion.curation_id, - }) - ); + if (suggestion!.operation === 'delete') { + // Because if a curation is deleted, there will be no curation detail page to navigate to afterwards. + KibanaLogic.values.navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH)); + } else { + KibanaLogic.values.navigateToUrl( + generateEnginePath(ENGINE_CURATION_PATH, { + curationId: updatedSuggestion.curation_id, + }) + ); + } } catch (e) { flashAPIErrors(e); } @@ -169,6 +179,11 @@ export const CurationSuggestionLogic = kea< const { engineName } = EngineLogic.values; const { suggestion } = values; + if (suggestion!.operation === 'delete') { + const confirmed = await confirmDialog('Are you sure you want to delete this curation?'); + if (!confirmed) return; + } + try { const updatedSuggestion = await updateSuggestion( http, @@ -187,11 +202,16 @@ export const CurationSuggestionLogic = kea< } ) ); - KibanaLogic.values.navigateToUrl( - generateEnginePath(ENGINE_CURATION_PATH, { - curationId: updatedSuggestion.curation_id, - }) - ); + if (suggestion!.operation === 'delete') { + // Because if a curation is deleted, there will be no curation detail page to navigate to afterwards. + KibanaLogic.values.navigateToUrl(generateEnginePath(ENGINE_CURATIONS_PATH)); + } else { + KibanaLogic.values.navigateToUrl( + generateEnginePath(ENGINE_CURATION_PATH, { + curationId: updatedSuggestion.curation_id, + }) + ); + } } catch (e) { flashAPIErrors(e); } @@ -325,3 +345,10 @@ const getCuration = async (http: HttpSetup, engineName: string, curationId: stri query: { skip_record_analytics: 'true' }, }); }; + +const confirmDialog = (msg: string) => { + return new Promise(function (resolve) { + const confirmed = window.confirm(msg); + return resolve(confirmed); + }); +}; From eb5e46a094bfea7b9e2dc56d849762c3a4a0bcd0 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Tue, 12 Oct 2021 07:53:48 -0400 Subject: [PATCH 09/68] Check platinum license (#114549) --- .../curations/views/curations_overview.test.tsx | 16 ++++++++++++++++ .../curations/views/curations_overview.tsx | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.test.tsx index 32ea59c8192ba..ff6ee66d8cb10 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.test.tsx @@ -15,6 +15,8 @@ import { shallow } from 'enzyme'; import { CurationsTable, EmptyState } from '../components'; +import { SuggestionsTable } from '../components/suggestions_table'; + import { CurationsOverview } from './curations_overview'; describe('CurationsOverview', () => { @@ -44,4 +46,18 @@ describe('CurationsOverview', () => { expect(wrapper.find(CurationsTable)).toHaveLength(1); }); + + it('renders a suggestions table when the user has a platinum license', () => { + setMockValues({ curations: [], hasPlatinumLicense: true }); + const wrapper = shallow(); + + expect(wrapper.find(SuggestionsTable).exists()).toBe(true); + }); + + it('doesn\t render a suggestions table when the user has no platinum license', () => { + setMockValues({ curations: [], hasPlatinumLicense: false }); + const wrapper = shallow(); + + expect(wrapper.find(SuggestionsTable).exists()).toBe(false); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.tsx index 7d3db5dedb262..079f0046cb9bf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_overview.tsx @@ -11,15 +11,16 @@ import { useValues } from 'kea'; import { EuiSpacer } from '@elastic/eui'; +import { LicensingLogic } from '../../../../shared/licensing'; import { CurationsTable, EmptyState } from '../components'; import { SuggestionsTable } from '../components/suggestions_table'; import { CurationsLogic } from '../curations_logic'; export const CurationsOverview: React.FC = () => { const { curations } = useValues(CurationsLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); - // TODO - const shouldShowSuggestions = true; + const shouldShowSuggestions = hasPlatinumLicense; return ( <> From adb49b7d37a3a368f938307c7df09729fdba43c7 Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Tue, 12 Oct 2021 14:06:31 +0200 Subject: [PATCH 10/68] refactor (#114603) --- .../cypress/integration/timelines/pagination.spec.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts index b569ea7cc082f..ce7b589f53c5a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts @@ -24,7 +24,7 @@ import { HOSTS_URL } from '../../urls/navigation'; const defaultPageSize = 25; describe('Pagination', () => { - beforeEach(() => { + before(() => { cleanKibana(); loginAndWaitForPage(HOSTS_URL); openTimelineUsingToggle(); @@ -41,20 +41,13 @@ describe('Pagination', () => { it('should be able to change items count per page with the dropdown', () => { const itemsPerPage = 100; - cy.intercept('POST', '/internal/bsearch').as('refetch'); - cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE_BTN).first().click(); cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE_OPTION(itemsPerPage)).click(); - cy.wait('@refetch').its('response.statusCode').should('eq', 200); cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE).should('contain.text', itemsPerPage); }); it('should be able to go to next / previous page', () => { - cy.intercept('POST', '/internal/bsearch').as('refetch'); cy.get(`${TIMELINE_FLYOUT} ${TIMELINE_EVENTS_COUNT_NEXT_PAGE}`).first().click(); - cy.wait('@refetch').its('response.statusCode').should('eq', 200); - cy.get(`${TIMELINE_FLYOUT} ${TIMELINE_EVENTS_COUNT_PREV_PAGE}`).first().click(); - cy.wait('@refetch').its('response.statusCode').should('eq', 200); }); }); From 9e65b12c4b6bd8b048994e92a7d4a970862e2372 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 12 Oct 2021 06:29:36 -0600 Subject: [PATCH 11/68] [Maps] Fix apis Maps endpoints getTile should return vector tile containing document (#114509) * [Maps] Fix apis Maps endpoints getTile should return vector tile containing document * use find instead of if statement * eslint * can not use layer.feature.find is layer.feature is not an array * unskip other test where fix has been merged Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../api_integration/apis/maps/get_tile.js | 27 ++++++++++++++----- .../maps/documents_source/docvalue_fields.js | 3 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index b153cc1ff030c..a1d4f10ca7be8 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -10,11 +10,19 @@ import Protobuf from 'pbf'; import expect from '@kbn/expect'; import { MVT_SOURCE_LAYER_NAME } from '../../../../plugins/maps/common/constants'; +function findFeature(layer, callbackFn) { + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + if (callbackFn(feature)) { + return feature; + } + } +} + export default function ({ getService }) { const supertest = getService('supertest'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/114471 - describe.skip('getTile', () => { + describe('getTile', () => { it('should return vector tile containing document', async () => { const resp = await supertest .get( @@ -32,8 +40,12 @@ export default function ({ getService }) { const layer = jsonTile.layers[MVT_SOURCE_LAYER_NAME]; expect(layer.length).to.be(3); // 2 docs + the metadata feature - // 1st doc - const feature = layer.feature(0); + // Verify ES document + + const feature = findFeature(layer, (feature) => { + return feature.properties._id === 'AU_x3_BsGFA8no6Qjjug'; + }); + expect(feature).not.to.be(undefined); expect(feature.type).to.be(1); expect(feature.extent).to.be(4096); expect(feature.id).to.be(undefined); @@ -46,8 +58,11 @@ export default function ({ getService }) { }); expect(feature.loadGeometry()).to.eql([[{ x: 44, y: 2382 }]]); - // Metadata feature - const metadataFeature = layer.feature(2); + // Verify metadata feature + const metadataFeature = findFeature(layer, (feature) => { + return feature.properties.__kbn_metadata_feature__; + }); + expect(metadataFeature).not.to.be(undefined); expect(metadataFeature.type).to.be(3); expect(metadataFeature.extent).to.be(4096); expect(metadataFeature.id).to.be(undefined); diff --git a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js index d1da193ac50a5..e24ba5b673169 100644 --- a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js +++ b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js @@ -11,8 +11,7 @@ export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const security = getService('security'); - // FLAKY: https://github.com/elastic/kibana/issues/114418 - describe.skip('docvalue_fields', () => { + describe('docvalue_fields', () => { before(async () => { await security.testUser.setRoles(['global_maps_read', 'test_logstash_reader'], false); }); From e1133e11ac42ede0579312dda98fa58b3790259b Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 12 Oct 2021 14:35:14 +0200 Subject: [PATCH 12/68] [Discover] Improve context code (#114284) --- .../__mocks__/index_pattern_with_timefield.ts | 2 + .../apps/context/context_app.test.tsx | 1 - .../application/apps/context/context_app.tsx | 22 +- .../apps/context/context_app_content.tsx | 8 +- .../apps/context/context_app_route.tsx | 2 +- .../apps/context/services/_stubs.ts | 13 - .../apps/context/services/anchor.test.ts | 49 ++-- .../apps/context/services/anchor.ts | 51 ++-- .../services/context.predecessors.test.ts | 236 +++++++----------- .../services/context.successors.test.ts | 225 ++++++----------- .../apps/context/services/context.ts | 148 ++++++----- .../context/services/context_state.test.ts | 31 +-- .../apps/context/services/context_state.ts | 26 +- .../utils/use_context_app_fetch.test.ts | 33 ++- .../context/utils/use_context_app_fetch.tsx | 47 ++-- .../context/utils/use_context_app_state.ts | 12 +- 16 files changed, 342 insertions(+), 564 deletions(-) diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index f1f1d74f3af3a..0f64a6c67741d 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -72,6 +72,8 @@ const indexPattern = { getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', getFormatterForField: () => ({ convert: () => 'formatted' }), + isTimeNanosBased: () => false, + popularizeField: () => {}, } as unknown as IndexPattern; indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); diff --git a/src/plugins/discover/public/application/apps/context/context_app.test.tsx b/src/plugins/discover/public/application/apps/context/context_app.test.tsx index d54a4f8bed247..0e50f8f714a2c 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.test.tsx @@ -26,7 +26,6 @@ const mockNavigationPlugin = { ui: { TopNavMenu: mockTopNavMenu } }; describe('ContextApp test', () => { const defaultProps = { indexPattern: indexPatternMock, - indexPatternId: 'the-index-pattern-id', anchorId: 'mocked_anchor_id', }; diff --git a/src/plugins/discover/public/application/apps/context/context_app.tsx b/src/plugins/discover/public/application/apps/context/context_app.tsx index 070391edae71c..9d39c93d250f2 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.tsx @@ -12,7 +12,7 @@ import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText, EuiPageContent, EuiPage, EuiSpacer } from '@elastic/eui'; import { cloneDeep } from 'lodash'; -import { esFilters, SortDirection } from '../../../../../data/public'; +import { esFilters } from '../../../../../data/public'; import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; import { ContextErrorMessage } from './components/context_error_message'; import { IndexPattern, IndexPatternField } from '../../../../../data/common'; @@ -31,21 +31,20 @@ const ContextAppContentMemoized = memo(ContextAppContent); export interface ContextAppProps { indexPattern: IndexPattern; - indexPatternId: string; anchorId: string; } -export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAppProps) => { +export const ContextApp = ({ indexPattern, anchorId }: ContextAppProps) => { const services = getServices(); - const { uiSettings: config, capabilities, indexPatterns, navigation, filterManager } = services; + const { uiSettings, capabilities, indexPatterns, navigation, filterManager } = services; - const isLegacy = useMemo(() => config.get(DOC_TABLE_LEGACY), [config]); - const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); + const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); /** * Context app state */ - const { appState, setAppState } = useContextAppState({ indexPattern, services }); + const { appState, setAppState } = useContextAppState({ services }); const prevAppState = useRef(); /** @@ -54,7 +53,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp const { fetchedState, fetchContextRows, fetchAllRows, fetchSurroundingRows } = useContextAppFetch( { anchorId, - indexPatternId, indexPattern, appState, useNewFieldsApi, @@ -79,7 +77,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp prevAppState.current = cloneDeep(appState); }, [ appState, - indexPatternId, anchorId, fetchContextRows, fetchAllRows, @@ -89,7 +86,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp const { columns, onAddColumn, onRemoveColumn, onSetColumns } = useDataGridColumns({ capabilities, - config, + config: uiSettings, indexPattern, indexPatterns, state: appState, @@ -112,7 +109,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp field, values, operation, - indexPatternId + indexPattern.id! ); filterManager.addFilters(newFilters); if (indexPatterns) { @@ -120,7 +117,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp await popularizeField(indexPattern, fieldName, indexPatterns, capabilities); } }, - [filterManager, indexPatternId, indexPatterns, indexPattern, capabilities] + [filterManager, indexPatterns, indexPattern, capabilities] ); const TopNavMenu = navigation.ui.TopNavMenu; @@ -166,7 +163,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp onAddColumn={onAddColumn} onRemoveColumn={onRemoveColumn} onSetColumns={onSetColumns} - sort={appState.sort as [[string, SortDirection]]} predecessorCount={appState.predecessorCount} successorCount={appState.successorCount} setAppState={setAppState} diff --git a/src/plugins/discover/public/application/apps/context/context_app_content.tsx b/src/plugins/discover/public/application/apps/context/context_app_content.tsx index 19b6bfac2876c..2d4d3cab250f3 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_content.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_content.tsx @@ -22,6 +22,7 @@ import { DiscoverServices } from '../../../build_services'; import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from './utils/constants'; import { DocTableContext } from '../main/components/doc_table/doc_table_context'; import { EsHitRecordList } from '../../types'; +import { SortPairArr } from '../main/components/doc_table/lib/get_sort'; export interface ContextAppContentProps { columns: string[]; @@ -33,7 +34,6 @@ export interface ContextAppContentProps { predecessorCount: number; successorCount: number; rows: EsHitRecordList; - sort: [[string, SortDirection]]; predecessors: EsHitRecordList; successors: EsHitRecordList; anchorStatus: LoadingStatus; @@ -65,7 +65,6 @@ export function ContextAppContent({ predecessorCount, successorCount, rows, - sort, predecessors, successors, anchorStatus, @@ -111,6 +110,9 @@ export function ContextAppContent({ }, [setAppState] ); + const sort = useMemo(() => { + return [[indexPattern.timeFieldName!, SortDirection.desc]]; + }, [indexPattern]); return ( @@ -149,7 +151,7 @@ export function ContextAppContent({ expandedDoc={expandedDoc} isLoading={isAnchorLoading} sampleSize={0} - sort={sort} + sort={sort as SortPairArr[]} isSortEnabled={false} showTimeCol={showTimeCol} services={services} diff --git a/src/plugins/discover/public/application/apps/context/context_app_route.tsx b/src/plugins/discover/public/application/apps/context/context_app_route.tsx index 4bade3d03d993..d124fd6cfa395 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_route.tsx @@ -49,5 +49,5 @@ export function ContextAppRoute(props: ContextAppProps) { return ; } - return ; + return ; } diff --git a/src/plugins/discover/public/application/apps/context/services/_stubs.ts b/src/plugins/discover/public/application/apps/context/services/_stubs.ts index 7e1473b876afc..e8d09e548c07a 100644 --- a/src/plugins/discover/public/application/apps/context/services/_stubs.ts +++ b/src/plugins/discover/public/application/apps/context/services/_stubs.ts @@ -9,7 +9,6 @@ import sinon from 'sinon'; import moment from 'moment'; -import { IndexPatternsContract } from '../../../../../../data/public'; import { EsHitRecordList } from '../../../types'; type SortHit = { @@ -18,18 +17,6 @@ type SortHit = { sort: [number, number]; }; -export function createIndexPatternsStub() { - return { - get: sinon.spy((indexPatternId) => - Promise.resolve({ - id: indexPatternId, - isTimeNanosBased: () => false, - popularizeField: () => {}, - }) - ), - } as unknown as IndexPatternsContract; -} - /** * A stubbed search source with a `fetch` method that returns all of `_stubHits`. */ diff --git a/src/plugins/discover/public/application/apps/context/services/anchor.test.ts b/src/plugins/discover/public/application/apps/context/services/anchor.test.ts index b4a76fa45ec2f..8886c8ab11f64 100644 --- a/src/plugins/discover/public/application/apps/context/services/anchor.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/anchor.test.ts @@ -6,30 +6,29 @@ * Side Public License, v 1. */ -import { EsQuerySortValue, SortDirection } from '../../../../../../data/public'; -import { createIndexPatternsStub, createSearchSourceStub } from './_stubs'; -import { fetchAnchorProvider, updateSearchSource } from './anchor'; +import { IndexPattern, SortDirection } from '../../../../../../data/public'; +import { createSearchSourceStub } from './_stubs'; +import { fetchAnchor, updateSearchSource } from './anchor'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; -import { EsHitRecord, EsHitRecordList } from '../../../types'; +import { EsHitRecordList } from '../../../types'; describe('context app', function () { - let fetchAnchor: ( - indexPatternId: string, - anchorId: string, - sort: EsQuerySortValue[] - ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let searchSourceStub: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchAnchor', function () { beforeEach(() => { searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }] as unknown as EsHitRecordList); - fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub); }); it('should use the `fetch` method of the SearchSource', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -38,7 +37,7 @@ describe('context app', function () { }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -49,7 +48,7 @@ describe('context app', function () { }); it('should set the SearchSource index pattern', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -59,7 +58,7 @@ describe('context app', function () { }); it('should set the SearchSource version flag to true', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -70,7 +69,7 @@ describe('context app', function () { }); it('should set the SearchSource size to 1', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -81,7 +80,7 @@ describe('context app', function () { }); it('should set the SearchSource query to an ids query', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -103,7 +102,7 @@ describe('context app', function () { }); it('should set the SearchSource sort order', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -145,7 +144,7 @@ describe('context app', function () { it('should reject with an error when no hits were found', function () { searchSourceStub._stubHits = []; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then( @@ -161,7 +160,7 @@ describe('context app', function () { it('should return the first hit after adding an anchor marker', function () { searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }]; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then((anchorDocument) => { @@ -174,16 +173,18 @@ describe('context app', function () { describe('useNewFields API', () => { beforeEach(() => { searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }] as unknown as EsHitRecordList); - fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub, true); }); it('should request fields if useNewFieldsApi set', function () { searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }]; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ - { '@timestamp': SortDirection.desc }, - { _doc: SortDirection.desc }, - ]).then(() => { + return fetchAnchor( + 'id', + indexPattern, + searchSourceStub, + [{ '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }], + true + ).then(() => { const setFieldsSpy = searchSourceStub.setField.withArgs('fields'); const removeFieldsSpy = searchSourceStub.removeField.withArgs('fieldsFromSource'); expect(setFieldsSpy.calledOnce).toBe(true); diff --git a/src/plugins/discover/public/application/apps/context/services/anchor.ts b/src/plugins/discover/public/application/apps/context/services/anchor.ts index 2d64f7526ffdd..f262d440b8a28 100644 --- a/src/plugins/discover/public/application/apps/context/services/anchor.ts +++ b/src/plugins/discover/public/application/apps/context/services/anchor.ts @@ -6,46 +6,35 @@ * Side Public License, v 1. */ -import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - ISearchSource, - IndexPatternsContract, - EsQuerySortValue, - IndexPattern, -} from '../../../../../../data/public'; +import { ISearchSource, EsQuerySortValue, IndexPattern } from '../../../../../../data/public'; import { EsHitRecord } from '../../../types'; -export function fetchAnchorProvider( - indexPatterns: IndexPatternsContract, +export async function fetchAnchor( + anchorId: string, + indexPattern: IndexPattern, searchSource: ISearchSource, + sort: EsQuerySortValue[], useNewFieldsApi: boolean = false -) { - return async function fetchAnchor( - indexPatternId: string, - anchorId: string, - sort: EsQuerySortValue[] - ): Promise { - const indexPattern = await indexPatterns.get(indexPatternId); - updateSearchSource(searchSource, anchorId, sort, useNewFieldsApi, indexPattern); +): Promise { + updateSearchSource(searchSource, anchorId, sort, useNewFieldsApi, indexPattern); - const response = await searchSource.fetch(); - const doc = get(response, ['hits', 'hits', 0]); + const response = await searchSource.fetch(); + const doc = response.hits?.hits?.[0]; - if (!doc) { - throw new Error( - i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { - defaultMessage: 'Failed to load anchor document.', - }) - ); - } + if (!doc) { + throw new Error( + i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { + defaultMessage: 'Failed to load anchor document.', + }) + ); + } - return { - ...doc, - isAnchor: true, - } as EsHitRecord; - }; + return { + ...doc, + isAnchor: true, + } as EsHitRecord; } export function updateSearchSource( diff --git a/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts b/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts index 028dec7b9fe19..9bcf6f9c90d2c 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts @@ -8,9 +8,9 @@ import moment from 'moment'; import { get, last } from 'lodash'; -import { SortDirection } from 'src/plugins/data/common'; -import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; -import { fetchContextProvider, SurrDocType } from './context'; +import { IndexPattern, SortDirection } from 'src/plugins/data/common'; +import { createContextSearchSourceStub } from './_stubs'; +import { fetchSurroundingDocs, SurrDocType } from './context'; import { setServices } from '../../../../kibana_services'; import { Query } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; @@ -30,9 +30,6 @@ interface Timestamp { describe('context predecessors', function () { let fetchPredecessors: ( - indexPatternId: string, - timeField: string, - sortDir: SortDirection, timeValIso: string, timeValNr: number, tieBreakerField: string, @@ -41,6 +38,12 @@ describe('context predecessors', function () { ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockSearchSource: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + timeFieldName: '@timestamp', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchPredecessors', function () { beforeEach(() => { @@ -56,30 +59,20 @@ describe('context predecessors', function () { }, } as unknown as DiscoverServices); - fetchPredecessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size = 10 - ) => { + fetchPredecessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size = 10) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.PREDECESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, [] ); @@ -95,19 +88,12 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { @@ -119,33 +105,26 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 2990), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 6 - ).then((hits: EsHitRecordList) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: string) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - expect( - intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) - ).toBe(true); - // should have started at the given time - expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have ended with a half-open interval - expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'gte']); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 6).then( + (hits: EsHitRecordList) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: string) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'gte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); it('should perform multiple queries until the expected hit count is returned', function () { @@ -156,57 +135,41 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_1000, - MS_PER_DAY * 1000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: string) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => { - return get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']); - }); - - // should have started at the given time - expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString()); - // should have stopped before reaching MS_PER_DAY * 1700 - expect(moment(last(intervals)?.lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700); - expect(intervals.length).toBeGreaterThan(1); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_1000, MS_PER_DAY * 1000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: string) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => { + return get(query, [ + 'bool', + 'must', + 'constant_score', + 'filter', + 'range', + '@timestamp', + ]); + }); + + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 1700 + expect(moment(last(intervals)?.lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700); + expect(intervals.length).toBeGreaterThan(1); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should return an empty array when no hits were found', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - expect(hits).toEqual([]); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + expect(hits).toEqual([]); + } + ); }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then(() => { + return fetchPredecessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then(() => { const setParentSpy = mockSearchSource.setParent; expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); expect(setParentSpy.called).toBe(true); @@ -214,16 +177,7 @@ describe('context predecessors', function () { }); it('should set the tiebreaker sort order to the opposite as the time field', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP, - MS_PER_DAY, - '_doc', - 0, - 3 - ).then(() => { + return fetchPredecessors(ANCHOR_TIMESTAMP, MS_PER_DAY, '_doc', 0, 3).then(() => { expect( mockSearchSource.setField.calledWith('sort', [ { '@timestamp': { order: 'asc', format: 'strict_date_optional_time' } }, @@ -248,32 +202,23 @@ describe('context predecessors', function () { }, } as unknown as DiscoverServices); - fetchPredecessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size = 10 - ) => { + fetchPredecessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size = 10) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.PREDECESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, - [] + [], + true ); }; }); @@ -287,23 +232,16 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); - const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(removeFieldsSpy.calledOnce).toBe(true); - expect(setFieldsSpy.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); + const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(removeFieldsSpy.calledOnce).toBe(true); + expect(setFieldsSpy.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts b/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts index 656491f01f9cf..169d969753645 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts @@ -8,11 +8,11 @@ import moment from 'moment'; import { get, last } from 'lodash'; -import { SortDirection } from 'src/plugins/data/common'; -import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; +import { IndexPattern, SortDirection } from 'src/plugins/data/common'; +import { createContextSearchSourceStub } from './_stubs'; import { setServices } from '../../../../kibana_services'; import { Query } from '../../../../../../data/public'; -import { fetchContextProvider, SurrDocType } from './context'; +import { fetchSurroundingDocs, SurrDocType } from './context'; import { DiscoverServices } from '../../../../build_services'; import { EsHitRecord, EsHitRecordList } from '../../../types'; @@ -29,9 +29,6 @@ interface Timestamp { describe('context successors', function () { let fetchSuccessors: ( - indexPatternId: string, - timeField: string, - sortDir: SortDirection, timeValIso: string, timeValNr: number, tieBreakerField: string, @@ -40,6 +37,12 @@ describe('context successors', function () { ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockSearchSource: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + timeFieldName: '@timestamp', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchSuccessors', function () { beforeEach(() => { @@ -55,30 +58,20 @@ describe('context successors', function () { }, } as unknown as DiscoverServices); - fetchSuccessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size - ) => { + fetchSuccessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.SUCCESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, [] ); @@ -94,19 +87,12 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { @@ -118,33 +104,26 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 2990), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 6 - ).then((hits) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: [string]) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - expect( - intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) - ).toBe(true); - // should have started at the given time - expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have ended with a half-open interval - expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'lte']); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 6).then( + (hits) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: [string]) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'lte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should perform multiple queries until the expected hit count is returned', function () { @@ -157,58 +136,33 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 4 - ).then((hits) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: [string]) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - // should have started at the given time - expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have stopped before reaching MS_PER_DAY * 2200 - expect(moment(last(intervals)?.gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 4).then( + (hits) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: [string]) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 2200 + expect(moment(last(intervals)?.gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4)); + } + ); }); it('should return an empty array when no hits were found', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then((hits) => { + return fetchSuccessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then((hits) => { expect(hits).toEqual([]); }); }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then(() => { + return fetchSuccessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then(() => { const setParentSpy = mockSearchSource.setParent; expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); expect(setParentSpy.called).toBe(true); @@ -216,16 +170,7 @@ describe('context successors', function () { }); it('should set the tiebreaker sort order to the same as the time field', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP, - MS_PER_DAY, - '_doc', - 0, - 3 - ).then(() => { + return fetchSuccessors(ANCHOR_TIMESTAMP, MS_PER_DAY, '_doc', 0, 3).then(() => { expect( mockSearchSource.setField.calledWith('sort', [ { '@timestamp': { order: SortDirection.desc, format: 'strict_date_optional_time' } }, @@ -250,32 +195,23 @@ describe('context successors', function () { }, } as unknown as DiscoverServices); - fetchSuccessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size - ) => { + fetchSuccessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.SUCCESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, - [] + [], + true ); }; }); @@ -289,23 +225,16 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); - const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); - expect(removeFieldsSpy.calledOnce).toBe(true); - expect(setFieldsSpy.calledOnce).toBe(true); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); + const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); + expect(removeFieldsSpy.calledOnce).toBe(true); + expect(setFieldsSpy.calledOnce).toBe(true); + } + ); }); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context.ts b/src/plugins/discover/public/application/apps/context/services/context.ts index 237de8e52e656..b76b5ac648c22 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { Filter, IndexPattern, IndexPatternsContract, SearchSource } from 'src/plugins/data/public'; +import { Filter, IndexPattern, SearchSource } from 'src/plugins/data/public'; import { reverseSortDir, SortDirection } from './utils/sorting'; import { convertIsoToMillis, extractNanos } from './utils/date_conversion'; import { fetchHitsInInterval } from './utils/fetch_hits_in_interval'; @@ -25,88 +25,82 @@ const DAY_MILLIS = 24 * 60 * 60 * 1000; // look from 1 day up to 10000 days into the past and future const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000].map((days) => days * DAY_MILLIS); -function fetchContextProvider(indexPatterns: IndexPatternsContract, useNewFieldsApi?: boolean) { - return { - fetchSurroundingDocs, - }; - - /** - * Fetch successor or predecessor documents of a given anchor document - * - * @param {SurrDocType} type - `successors` or `predecessors` - * @param {string} indexPatternId - * @param {EsHitRecord} anchor - anchor record - * @param {string} timeField - name of the timefield, that's sorted on - * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field - * @param {SortDirection} sortDir - direction of sorting - * @param {number} size - number of records to retrieve - * @param {Filter[]} filters - to apply in the elastic query - * @returns {Promise} - */ - async function fetchSurroundingDocs( - type: SurrDocType, - indexPatternId: string, - anchor: EsHitRecord, - timeField: string, - tieBreakerField: string, - sortDir: SortDirection, - size: number, - filters: Filter[] - ): Promise { - if (typeof anchor !== 'object' || anchor === null || !size) { - return []; - } - const indexPattern = await indexPatterns.get(indexPatternId); - const { data } = getServices(); - const searchSource = data.search.searchSource.createEmpty() as SearchSource; - updateSearchSource(searchSource, indexPattern, filters, Boolean(useNewFieldsApi)); - const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir); - - const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : ''; - const timeValueMillis = - nanos !== '' ? convertIsoToMillis(anchor.fields[timeField][0]) : anchor.sort[0]; - - const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis as number, type, sortDir); - let documents: EsHitRecordList = []; - - for (const interval of intervals) { - const remainingSize = size - documents.length; - - if (remainingSize <= 0) { - break; - } +/** + * Fetch successor or predecessor documents of a given anchor document + * + * @param {SurrDocType} type - `successors` or `predecessors` + * @param {IndexPattern} indexPattern + * @param {EsHitRecord} anchor - anchor record + * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field + * @param {SortDirection} sortDir - direction of sorting + * @param {number} size - number of records to retrieve + * @param {Filter[]} filters - to apply in the elastic query + * @param {boolean} useNewFieldsApi + * @returns {Promise} + */ +export async function fetchSurroundingDocs( + type: SurrDocType, + indexPattern: IndexPattern, + anchor: EsHitRecord, + tieBreakerField: string, + sortDir: SortDirection, + size: number, + filters: Filter[], + useNewFieldsApi?: boolean +): Promise { + if (typeof anchor !== 'object' || anchor === null || !size) { + return []; + } + const { data } = getServices(); + const timeField = indexPattern.timeFieldName!; + const searchSource = data.search.searchSource.createEmpty() as SearchSource; + updateSearchSource(searchSource, indexPattern, filters, Boolean(useNewFieldsApi)); + const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir); - const searchAfter = getEsQuerySearchAfter( - type, - documents, - timeField, - anchor, - nanos, - useNewFieldsApi - ); + const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : ''; + const timeValueMillis = + nanos !== '' ? convertIsoToMillis(anchor.fields[timeField][0]) : anchor.sort[0]; - const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); + const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis as number, type, sortDir); + let documents: EsHitRecordList = []; - const hits = await fetchHitsInInterval( - searchSource, - timeField, - sort, - sortDirToApply, - interval, - searchAfter, - remainingSize, - nanos, - anchor._id - ); + for (const interval of intervals) { + const remainingSize = size - documents.length; - documents = - type === SurrDocType.SUCCESSORS - ? [...documents, ...hits] - : [...hits.slice().reverse(), ...documents]; + if (remainingSize <= 0) { + break; } - return documents; + const searchAfter = getEsQuerySearchAfter( + type, + documents, + timeField, + anchor, + nanos, + useNewFieldsApi + ); + + const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); + + const hits = await fetchHitsInInterval( + searchSource, + timeField, + sort, + sortDirToApply, + interval, + searchAfter, + remainingSize, + nanos, + anchor._id + ); + + documents = + type === SurrDocType.SUCCESSORS + ? [...documents, ...hits] + : [...hits.slice().reverse(), ...documents]; } + + return documents; } export function updateSearchSource( @@ -125,5 +119,3 @@ export function updateSearchSource( .setField('filter', filters) .setField('trackTotalHits', false); } - -export { fetchContextProvider }; diff --git a/src/plugins/discover/public/application/apps/context/services/context_state.test.ts b/src/plugins/discover/public/application/apps/context/services/context_state.test.ts index 3e5acccff634e..3df8ab710729f 100644 --- a/src/plugins/discover/public/application/apps/context/services/context_state.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context_state.test.ts @@ -24,7 +24,6 @@ describe('Test Discover Context State', () => { history.push('/'); state = getState({ defaultSize: 4, - timeFieldName: 'time', history, uiSettings: { get: (key: string) => @@ -44,12 +43,6 @@ describe('Test Discover Context State', () => { ], "filters": Array [], "predecessorCount": 4, - "sort": Array [ - Array [ - "time", - "desc", - ], - ], "successorCount": 4, } `); @@ -62,41 +55,29 @@ describe('Test Discover Context State', () => { state.setAppState({ predecessorCount: 10 }); state.flushToUrl(); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(!(time,desc)),successorCount:4)"` + `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,successorCount:4)"` ); }); test('getState -> url to appState syncing', async () => { - history.push( - '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' - ); + history.push('/#?_a=(columns:!(_source),predecessorCount:1,successorCount:1)'); expect(state.appState.getState()).toMatchInlineSnapshot(` Object { "columns": Array [ "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); }); test('getState -> url to appState syncing with return to a url without state', async () => { - history.push( - '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' - ); + history.push('/#?_a=(columns:!(_source),predecessorCount:1,successorCount:1)'); expect(state.appState.getState()).toMatchInlineSnapshot(` Object { "columns": Array [ "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); @@ -107,10 +88,6 @@ describe('Test Discover Context State', () => { "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); @@ -183,7 +160,7 @@ describe('Test Discover Context State', () => { `); state.flushToUrl(); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match_phrase:(extension:(query:jpg))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match_phrase:(extension:(query:png))))),predecessorCount:4,sort:!(!(time,desc)),successorCount:4)"` + `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match_phrase:(extension:(query:jpg))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match_phrase:(extension:(query:png))))),predecessorCount:4,successorCount:4)"` ); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context_state.ts b/src/plugins/discover/public/application/apps/context/services/context_state.ts index 582ca196e3484..87f7cf00bafcf 100644 --- a/src/plugins/discover/public/application/apps/context/services/context_state.ts +++ b/src/plugins/discover/public/application/apps/context/services/context_state.ts @@ -16,7 +16,7 @@ import { withNotifyOnErrors, ReduxLikeStateContainer, } from '../../../../../../kibana_utils/public'; -import { esFilters, FilterManager, Filter, SortDirection } from '../../../../../../data/public'; +import { esFilters, FilterManager, Filter } from '../../../../../../data/public'; import { handleSourceColumnState } from '../../../helpers/state_helpers'; export interface AppState { @@ -32,14 +32,16 @@ export interface AppState { * Number of records to be fetched before anchor records (newer records) */ predecessorCount: number; - /** - * Sorting of the records to be fetched, assumed to be a legacy parameter - */ - sort: string[][]; /** * Number of records to be fetched after the anchor records (older records) */ successorCount: number; + /** + * Array of the used sorting [[field,direction],...] + * this is actually not needed in Discover Context, there's no sorting + * but it's used in the DocTable component + */ + sort?: string[][]; } interface GlobalState { @@ -54,10 +56,6 @@ export interface GetStateParams { * Number of records to be fetched when 'Load' link/button is clicked */ defaultSize: number; - /** - * The timefield used for sorting - */ - timeFieldName: string; /** * Determins the use of long vs. short/hashed urls */ @@ -124,7 +122,6 @@ const APP_STATE_URL_KEY = '_a'; */ export function getState({ defaultSize, - timeFieldName, storeInSessionStorage = false, history, toasts, @@ -140,12 +137,7 @@ export function getState({ const globalStateContainer = createStateContainer(globalStateInitial); const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; - const appStateInitial = createInitialAppState( - defaultSize, - timeFieldName, - appStateFromUrl, - uiSettings - ); + const appStateInitial = createInitialAppState(defaultSize, appStateFromUrl, uiSettings); const appStateContainer = createStateContainer(appStateInitial); const { start, stop } = syncStates([ @@ -267,7 +259,6 @@ function getFilters(state: AppState | GlobalState): Filter[] { */ function createInitialAppState( defaultSize: number, - timeFieldName: string, urlState: AppState, uiSettings: IUiSettingsClient ): AppState { @@ -276,7 +267,6 @@ function createInitialAppState( filters: [], predecessorCount: defaultSize, successorCount: defaultSize, - sort: [[timeFieldName, SortDirection.desc]], }; if (typeof urlState !== 'object') { return defaultState; diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts index 5efd5e1195c5d..b3626f9c06f10 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts @@ -8,12 +8,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { setServices, getServices } from '../../../../kibana_services'; -import { SortDirection } from '../../../../../../data/public'; import { createFilterManagerMock } from '../../../../../../data/public/query/filter_manager/filter_manager.mock'; import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../../common'; import { DiscoverServices } from '../../../../build_services'; -import { indexPatternMock } from '../../../../__mocks__/index_pattern'; -import { indexPatternsMock } from '../../../../__mocks__/index_patterns'; import { FailureReason, LoadingStatus } from '../services/context_query_state'; import { ContextAppFetchProps, useContextAppFetch } from './use_context_app_fetch'; import { @@ -21,6 +18,9 @@ import { mockPredecessorHits, mockSuccessorHits, } from '../__mocks__/use_context_app_fetch'; +import { indexPatternWithTimefieldMock } from '../../../../__mocks__/index_pattern_with_timefield'; +import { createContextSearchSourceStub } from '../services/_stubs'; +import { IndexPattern } from '../../../../../../data_views/common'; const mockFilterManager = createFilterManagerMock(); @@ -28,20 +28,19 @@ jest.mock('../services/context', () => { const originalModule = jest.requireActual('../services/context'); return { ...originalModule, - fetchContextProvider: () => ({ - fetchSurroundingDocs: (type: string, indexPatternId: string) => { - if (!indexPatternId) { - throw new Error(); - } - return type === 'predecessors' ? mockPredecessorHits : mockSuccessorHits; - }, - }), + + fetchSurroundingDocs: (type: string, indexPattern: IndexPattern) => { + if (!indexPattern || !indexPattern.id) { + throw new Error(); + } + return type === 'predecessors' ? mockPredecessorHits : mockSuccessorHits; + }, }; }); jest.mock('../services/anchor', () => ({ - fetchAnchorProvider: () => (indexPatternId: string) => { - if (!indexPatternId) { + fetchAnchor: (anchorId: string, indexPattern: IndexPattern) => { + if (!indexPattern.id || !anchorId) { throw new Error(); } return mockAnchorHit; @@ -50,16 +49,16 @@ jest.mock('../services/anchor', () => ({ const initDefaults = (tieBreakerFields: string[], indexPatternId = 'the-index-pattern-id') => { const dangerNotification = jest.fn(); + const mockSearchSource = createContextSearchSourceStub('timestamp'); setServices({ data: { search: { searchSource: { - createEmpty: jest.fn(), + createEmpty: jest.fn().mockImplementation(() => mockSearchSource), }, }, }, - indexPatterns: indexPatternsMock, toastNotifications: { addDanger: dangerNotification }, core: { notifications: { toasts: [] } }, history: () => {}, @@ -77,10 +76,8 @@ const initDefaults = (tieBreakerFields: string[], indexPatternId = 'the-index-pa dangerNotification, props: { anchorId: 'mock_anchor_id', - indexPatternId, - indexPattern: indexPatternMock, + indexPattern: { ...indexPatternWithTimefieldMock, id: indexPatternId }, appState: { - sort: [['order_date', SortDirection.desc]], predecessorCount: 2, successorCount: 2, }, diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx index fa6a761397335..ed3b4e8ed5b5a 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx @@ -7,11 +7,10 @@ */ import React, { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { fromPairs } from 'lodash'; import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../../common'; import { DiscoverServices } from '../../../../build_services'; -import { fetchAnchorProvider } from '../services/anchor'; -import { fetchContextProvider, SurrDocType } from '../services/context'; +import { fetchAnchor } from '../services/anchor'; +import { fetchSurroundingDocs, SurrDocType } from '../services/context'; import { MarkdownSimple, toMountPoint } from '../../../../../../kibana_react/public'; import { IndexPattern, SortDirection } from '../../../../../../data/public'; import { @@ -30,7 +29,6 @@ const createError = (statusKey: string, reason: FailureReason, error?: Error) => export interface ContextAppFetchProps { anchorId: string; - indexPatternId: string; indexPattern: IndexPattern; appState: AppState; useNewFieldsApi: boolean; @@ -39,13 +37,12 @@ export interface ContextAppFetchProps { export function useContextAppFetch({ anchorId, - indexPatternId, indexPattern, appState, useNewFieldsApi, services, }: ContextAppFetchProps) { - const { uiSettings: config, data, indexPatterns, toastNotifications, filterManager } = services; + const { uiSettings: config, data, toastNotifications, filterManager } = services; const searchSource = useMemo(() => { return data.search.searchSource.createEmpty(); @@ -54,13 +51,6 @@ export function useContextAppFetch({ () => getFirstSortableField(indexPattern, config.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)), [config, indexPattern] ); - const fetchAnchor = useMemo(() => { - return fetchAnchorProvider(indexPatterns, searchSource, useNewFieldsApi); - }, [indexPatterns, searchSource, useNewFieldsApi]); - const { fetchSurroundingDocs } = useMemo( - () => fetchContextProvider(indexPatterns, useNewFieldsApi), - [indexPatterns, useNewFieldsApi] - ); const [fetchedState, setFetchedState] = useState( getInitialContextQueryState() @@ -71,8 +61,6 @@ export function useContextAppFetch({ }, []); const fetchAnchorRow = useCallback(async () => { - const { sort } = appState; - const [[, sortDir]] = sort; const errorTitle = i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', { defaultMessage: 'Unable to load the anchor document', }); @@ -94,10 +82,11 @@ export function useContextAppFetch({ try { setState({ anchorStatus: { value: LoadingStatus.LOADING } }); - const anchor = await fetchAnchor(indexPatternId, anchorId, [ - fromPairs(sort), - { [tieBreakerField]: sortDir }, - ]); + const sort = [ + { [indexPattern.timeFieldName!]: SortDirection.desc }, + { [tieBreakerField]: SortDirection.desc }, + ]; + const anchor = await fetchAnchor(anchorId, indexPattern, searchSource, sort, useNewFieldsApi); setState({ anchor, anchorStatus: { value: LoadingStatus.LOADED } }); return anchor; } catch (error) { @@ -108,20 +97,18 @@ export function useContextAppFetch({ }); } }, [ - appState, tieBreakerField, setState, toastNotifications, - fetchAnchor, - indexPatternId, + indexPattern, anchorId, + searchSource, + useNewFieldsApi, ]); const fetchSurroundingRows = useCallback( async (type: SurrDocType, fetchedAnchor?: EsHitRecord) => { const filters = filterManager.getFilters(); - const { sort } = appState; - const [[sortField, sortDir]] = sort; const count = type === SurrDocType.PREDECESSORS ? appState.predecessorCount : appState.successorCount; @@ -135,13 +122,13 @@ export function useContextAppFetch({ setState({ [statusKey]: { value: LoadingStatus.LOADING } }); const rows = await fetchSurroundingDocs( type, - indexPatternId, + indexPattern, anchor as EsHitRecord, - sortField, tieBreakerField, - sortDir as SortDirection, + SortDirection.desc, count, - filters + filters, + useNewFieldsApi ); setState({ [type]: rows, [statusKey]: { value: LoadingStatus.LOADED } }); } catch (error) { @@ -158,9 +145,9 @@ export function useContextAppFetch({ fetchedState.anchor, tieBreakerField, setState, - fetchSurroundingDocs, - indexPatternId, + indexPattern, toastNotifications, + useNewFieldsApi, ] ); diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts b/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts index 3e968b5dfb82e..56701f17c7a63 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts @@ -9,29 +9,21 @@ import { useEffect, useMemo, useState } from 'react'; import { cloneDeep } from 'lodash'; import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../../../common'; -import { IndexPattern } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; import { AppState, getState } from '../services/context_state'; -export function useContextAppState({ - indexPattern, - services, -}: { - indexPattern: IndexPattern; - services: DiscoverServices; -}) { +export function useContextAppState({ services }: { services: DiscoverServices }) { const { uiSettings: config, history, core, filterManager } = services; const stateContainer = useMemo(() => { return getState({ defaultSize: parseInt(config.get(CONTEXT_DEFAULT_SIZE_SETTING), 10), - timeFieldName: indexPattern.timeFieldName!, storeInSessionStorage: config.get('state:storeInSessionStorage'), history: history(), toasts: core.notifications.toasts, uiSettings: config, }); - }, [config, history, indexPattern, core.notifications.toasts]); + }, [config, history, core.notifications.toasts]); const [appState, setState] = useState(stateContainer.appState.getState()); From c3f1e0de541901679de4cb7b2317b5b1f6b58e4f Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 12 Oct 2021 14:35:45 +0200 Subject: [PATCH 13/68] [FieldFormats] Remove SerializedFieldFormat copy from expressions plugin (#114245) --- src/plugins/data/common/search/aggs/agg_config.ts | 7 ++----- src/plugins/data/common/search/aggs/agg_type.ts | 3 ++- .../data/common/search/aggs/utils/get_aggs_formats.ts | 2 +- src/plugins/data_views/common/data_views/data_view.ts | 7 +++++-- src/plugins/data_views/common/types.ts | 3 +-- .../apps/main/components/chart/point_series.ts | 2 +- .../common/expression_types/specs/datatable.ts | 2 +- src/plugins/expressions/common/types/common.ts | 11 ----------- src/plugins/expressions/common/types/index.ts | 8 +------- src/plugins/expressions/public/index.ts | 1 - src/plugins/expressions/server/index.ts | 1 - src/plugins/expressions/tsconfig.json | 1 + src/plugins/vis_types/pie/public/types/types.ts | 3 ++- .../common/expression_functions/xy_dimension.ts | 2 +- src/plugins/visualizations/public/vis_schemas.ts | 2 +- x-pack/plugins/lens/common/types.ts | 7 +++++-- .../public/xy_visualization/axes_configuration.ts | 7 +++++-- 17 files changed, 29 insertions(+), 40 deletions(-) diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts index 1a70a41e72dd5..4cb091787c058 100644 --- a/src/plugins/data/common/search/aggs/agg_config.ts +++ b/src/plugins/data/common/search/aggs/agg_config.ts @@ -13,11 +13,8 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { Assign, Ensure } from '@kbn/utility-types'; import { ISearchOptions, ISearchSource } from 'src/plugins/data/public'; -import { - ExpressionAstExpression, - ExpressionAstArgument, - SerializedFieldFormat, -} from 'src/plugins/expressions/common'; +import { ExpressionAstExpression, ExpressionAstArgument } from 'src/plugins/expressions/common'; +import type { SerializedFieldFormat } from 'src/plugins/field_formats/common'; import { IAggType } from './agg_type'; import { writeParams } from './agg_params'; diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts index 48ce54bbd61bd..ebc1705f6c01b 100644 --- a/src/plugins/data/common/search/aggs/agg_type.ts +++ b/src/plugins/data/common/search/aggs/agg_type.ts @@ -10,8 +10,9 @@ import { constant, noop, identity } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ISearchSource } from 'src/plugins/data/public'; -import { DatatableColumnType, SerializedFieldFormat } from 'src/plugins/expressions/common'; +import { DatatableColumnType } from 'src/plugins/expressions/common'; import type { RequestAdapter } from 'src/plugins/inspector/common'; +import type { SerializedFieldFormat } from 'src/plugins/field_formats/common'; import { estypes } from '@elastic/elasticsearch'; import { initParams } from './agg_params'; diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts index 2aead866c6b60..1652f51477e64 100644 --- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts +++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts @@ -9,12 +9,12 @@ /* eslint-disable max-classes-per-file */ import { i18n } from '@kbn/i18n'; -import { SerializedFieldFormat } from 'src/plugins/expressions/common/types'; import { FieldFormat, FieldFormatInstanceType, FieldFormatsContentType, IFieldFormat, + SerializedFieldFormat, } from '../../../../../field_formats/common'; import { DateRange } from '../../expressions'; import { convertDateRangeToString } from '../buckets/lib/date_range'; diff --git a/src/plugins/data_views/common/data_views/data_view.ts b/src/plugins/data_views/common/data_views/data_view.ts index 00b96cda32ad7..5768ebe635729 100644 --- a/src/plugins/data_views/common/data_views/data_view.ts +++ b/src/plugins/data_views/common/data_views/data_view.ts @@ -19,9 +19,12 @@ import { IIndexPattern, IFieldType } from '../../common'; import { DataViewField, IIndexPatternFieldList, fieldList } from '../fields'; import { formatHitProvider } from './format_hit'; import { flattenHitWrapper } from './flatten_hit'; -import { FieldFormatsStartCommon, FieldFormat } from '../../../field_formats/common'; +import { + FieldFormatsStartCommon, + FieldFormat, + SerializedFieldFormat, +} from '../../../field_formats/common'; import { DataViewSpec, TypeMeta, SourceFilter, DataViewFieldMap } from '../types'; -import { SerializedFieldFormat } from '../../../expressions/common'; interface DataViewDeps { spec?: DataViewSpec; diff --git a/src/plugins/data_views/common/types.ts b/src/plugins/data_views/common/types.ts index 2b184bc1ef2a4..bbc5ad374636f 100644 --- a/src/plugins/data_views/common/types.ts +++ b/src/plugins/data_views/common/types.ts @@ -13,9 +13,8 @@ import type { SavedObject } from 'src/core/server'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { IFieldType } from './fields'; import { RUNTIME_FIELD_TYPES } from './constants'; -import { SerializedFieldFormat } from '../../expressions/common'; import { DataViewField } from './fields'; -import { FieldFormat } from '../../field_formats/common'; +import { FieldFormat, SerializedFieldFormat } from '../../field_formats/common'; export type FieldFormatMap = Record; diff --git a/src/plugins/discover/public/application/apps/main/components/chart/point_series.ts b/src/plugins/discover/public/application/apps/main/components/chart/point_series.ts index 1245b712e6fd7..ee057fcb48c6a 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/point_series.ts +++ b/src/plugins/discover/public/application/apps/main/components/chart/point_series.ts @@ -9,7 +9,7 @@ import { uniq } from 'lodash'; import { Duration, Moment } from 'moment'; import { Unit } from '@elastic/datemath'; -import { SerializedFieldFormat } from '../../../../../../../expressions/common'; +import type { SerializedFieldFormat } from '../../../../../../../field_formats/common'; export interface Column { id: string; diff --git a/src/plugins/expressions/common/expression_types/specs/datatable.ts b/src/plugins/expressions/common/expression_types/specs/datatable.ts index b45c36950f870..a07f103d12e06 100644 --- a/src/plugins/expressions/common/expression_types/specs/datatable.ts +++ b/src/plugins/expressions/common/expression_types/specs/datatable.ts @@ -8,11 +8,11 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { map, pick, zipObject } from 'lodash'; +import type { SerializedFieldFormat } from 'src/plugins/field_formats/common'; import { ExpressionTypeDefinition, ExpressionValueBoxed } from '../types'; import { PointSeries, PointSeriesColumn } from './pointseries'; import { ExpressionValueRender } from './render'; -import { SerializedFieldFormat } from '../../types'; const name = 'datatable'; diff --git a/src/plugins/expressions/common/types/common.ts b/src/plugins/expressions/common/types/common.ts index 64b3d00895f56..b28ff27a79ac1 100644 --- a/src/plugins/expressions/common/types/common.ts +++ b/src/plugins/expressions/common/types/common.ts @@ -46,14 +46,3 @@ export type TypeString = KnownTypeToString< * `date` is typed as a number or string, and represents a date */ export type UnmappedTypeStrings = 'date' | 'filter'; - -/** - * JSON representation of a field formatter configuration. - * Is used to carry information about how to format data in - * a data table as part of the column definition. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface SerializedFieldFormat> { - id?: string; - params?: TParams; -} diff --git a/src/plugins/expressions/common/types/index.ts b/src/plugins/expressions/common/types/index.ts index 7d672df2401ff..00a79289c0b5f 100644 --- a/src/plugins/expressions/common/types/index.ts +++ b/src/plugins/expressions/common/types/index.ts @@ -6,13 +6,7 @@ * Side Public License, v 1. */ -export { - TypeToString, - KnownTypeToString, - TypeString, - UnmappedTypeStrings, - SerializedFieldFormat, -} from './common'; +export { TypeToString, KnownTypeToString, TypeString, UnmappedTypeStrings } from './common'; export * from './style'; export * from './registry'; diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 02656943cac3e..6e8d220a3ca0c 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -97,7 +97,6 @@ export { PointSeriesRow, Range, SerializedDatatable, - SerializedFieldFormat, Style, TextAlignment, TextDecoration, diff --git a/src/plugins/expressions/server/index.ts b/src/plugins/expressions/server/index.ts index c09a8bf0104af..7c0662ad54e4b 100644 --- a/src/plugins/expressions/server/index.ts +++ b/src/plugins/expressions/server/index.ts @@ -88,7 +88,6 @@ export { PointSeriesRow, Range, SerializedDatatable, - SerializedFieldFormat, Style, TextAlignment, TextDecoration, diff --git a/src/plugins/expressions/tsconfig.json b/src/plugins/expressions/tsconfig.json index 6716149d6b9c7..d9991ff791e83 100644 --- a/src/plugins/expressions/tsconfig.json +++ b/src/plugins/expressions/tsconfig.json @@ -11,5 +11,6 @@ { "path": "../../core/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, { "path": "../inspector/tsconfig.json" }, + { "path": "../field_formats/tsconfig.json" } ] } diff --git a/src/plugins/vis_types/pie/public/types/types.ts b/src/plugins/vis_types/pie/public/types/types.ts index a1f41e80fae28..fb5efb5971805 100644 --- a/src/plugins/vis_types/pie/public/types/types.ts +++ b/src/plugins/vis_types/pie/public/types/types.ts @@ -8,7 +8,8 @@ import { Position } from '@elastic/charts'; import { UiCounterMetricType } from '@kbn/analytics'; -import { DatatableColumn, SerializedFieldFormat } from '../../../../expressions/public'; +import { DatatableColumn } from '../../../../expressions/public'; +import type { SerializedFieldFormat } from '../../../../field_formats/common'; import { ExpressionValueVisDimension } from '../../../../visualizations/public'; import { ExpressionValuePieLabels } from '../expression_functions/pie_labels'; import { PaletteOutput, ChartsPluginSetup } from '../../../../charts/public'; diff --git a/src/plugins/visualizations/common/expression_functions/xy_dimension.ts b/src/plugins/visualizations/common/expression_functions/xy_dimension.ts index 82538fea8605a..5bbddd48e9b8b 100644 --- a/src/plugins/visualizations/common/expression_functions/xy_dimension.ts +++ b/src/plugins/visualizations/common/expression_functions/xy_dimension.ts @@ -14,8 +14,8 @@ import type { ExpressionValueBoxed, Datatable, DatatableColumn, - SerializedFieldFormat, } from '../../../expressions/common'; +import type { SerializedFieldFormat } from '../../../field_formats/common'; export interface DateHistogramParams { date: boolean; diff --git a/src/plugins/visualizations/public/vis_schemas.ts b/src/plugins/visualizations/public/vis_schemas.ts index 115e13ece45ff..f80f85fb55a60 100644 --- a/src/plugins/visualizations/public/vis_schemas.ts +++ b/src/plugins/visualizations/public/vis_schemas.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SerializedFieldFormat } from '../../expressions/public'; +import type { SerializedFieldFormat } from '../../field_formats/common'; import { IAggConfig, search } from '../../data/public'; import { Vis, VisToExpressionAstParams } from './types'; diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 79450b76190fb..659d3c0eced26 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -6,8 +6,11 @@ */ import type { Filter, FilterMeta } from '@kbn/es-query'; -import type { IFieldFormat } from '../../../../src/plugins/field_formats/common'; -import type { Datatable, SerializedFieldFormat } from '../../../../src/plugins/expressions/common'; +import type { + IFieldFormat, + SerializedFieldFormat, +} from '../../../../src/plugins/field_formats/common'; +import type { Datatable } from '../../../../src/plugins/expressions/common'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts index 9c83e2c58146e..9ac0171a51084 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts @@ -7,8 +7,11 @@ import { FormatFactory } from '../../common'; import { AxisExtentConfig, XYLayerConfig } from '../../common/expressions'; -import { Datatable, SerializedFieldFormat } from '../../../../../src/plugins/expressions/public'; -import type { IFieldFormat } from '../../../../../src/plugins/field_formats/common'; +import { Datatable } from '../../../../../src/plugins/expressions/public'; +import type { + IFieldFormat, + SerializedFieldFormat, +} from '../../../../../src/plugins/field_formats/common'; interface FormattedMetric { layer: string; From 4436ed2f7127f1bb31d9ee6fdc0dae54c889ccd5 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 12 Oct 2021 09:20:11 -0400 Subject: [PATCH 14/68] [Security Solution][Endpoint] Bug fixes and unit test cases for the Trusted Apps list view under Policy Details (#113865) * test: rename mock utility * Tests: artifact grid test coverage * Fix: CardCompressedHeader should use CardCompressedHeaderLayout * Tests: ArtifactEntryCollapsibleCard test coverage * Context Menu adjustments to test ids and to avoid react console errors/warnings * add test id to truncate wrapper in ContextMenuItemWithRouterSupport * Tests for ContextMenuWithRouterSupport * new mocks test utils * HTTP mocks for Policy Details Trusted apps list page * tests for policy trusted apps selectors * Refactor: move reusable fleet http mocks to `page/mocks` * HTTP mocks for fleet get package policy and Agent status + mock for all Policy Details APIs * Tests: Policy Details Trusted Apps List * Moved `seededUUIDv4()` to `BaseDataGenerator` and changed trusted apps generator to use it * change `createStartServicesMock` to optionally accept `coreStart` as input * Show api load errors on policy TA list --- .../data_generators/base_data_generator.ts | 5 + .../data_generators/trusted_app_generator.ts | 2 +- .../common/endpoint/generate_data.ts | 16 +- .../use_endpoint_privileges.test.ts | 2 +- .../common/lib/kibana/kibana_react.mock.ts | 5 +- .../mock/endpoint/app_context_render.tsx | 7 +- .../actions_context_menu.tsx | 4 +- .../artifact_card_grid.test.tsx | 152 ++++++----- .../components/grid_header.tsx | 2 +- .../artifact_entry_card.test.tsx | 10 +- .../artifact_entry_card_minified.test.tsx | 6 +- .../artifact_entry_collapsible_card.test.tsx | 104 +++++++ .../components/card_compressed_header.tsx | 68 ++--- .../artifact_entry_card/test_utils.ts | 24 +- .../context_menu_item_nav_by_router.tsx | 3 + .../context_menu_with_router_support.test.tsx | 133 +++++++++ .../context_menu_with_router_support.tsx | 13 +- .../context_menu_with_router_support/index.ts | 1 + .../management/pages/endpoint_hosts/index.tsx | 2 + .../management/pages/endpoint_hosts/mocks.ts | 120 ++------- .../management/pages/mocks/fleet_mocks.ts | 164 ++++++++++++ .../public/management/pages/mocks/index.ts | 8 + .../selectors/trusted_apps_selectors.test.ts | 185 ++++++++++++- .../selectors/trusted_apps_selectors.ts | 9 + .../pages/policy/test_utils/index.ts | 23 +- .../pages/policy/test_utils/mocks.ts | 123 +++++++++ .../list/policy_trusted_apps_list.test.tsx | 253 ++++++++++++++++++ .../list/policy_trusted_apps_list.tsx | 34 ++- 28 files changed, 1212 insertions(+), 266 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/mocks/fleet_mocks.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/mocks/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/test_utils/mocks.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts index da3f387016b9f..6fbe54578f469 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts @@ -127,6 +127,11 @@ export class BaseDataGenerator { return uuid.v4(); } + /** generate a seeded random UUID v4 */ + protected seededUUIDv4(): string { + return uuid.v4({ random: [...this.randomNGenerator(255, 16)] }); + } + /** Generate a random number up to the max provided */ protected randomN(max: number): number { return Math.floor(this.random() * max); diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts index be0178b83be90..91c2e17a1e12d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/trusted_app_generator.ts @@ -45,7 +45,7 @@ export class TrustedAppGenerator extends BaseDataGenerator { return merge( this.generateTrustedAppForCreate(), { - id: this.randomUUID(), + id: this.seededUUIDv4(), version: this.randomString(5), created_at: this.randomPastDate(), updated_at: new Date().toISOString(), diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 1492e0e8c82c9..3e94dfaebc7fe 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -5,7 +5,6 @@ * 2.0. */ -import uuid from 'uuid'; import seedrandom from 'seedrandom'; import semverLte from 'semver/functions/lte'; import { assertNever } from '@kbn/std'; @@ -32,9 +31,10 @@ import { import { GetAgentPoliciesResponseItem, GetPackagesResponse, -} from '../../../fleet/common/types/rest_spec'; -import { EsAssetReference, KibanaAssetReference } from '../../../fleet/common/types/models'; -import { agentPolicyStatuses } from '../../../fleet/common/constants'; + EsAssetReference, + KibanaAssetReference, + agentPolicyStatuses, +} from '../../../fleet/common'; import { firstNonNullValue } from './models/ecs_safety_helpers'; import { EventOptions } from './types/generator'; import { BaseDataGenerator } from './data_generators/base_data_generator'; @@ -406,6 +406,7 @@ const alertsDefaultDataStream = { export class EndpointDocGenerator extends BaseDataGenerator { commonInfo: HostInfo; sequence: number = 0; + /** * The EndpointDocGenerator parameters * @@ -523,6 +524,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { data_stream: metadataDataStream, }; } + /** * Creates a malware alert from the simulated host represented by this EndpointDocGenerator * @param ts - Timestamp to put in the event @@ -744,6 +746,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { } return newAlert; } + /** * Creates an alert from the simulated host represented by this EndpointDocGenerator * @param ts - Timestamp to put in the event @@ -900,6 +903,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { }; return newAlert; } + /** * Returns the default DLLs used in alerts */ @@ -1871,10 +1875,6 @@ export class EndpointDocGenerator extends BaseDataGenerator { }; } - private seededUUIDv4(): string { - return uuid.v4({ random: [...this.randomNGenerator(255, 16)] }); - } - private randomHostPolicyResponseActionNames(): string[] { return this.randomArray(this.randomN(8), () => this.randomChoice([ diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts index 8e9dae9f12ad5..a05d1ac8d3588 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts @@ -8,10 +8,10 @@ import { renderHook, RenderHookResult, RenderResult } from '@testing-library/react-hooks'; import { useHttp, useCurrentUser } from '../../lib/kibana'; import { EndpointPrivileges, useEndpointPrivileges } from './use_endpoint_privileges'; -import { fleetGetCheckPermissionsHttpMock } from '../../../management/pages/endpoint_hosts/mocks'; import { securityMock } from '../../../../../security/public/mocks'; import { appRoutesService } from '../../../../../fleet/common'; import { AuthenticatedUser } from '../../../../../security/common'; +import { fleetGetCheckPermissionsHttpMock } from '../../../management/pages/mocks'; jest.mock('../../lib/kibana'); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index df821c7ac5f6d..b98618ac76412 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -91,8 +91,9 @@ export const createUseUiSetting$Mock = () => { ]; }; -export const createStartServicesMock = (): StartServices => { - const core = coreMock.createStart(); +export const createStartServicesMock = ( + core: ReturnType = coreMock.createStart() +): StartServices => { core.uiSettings.get.mockImplementation(createUseUiSettingMock()); const { storage } = createSecuritySolutionStorageMock(); const data = dataPluginMock.createStartContract(); diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index f8a77d97b8700..20d411a0437c2 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -24,8 +24,8 @@ import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { PLUGIN_ID } from '../../../../../fleet/common'; import { APP_ID, APP_PATH } from '../../../../common/constants'; import { KibanaContextProvider, KibanaServices } from '../../lib/kibana'; -import { fleetGetPackageListHttpMock } from '../../../management/pages/endpoint_hosts/mocks'; import { getDeepLinks } from '../../../app/deep_links'; +import { fleetGetPackageListHttpMock } from '../../../management/pages/mocks'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -98,10 +98,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { const depsStart = depsStartMock(); const middlewareSpy = createSpyMiddleware(); const { storage } = createSecuritySolutionStorageMock(); - const startServices: StartServices = { - ...createStartServicesMock(), - ...coreStart, - }; + const startServices: StartServices = createStartServicesMock(coreStart); const storeReducer = { ...SUB_PLUGINS_REDUCER, diff --git a/x-pack/plugins/security_solution/public/management/components/actions_context_menu/actions_context_menu.tsx b/x-pack/plugins/security_solution/public/management/components/actions_context_menu/actions_context_menu.tsx index c2f9e32f61afb..c2511a31a73ae 100644 --- a/x-pack/plugins/security_solution/public/management/components/actions_context_menu/actions_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/management/components/actions_context_menu/actions_context_menu.tsx @@ -15,10 +15,11 @@ import { EuiIconProps, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import uuid from 'uuid'; import { ContextMenuItemNavByRouter, ContextMenuItemNavByRouterProps, -} from '../context_menu_with_router_support/context_menu_item_nav_by_router'; +} from '../context_menu_with_router_support'; import { useTestIdGenerator } from '../hooks/use_test_id_generator'; export interface ActionsContextMenuProps { @@ -48,6 +49,7 @@ export const ActionsContextMenu = memo( return ( { handleCloseMenu(); if (itemProps.onClick) { diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx index a44076c8ad112..d360ca8fa168f 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/artifact_card_grid.test.tsx @@ -5,58 +5,20 @@ * 2.0. */ -import { TrustedAppGenerator } from '../../../../common/endpoint/data_generators/trusted_app_generator'; -import { cloneDeep } from 'lodash'; -import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; import React from 'react'; import { ArtifactCardGrid, ArtifactCardGridProps } from './artifact_card_grid'; - -// FIXME:PT refactor helpers below after merge of PR https://github.com/elastic/kibana/pull/113363 - -const getCommonItemDataOverrides = () => { - return { - name: 'some internal app', - description: 'this app is trusted by the company', - created_at: new Date('2021-07-01').toISOString(), - }; -}; - -const getTrustedAppProvider = () => - new TrustedAppGenerator('seed').generate(getCommonItemDataOverrides()); - -const getExceptionProvider = () => { - // cloneDeep needed because exception mock generator uses state across instances - return cloneDeep( - getExceptionListItemSchemaMock({ - ...getCommonItemDataOverrides(), - os_types: ['windows'], - updated_at: new Date().toISOString(), - created_by: 'Justa', - updated_by: 'Mara', - entries: [ - { - field: 'process.hash.*', - operator: 'included', - type: 'match', - value: '1234234659af249ddf3e40864e9fb241', - }, - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: '/one/two/three', - }, - ], - tags: ['policy:all'], - }) - ); -}; +import { fireEvent, act } from '@testing-library/react'; +import { + getExceptionProviderMock, + getTrustedAppProviderMock, +} from '../artifact_entry_card/test_utils'; +import { AnyArtifact } from '../artifact_entry_card'; describe.each([ - ['trusted apps', getTrustedAppProvider], - ['exceptions/event filters', getExceptionProvider], -])('when using the ArtifactCardGrid component %s', (_, generateItem) => { + ['trusted apps', getTrustedAppProviderMock], + ['exceptions/event filters', getExceptionProviderMock], +])('when using the ArtifactCardGrid component with %s', (_, generateItem) => { let appTestContext: AppContextTestRender; let renderResult: ReturnType; let render: ( @@ -64,34 +26,45 @@ describe.each([ ) => ReturnType; let items: ArtifactCardGridProps['items']; let pageChangeHandler: jest.Mock; - let expandCollapseHandler: jest.Mock; - let cardComponentPropsProvider: Required['cardComponentProps']; + let expandCollapseHandler: jest.MockedFunction; + let cardComponentPropsProvider: jest.MockedFunction< + Required['cardComponentProps'] + >; beforeEach(() => { items = Array.from({ length: 5 }, () => generateItem()); pageChangeHandler = jest.fn(); expandCollapseHandler = jest.fn(); - cardComponentPropsProvider = jest.fn().mockReturnValue({}); + cardComponentPropsProvider = jest.fn((item) => { + return { + 'data-test-subj': `card-${items.indexOf(item as AnyArtifact)}`, + }; + }); appTestContext = createAppRootMockRenderer(); render = (props = {}) => { - renderResult = appTestContext.render( - - ); + const gridProps: ArtifactCardGridProps = { + items, + onPageChange: pageChangeHandler!, + onExpandCollapse: expandCollapseHandler!, + cardComponentProps: cardComponentPropsProvider, + pagination: { + pageSizeOptions: [5, 10], + pageSize: 5, + totalItemCount: items.length, + pageIndex: 0, + }, + 'data-test-subj': 'testGrid', + ...props, + }; + + renderResult = appTestContext.render(); return renderResult; }; }); it('should render the cards', () => { + cardComponentPropsProvider.mockImplementation(() => ({})); render(); expect(renderResult.getAllByTestId('testGrid-card')).toHaveLength(5); @@ -100,24 +73,59 @@ describe.each([ it.each([ ['header', 'testGrid-header'], ['expand/collapse placeholder', 'testGrid-header-expandCollapsePlaceHolder'], - ['name column', 'testGrid-header-layout-title'], - ['description column', 'testGrid-header-layout-description'], + ['name column', 'testGrid-header-layout-titleHolder'], + ['description column', 'testGrid-header-layout-descriptionHolder'], ['description column', 'testGrid-header-layout-cardActionsPlaceholder'], - ])('should display the Grid Header - %s', (__, selector) => { + ])('should display the Grid Header - %s', (__, testSubjId) => { render(); - expect(renderResult.getByTestId(selector)).not.toBeNull(); + expect(renderResult.getByTestId(testSubjId)).not.toBeNull(); }); - it.todo('should call onPageChange callback when paginating'); + it('should call onPageChange callback when paginating', () => { + items = Array.from({ length: 15 }, () => generateItem()); + render(); + act(() => { + fireEvent.click(renderResult.getByTestId('pagination-button-next')); + }); - it.todo('should use the props provided by cardComponentProps callback'); + expect(pageChangeHandler).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 5 }); + }); - describe('and when cards are expanded/collapsed', () => { - it.todo('should call onExpandCollapse callback'); + it('should pass along the props provided by cardComponentProps callback', () => { + cardComponentPropsProvider.mockReturnValue({ 'data-test-subj': 'test-card' }); + render(); - it.todo('should provide list of cards that are expanded and collapsed'); + expect(renderResult.getAllByTestId('test-card')).toHaveLength(5); + }); - it.todo('should show card expanded if card props defined it as such'); + describe('and when cards are expanded/collapsed', () => { + it('should call onExpandCollapse callback', () => { + render(); + act(() => { + fireEvent.click(renderResult.getByTestId('card-0-header-expandCollapse')); + }); + + expect(expandCollapseHandler).toHaveBeenCalledWith({ + expanded: [items[0]], + collapsed: items.slice(1), + }); + }); + + it('should show card expanded if card props defined it as such', () => { + const originalPropsProvider = cardComponentPropsProvider.getMockImplementation(); + cardComponentPropsProvider.mockImplementation((item) => { + const props = originalPropsProvider!(item); + + if (items.indexOf(item as AnyArtifact) === 1) { + props.expanded = true; + } + + return props; + }); + render(); + + expect(renderResult.getByTestId('card-1-criteriaConditions')).not.toBeNull(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/components/grid_header.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/components/grid_header.tsx index 03fde724b89a5..fb198e86fc386 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/components/grid_header.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_card_grid/components/grid_header.tsx @@ -63,7 +63,7 @@ export const GridHeader = memo(({ 'data-test-subj': dataTestSub } - actionMenu={false} + actionMenu={true} /> ); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx index 52f0eb5fc8982..52299679ec87b 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx @@ -11,11 +11,11 @@ import { ArtifactEntryCard, ArtifactEntryCardProps } from './artifact_entry_card import { act, fireEvent, getByTestId } from '@testing-library/react'; import { AnyArtifact } from './types'; import { isTrustedApp } from './utils'; -import { getTrustedAppProvider, getExceptionProvider } from './test_utils'; +import { getTrustedAppProviderMock, getExceptionProviderMock } from './test_utils'; describe.each([ - ['trusted apps', getTrustedAppProvider], - ['exceptions/event filters', getExceptionProvider], + ['trusted apps', getTrustedAppProviderMock], + ['exceptions/event filters', getExceptionProviderMock], ])('when using the ArtifactEntryCard component with %s', (_, generateItem) => { let item: AnyArtifact; let appTestContext: AppContextTestRender; @@ -48,10 +48,10 @@ describe.each([ 'some internal app' ); expect(renderResult.getByTestId('testCard-subHeader-touchedBy-createdBy').textContent).toEqual( - 'Created byJJusta' + 'Created byMMarty' ); expect(renderResult.getByTestId('testCard-subHeader-touchedBy-updatedBy').textContent).toEqual( - 'Updated byMMara' + 'Updated byEEllamae' ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx index 1178e4b07e5bd..508dc3103ca12 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx @@ -13,11 +13,11 @@ import { } from './artifact_entry_card_minified'; import { act, fireEvent } from '@testing-library/react'; import { AnyArtifact } from './types'; -import { getTrustedAppProvider, getExceptionProvider } from './test_utils'; +import { getTrustedAppProviderMock, getExceptionProviderMock } from './test_utils'; describe.each([ - ['trusted apps', getTrustedAppProvider], - ['exceptions/event filters', getExceptionProvider], + ['trusted apps', getTrustedAppProviderMock], + ['exceptions/event filters', getExceptionProviderMock], ])('when using the ArtifactEntryCardMinified component with %s', (_, generateItem) => { let item: AnyArtifact; let appTestContext: AppContextTestRender; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx new file mode 100644 index 0000000000000..84860b1144f08 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_collapsible_card.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { act, fireEvent } from '@testing-library/react'; +import { AnyArtifact } from './types'; +import { getTrustedAppProviderMock, getExceptionProviderMock } from './test_utils'; +import { + ArtifactEntryCollapsibleCard, + ArtifactEntryCollapsibleCardProps, +} from './artifact_entry_collapsible_card'; + +describe.each([ + ['trusted apps', getTrustedAppProviderMock], + ['exceptions/event filters', getExceptionProviderMock], +])('when using the ArtifactEntryCard component with %s', (_, generateItem) => { + let item: AnyArtifact; + let appTestContext: AppContextTestRender; + let renderResult: ReturnType; + let render: ( + props?: Partial + ) => ReturnType; + let handleOnExpandCollapse: jest.MockedFunction< + ArtifactEntryCollapsibleCardProps['onExpandCollapse'] + >; + + beforeEach(() => { + item = generateItem(); + appTestContext = createAppRootMockRenderer(); + handleOnExpandCollapse = jest.fn(); + render = (props = {}) => { + const cardProps: ArtifactEntryCollapsibleCardProps = { + item, + onExpandCollapse: handleOnExpandCollapse, + 'data-test-subj': 'testCard', + ...props, + }; + + renderResult = appTestContext.render(); + return renderResult; + }; + }); + + it.each([ + ['expandCollapse button', 'testCard-header-expandCollapse'], + ['name', 'testCard-header-titleHolder'], + ['description', 'testCard-header-descriptionHolder'], + ['assignment', 'testCard-header-effectScope'], + ])('should show %s', (__, testSubjId) => { + render(); + + expect(renderResult.getByTestId(testSubjId)).not.toBeNull(); + }); + + it('should NOT show actions menu if none are defined', async () => { + render(); + + expect(renderResult.queryByTestId('testCard-header-actions')).toBeNull(); + }); + + it('should render card collapsed', () => { + render(); + + expect(renderResult.queryByTestId('testCard-header-criteriaConditions')).toBeNull(); + }); + + it('should render card expanded', () => { + render({ expanded: true }); + + expect(renderResult.getByTestId('testCard-criteriaConditions')).not.toBeNull(); + }); + + it('should call `onExpandCollapse` callback when button is clicked', () => { + render(); + act(() => { + fireEvent.click(renderResult.getByTestId('testCard-header-expandCollapse')); + }); + + expect(handleOnExpandCollapse).toHaveBeenCalled(); + }); + + it.each([ + ['title', 'testCard-header-titleHolder'], + ['description', 'testCard-header-descriptionHolder'], + ])('should truncate %s text when collapsed', (__, testSubjId) => { + render(); + + expect(renderResult.getByTestId(testSubjId).classList.contains('eui-textTruncate')).toBe(true); + }); + + it.each([ + ['title', 'testCard-header-titleHolder'], + ['description', 'testCard-header-descriptionHolder'], + ])('should NOT truncate %s text when expanded', (__, testSubjId) => { + render({ expanded: true }); + + expect(renderResult.getByTestId(testSubjId).classList.contains('eui-textTruncate')).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_compressed_header.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_compressed_header.tsx index 6141437779d7d..a4928ffe40674 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_compressed_header.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_compressed_header.tsx @@ -38,7 +38,6 @@ export const CardCompressedHeader = memo( 'data-test-subj': dataTestSubj, }) => { const getTestId = useTestIdGenerator(dataTestSubj); - const cssClassNames = useCollapsedCssClassNames(expanded); const policyNavLinks = usePolicyNavLinks(artifact, policies); const handleExpandCollapseClick = useCallback(() => { @@ -46,37 +45,31 @@ export const CardCompressedHeader = memo( }, [onExpandCollapse]); return ( - - + - - - - - - {artifact.name} - - - - - {artifact.description || getEmptyValue()} - - - - - - - - - + } + name={ + + {artifact.name} + + } + description={ + + {artifact.description || getEmptyValue()} + + } + effectScope={ + + } + actionMenu={} + /> ); } ); @@ -106,8 +99,11 @@ export interface CardCompressedHeaderLayoutProps extends Pick( data-test-subj={dataTestSubj} className={flushTopCssClassname} > - + {expandToggle} @@ -145,27 +145,27 @@ export const CardCompressedHeaderLayout = memo( {name} {description} {effectScope} - {actionMenu === false ? ( + {actionMenu === true ? ( { +const getCommonItemDataOverrides = () => { return { name: 'some internal app', description: 'this app is trusted by the company', @@ -17,18 +19,26 @@ export const getCommonItemDataOverrides = () => { }; }; -export const getTrustedAppProvider = () => +export const getTrustedAppProviderMock = (): TrustedApp => new TrustedAppGenerator('seed').generate(getCommonItemDataOverrides()); -export const getExceptionProvider = () => { +export const getExceptionProviderMock = (): ExceptionListItemSchema => { + // Grab the properties from the generated Trusted App that should be the same across both types + // eslint-disable-next-line @typescript-eslint/naming-convention + const { name, description, created_at, updated_at, updated_by, created_by, id } = + getTrustedAppProviderMock(); + // cloneDeep needed because exception mock generator uses state across instances return cloneDeep( getExceptionListItemSchemaMock({ - ...getCommonItemDataOverrides(), + name, + description, + created_at, + updated_at, + updated_by, + created_by, + id, os_types: ['windows'], - updated_at: new Date().toISOString(), - created_by: 'Justa', - updated_by: 'Mara', entries: [ { field: 'process.hash.*', diff --git a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx index fd087f267a9b5..b955d9fe71db7 100644 --- a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx +++ b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx @@ -9,6 +9,7 @@ import React, { memo } from 'react'; import { EuiContextMenuItem, EuiContextMenuItemProps } from '@elastic/eui'; import { NavigateToAppOptions } from 'kibana/public'; import { useNavigateToAppEventHandler } from '../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { useTestIdGenerator } from '../hooks/use_test_id_generator'; export interface ContextMenuItemNavByRouterProps extends EuiContextMenuItemProps { /** The Kibana (plugin) app id */ @@ -34,6 +35,7 @@ export const ContextMenuItemNavByRouter = memo( ...navigateOptions, onClick, }); + const getTestId = useTestIdGenerator(otherMenuItemProps['data-test-subj']); return ( ( {textTruncate ? (
{ + let appTestContext: AppContextTestRender; + let renderResult: ReturnType; + let render: ( + props?: Partial + ) => ReturnType; + let items: ContextMenuWithRouterSupportProps['items']; + + const clickMenuTriggerButton = () => { + act(() => { + fireEvent.click(renderResult.getByTestId('testMenu-triggerButton')); + }); + }; + + const getContextMenuPanel = () => renderResult.queryByTestId('testMenu-popoverPanel'); + + beforeEach(() => { + appTestContext = createAppRootMockRenderer(); + + items = [ + { + children: 'click me 1', + 'data-test-subj': 'menu-item-one', + textTruncate: false, + }, + { + children: 'click me 2', + navigateAppId: APP_ID, + navigateOptions: { + path: '/one/two/three', + }, + href: 'http://some-url.elastic/one/two/three', + }, + { + children: 'click me 3 with some very long text here that needs to be truncated', + textTruncate: true, + }, + ]; + + render = (overrideProps = {}) => { + const props: ContextMenuWithRouterSupportProps = { + items, + 'data-test-subj': 'testMenu', + button: {'Menu'}, + ...overrideProps, + }; + + renderResult = appTestContext.render(); + + return renderResult; + }; + }); + + it('should toggle the context menu when button is clicked', () => { + render(); + + expect(getContextMenuPanel()).toBeNull(); + + clickMenuTriggerButton(); + + expect(getContextMenuPanel()).not.toBeNull(); + }); + + it('should auto include test subjects on items if one is not defined by the menu item props', () => { + render(); + clickMenuTriggerButton(); + + // this test id should be unchanged from what the Props for the item + expect(renderResult.getByTestId('menu-item-one')).not.toBeNull(); + + // these should have been auto-inserted + expect(renderResult.getByTestId('testMenu-item-1')).not.toBeNull(); + expect(renderResult.getByTestId('testMenu-item-2')).not.toBeNull(); + }); + + it('should truncate text of menu item when `textTruncate` prop is `true`', () => { + render({ maxWidth: undefined }); + clickMenuTriggerButton(); + + expect(renderResult.getByTestId('testMenu-item-2-truncateWrapper')).not.toBeNull(); + }); + + it('should close menu when a menu item is clicked and call menu item onclick callback', async () => { + render(); + clickMenuTriggerButton(); + await act(async () => { + const menuPanelRemoval = waitForElementToBeRemoved(getContextMenuPanel()); + fireEvent.click(renderResult.getByTestId('menu-item-one')); + await menuPanelRemoval; + }); + + expect(getContextMenuPanel()).toBeNull(); + }); + + it('should truncate menu and menu item content when `maxWidth` is used', () => { + render(); + clickMenuTriggerButton(); + + expect(renderResult.getByTestId('menu-item-one-truncateWrapper')).not.toBeNull(); + expect(renderResult.getByTestId('testMenu-item-1-truncateWrapper')).not.toBeNull(); + expect(renderResult.getByTestId('testMenu-item-2-truncateWrapper')).not.toBeNull(); + }); + + it('should navigate using the router when item is clicked', () => { + render(); + clickMenuTriggerButton(); + act(() => { + fireEvent.click(renderResult.getByTestId('testMenu-item-1')); + }); + + expect(appTestContext.coreStart.application.navigateToApp).toHaveBeenCalledWith( + APP_ID, + expect.objectContaining({ path: '/one/two/three' }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx index 3f21f3995ac5b..41abb0309a7d1 100644 --- a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx +++ b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx @@ -13,6 +13,7 @@ import { EuiPopover, EuiPopoverProps, } from '@elastic/eui'; +import uuid from 'uuid'; import { ContextMenuItemNavByRouter, ContextMenuItemNavByRouterProps, @@ -49,10 +50,12 @@ export const ContextMenuWithRouterSupport = memo { - return items.map((itemProps) => { + return items.map((itemProps, index) => { return ( { handleCloseMenu(); @@ -63,7 +66,7 @@ export const ContextMenuWithRouterSupport = memo ); }); - }, [handleCloseMenu, items, maxWidth]); + }, [getTestId, handleCloseMenu, items, maxWidth]); type AdditionalPanelProps = Partial>; const additionalContextMenuPanelProps = useMemo(() => { @@ -86,7 +89,11 @@ export const ContextMenuWithRouterSupport = memo +
{button}
} diff --git a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/index.ts b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/index.ts index 56c6009ccf1b2..527183ef40697 100644 --- a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/index.ts +++ b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/index.ts @@ -6,3 +6,4 @@ */ export * from './context_menu_with_router_support'; +export * from './context_menu_item_nav_by_router'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/index.tsx index 30397fe1d32f2..70f5bca339c82 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/index.tsx @@ -24,3 +24,5 @@ export const EndpointsContainer = memo(() => { }); EndpointsContainer.displayName = 'EndpointsContainer'; +export { endpointListFleetApisHttpMock } from './mocks'; +export { EndpointListFleetApisHttpMockInterface } from './mocks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts index 010fe48f29418..e0b5837c2f78a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -26,19 +26,21 @@ import { HOST_METADATA_LIST_ROUTE, } from '../../../../common/endpoint/constants'; import { - AGENT_POLICY_API_ROUTES, - appRoutesService, - CheckPermissionsResponse, - EPM_API_ROUTES, - GetAgentPoliciesResponse, - GetPackagesResponse, -} from '../../../../../fleet/common'; -import { - PendingActionsHttpMockInterface, pendingActionsHttpMock, + PendingActionsHttpMockInterface, } from '../../../common/lib/endpoint_pending_actions/mocks'; import { METADATA_TRANSFORM_STATS_URL, TRANSFORM_STATES } from '../../../../common/constants'; import { TransformStatsResponse } from './types'; +import { + fleetGetAgentPolicyListHttpMock, + FleetGetAgentPolicyListHttpMockInterface, + FleetGetAgentStatusHttpMockInterface, + fleetGetCheckPermissionsHttpMock, + FleetGetCheckPermissionsInterface, + FleetGetEndpointPackagePolicyHttpMockInterface, + fleetGetPackageListHttpMock, + FleetGetPackageListHttpMockInterface, +} from '../mocks'; type EndpointMetadataHttpMocksInterface = ResponseProvidersInterface<{ metadataList: () => HostResultList; @@ -149,88 +151,6 @@ export const endpointActivityLogHttpMock = }, ]); -export type FleetGetPackageListHttpMockInterface = ResponseProvidersInterface<{ - packageList: () => GetPackagesResponse; -}>; -export const fleetGetPackageListHttpMock = - httpHandlerMockFactory([ - { - id: 'packageList', - method: 'get', - path: EPM_API_ROUTES.LIST_PATTERN, - handler() { - const generator = new EndpointDocGenerator('seed'); - - return { - response: [generator.generateEpmPackage()], - }; - }, - }, - ]); - -export type FleetGetAgentPolicyListHttpMockInterface = ResponseProvidersInterface<{ - agentPolicy: () => GetAgentPoliciesResponse; -}>; -export const fleetGetAgentPolicyListHttpMock = - httpHandlerMockFactory([ - { - id: 'agentPolicy', - path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, - method: 'get', - handler: () => { - const generator = new EndpointDocGenerator('seed'); - const endpointMetadata = generator.generateHostMetadata(); - const agentPolicy = generator.generateAgentPolicy(); - - // Make sure that the Agent policy returned from the API has the Integration Policy ID that - // the endpoint metadata is using. This is needed especially when testing the Endpoint Details - // flyout where certain actions might be disabled if we know the endpoint integration policy no - // longer exists. - (agentPolicy.package_policies as string[]).push( - endpointMetadata.Endpoint.policy.applied.id - ); - - return { - items: [agentPolicy], - perPage: 10, - total: 1, - page: 1, - }; - }, - }, - ]); - -export type FleetGetCheckPermissionsInterface = ResponseProvidersInterface<{ - checkPermissions: () => CheckPermissionsResponse; -}>; - -export const fleetGetCheckPermissionsHttpMock = - httpHandlerMockFactory([ - { - id: 'checkPermissions', - path: appRoutesService.getCheckPermissionsPath(), - method: 'get', - handler: () => { - return { - error: undefined, - success: true, - }; - }, - }, - ]); - -type FleetApisHttpMockInterface = FleetGetPackageListHttpMockInterface & - FleetGetAgentPolicyListHttpMockInterface & - FleetGetCheckPermissionsInterface; -/** - * Mocks all Fleet apis needed to render the Endpoint List/Details pages - */ -export const fleetApisHttpMock = composeHttpHandlerMocks([ - fleetGetPackageListHttpMock, - fleetGetAgentPolicyListHttpMock, - fleetGetCheckPermissionsHttpMock, -]); - type TransformHttpMocksInterface = ResponseProvidersInterface<{ metadataTransformStats: () => TransformStatsResponse; }>; @@ -251,10 +171,24 @@ export const transformsHttpMocks = httpHandlerMockFactory([ + fleetGetPackageListHttpMock, + fleetGetAgentPolicyListHttpMock, + fleetGetCheckPermissionsHttpMock, + ]); type EndpointPageHttpMockInterface = EndpointMetadataHttpMocksInterface & EndpointPolicyResponseHttpMockInterface & EndpointActivityLogHttpMockInterface & - FleetApisHttpMockInterface & + EndpointListFleetApisHttpMockInterface & PendingActionsHttpMockInterface & TransformHttpMocksInterface; /** @@ -264,7 +198,7 @@ export const endpointPageHttpMock = composeHttpHandlerMocks GetPackagesResponse; +}>; +export const fleetGetPackageListHttpMock = + httpHandlerMockFactory([ + { + id: 'packageList', + method: 'get', + path: EPM_API_ROUTES.LIST_PATTERN, + handler() { + const generator = new EndpointDocGenerator('seed'); + + return { + response: [generator.generateEpmPackage()], + }; + }, + }, + ]); + +export type FleetGetEndpointPackagePolicyHttpMockInterface = ResponseProvidersInterface<{ + endpointPackagePolicy: () => GetPolicyResponse; +}>; +export const fleetGetEndpointPackagePolicyHttpMock = + httpHandlerMockFactory([ + { + id: 'endpointPackagePolicy', + path: PACKAGE_POLICY_API_ROUTES.INFO_PATTERN, + method: 'get', + handler: () => { + return { + items: new EndpointDocGenerator('seed').generatePolicyPackagePolicy(), + }; + }, + }, + ]); + +export type FleetGetEndpointPackagePolicyListHttpMockInterface = ResponseProvidersInterface<{ + endpointPackagePolicyList: () => GetPolicyListResponse; +}>; +export const fleetGetEndpointPackagePolicyListHttpMock = + httpHandlerMockFactory([ + { + id: 'endpointPackagePolicyList', + path: PACKAGE_POLICY_API_ROUTES.LIST_PATTERN, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + const items = Array.from({ length: 5 }, (_, index) => { + const policy = generator.generatePolicyPackagePolicy(); + policy.name += ` ${index}`; + return policy; + }); + + return { + items, + total: 1, + page: 1, + perPage: 10, + }; + }, + }, + ]); + +export type FleetGetAgentPolicyListHttpMockInterface = ResponseProvidersInterface<{ + agentPolicy: () => GetAgentPoliciesResponse; +}>; +export const fleetGetAgentPolicyListHttpMock = + httpHandlerMockFactory([ + { + id: 'agentPolicy', + path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + const endpointMetadata = generator.generateHostMetadata(); + const agentPolicy = generator.generateAgentPolicy(); + + // Make sure that the Agent policy returned from the API has the Integration Policy ID that + // the endpoint metadata is using. This is needed especially when testing the Endpoint Details + // flyout where certain actions might be disabled if we know the endpoint integration policy no + // longer exists. + (agentPolicy.package_policies as string[]).push( + endpointMetadata.Endpoint.policy.applied.id + ); + + return { + items: [agentPolicy], + perPage: 10, + total: 1, + page: 1, + }; + }, + }, + ]); + +export type FleetGetCheckPermissionsInterface = ResponseProvidersInterface<{ + checkPermissions: () => CheckPermissionsResponse; +}>; +export const fleetGetCheckPermissionsHttpMock = + httpHandlerMockFactory([ + { + id: 'checkPermissions', + path: appRoutesService.getCheckPermissionsPath(), + method: 'get', + handler: () => { + return { + error: undefined, + success: true, + }; + }, + }, + ]); + +export type FleetGetAgentStatusHttpMockInterface = ResponseProvidersInterface<{ + agentStatus: () => GetAgentStatusResponse; +}>; +export const fleetGetAgentStatusHttpMock = + httpHandlerMockFactory([ + { + id: 'agentStatus', + path: AGENT_API_ROUTES.STATUS_PATTERN, + method: 'get', + handler: () => { + return { + results: { + total: 50, + inactive: 5, + online: 40, + error: 0, + offline: 5, + updating: 0, + other: 0, + events: 0, + }, + }; + }, + }, + ]); diff --git a/x-pack/plugins/security_solution/public/management/pages/mocks/index.ts b/x-pack/plugins/security_solution/public/management/pages/mocks/index.ts new file mode 100644 index 0000000000000..c7388cad5696f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/mocks/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './fleet_mocks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts index 6839edb965332..0fbd674b265b0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { PolicyDetailsState } from '../../../types'; +import { PolicyArtifactsState, PolicyDetailsState } from '../../../types'; import { initialPolicyDetailsState } from '../reducer'; import { getAssignableArtifactsList, @@ -16,6 +16,12 @@ import { getAssignableArtifactsListExist, getAssignableArtifactsListExistIsLoading, getUpdateArtifacts, + doesPolicyTrustedAppsListNeedUpdate, + isPolicyTrustedAppListLoading, + getPolicyTrustedAppList, + getPolicyTrustedAppsListPagination, + getTrustedAppsListOfAllPolicies, + getTrustedAppsAllPoliciesById, } from './trusted_apps_selectors'; import { getCurrentArtifactsLocation, isOnPolicyTrustedAppsView } from './policy_common_selectors'; @@ -27,15 +33,190 @@ import { createFailedResourceState, } from '../../../../../state'; import { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH } from '../../../../../common/constants'; -import { getMockListResponse, getAPIError, getMockCreateResponse } from '../../../test_utils'; +import { + getMockListResponse, + getAPIError, + getMockCreateResponse, + getMockPolicyDetailsArtifactListUrlParams, + getMockPolicyDetailsArtifactsPageLocationUrlParams, +} from '../../../test_utils'; +import { getGeneratedPolicyResponse } from '../../../../trusted_apps/store/mocks'; describe('policy trusted apps selectors', () => { let initialState: ImmutableObject; + const createArtifactsState = ( + artifacts: Partial = {} + ): ImmutableObject => { + return { + ...initialState, + artifacts: { + ...initialState.artifacts, + ...artifacts, + }, + }; + }; + beforeEach(() => { initialState = initialPolicyDetailsState(); }); + describe('doesPolicyTrustedAppsListNeedUpdate()', () => { + it('should return true if state is not loaded', () => { + expect(doesPolicyTrustedAppsListNeedUpdate(initialState)).toBe(true); + }); + + it('should return true if it is loaded, but URL params were changed', () => { + expect( + doesPolicyTrustedAppsListNeedUpdate( + createArtifactsState({ + location: getMockPolicyDetailsArtifactsPageLocationUrlParams({ page_index: 4 }), + assignedList: createLoadedResourceState({ + location: getMockPolicyDetailsArtifactListUrlParams(), + artifacts: getMockListResponse(), + }), + }) + ) + ).toBe(true); + }); + + it('should return false if state is loaded adn URL params are the same', () => { + expect( + doesPolicyTrustedAppsListNeedUpdate( + createArtifactsState({ + location: getMockPolicyDetailsArtifactsPageLocationUrlParams(), + assignedList: createLoadedResourceState({ + location: getMockPolicyDetailsArtifactListUrlParams(), + artifacts: getMockListResponse(), + }), + }) + ) + ).toBe(false); + }); + }); + + describe('isPolicyTrustedAppListLoading()', () => { + it('should return true when loading data', () => { + expect( + isPolicyTrustedAppListLoading( + createArtifactsState({ + assignedList: createLoadingResourceState(createUninitialisedResourceState()), + }) + ) + ).toBe(true); + }); + + it.each([ + ['uninitialized', createUninitialisedResourceState() as PolicyArtifactsState['assignedList']], + ['loaded', createLoadedResourceState({}) as PolicyArtifactsState['assignedList']], + ['failed', createFailedResourceState({}) as PolicyArtifactsState['assignedList']], + ])('should return false when state is %s', (__, assignedListState) => { + expect( + isPolicyTrustedAppListLoading(createArtifactsState({ assignedList: assignedListState })) + ).toBe(false); + }); + }); + + describe('getPolicyTrustedAppList()', () => { + it('should return the list of trusted apps', () => { + const listResponse = getMockListResponse(); + + expect( + getPolicyTrustedAppList( + createArtifactsState({ + location: getMockPolicyDetailsArtifactsPageLocationUrlParams(), + assignedList: createLoadedResourceState({ + location: getMockPolicyDetailsArtifactListUrlParams(), + artifacts: listResponse, + }), + }) + ) + ).toEqual(listResponse.data); + }); + + it('should return empty array if no data is loaded', () => { + expect(getPolicyTrustedAppList(initialState)).toEqual([]); + }); + }); + + describe('getPolicyTrustedAppsListPagination()', () => { + it('should return default pagination data even if no api data is available', () => { + expect(getPolicyTrustedAppsListPagination(initialState)).toEqual({ + pageIndex: 0, + pageSize: 10, + pageSizeOptions: [10, 20, 50], + totalItemCount: 0, + }); + }); + + it('should return pagination data based on api response data', () => { + const listResponse = getMockListResponse(); + + listResponse.page = 6; + listResponse.per_page = 100; + listResponse.total = 1000; + + expect( + getPolicyTrustedAppsListPagination( + createArtifactsState({ + location: getMockPolicyDetailsArtifactsPageLocationUrlParams({ + page_index: 5, + page_size: 100, + }), + assignedList: createLoadedResourceState({ + location: getMockPolicyDetailsArtifactListUrlParams({ + page_index: 5, + page_size: 100, + }), + artifacts: listResponse, + }), + }) + ) + ).toEqual({ + pageIndex: 5, + pageSize: 100, + pageSizeOptions: [10, 20, 50], + totalItemCount: 1000, + }); + }); + }); + + describe('getTrustedAppsListOfAllPolicies()', () => { + it('should return the loaded list of policies', () => { + const policiesApiResponse = getGeneratedPolicyResponse(); + + expect( + getTrustedAppsListOfAllPolicies( + createArtifactsState({ + policies: createLoadedResourceState(policiesApiResponse), + }) + ) + ).toEqual(policiesApiResponse.items); + }); + + it('should return an empty array of no policy data was loaded yet', () => { + expect(getTrustedAppsListOfAllPolicies(initialState)).toEqual([]); + }); + }); + + describe('getTrustedAppsAllPoliciesById()', () => { + it('should return an empty object if no polices', () => { + expect(getTrustedAppsAllPoliciesById(initialState)).toEqual({}); + }); + + it('should return an object with policy id and policy data', () => { + const policiesApiResponse = getGeneratedPolicyResponse(); + + expect( + getTrustedAppsAllPoliciesById( + createArtifactsState({ + policies: createLoadedResourceState(policiesApiResponse), + }) + ) + ).toEqual({ [policiesApiResponse.items[0].id]: policiesApiResponse.items[0] }); + }); + }); + describe('isOnPolicyTrustedAppsPage()', () => { it('when location is on policy trusted apps page', () => { const isOnPage = isOnPolicyTrustedAppsView({ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts index 84f0f4a2c63b8..3177f13ae77e8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts @@ -31,6 +31,7 @@ import { LoadedResourceState, } from '../../../../../state'; import { getCurrentArtifactsLocation } from './policy_common_selectors'; +import { ServerApiError } from '../../../../../../common/types'; export const doesPolicyHaveTrustedApps = ( state: PolicyDetailsState @@ -212,3 +213,11 @@ export const getDoesAnyTrustedAppExistsIsLoading: PolicyDetailsSelector return isLoadingResourceState(doesAnyTrustedAppExists); } ); + +export const getPolicyTrustedAppListError: PolicyDetailsSelector< + Immutable | undefined +> = createSelector(getCurrentPolicyAssignedTrustedAppsState, (currentAssignedTrustedAppsState) => { + if (isFailedResourceState(currentAssignedTrustedAppsState)) { + return currentAssignedTrustedAppsState.error; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/index.ts index d92c41f5a1cc6..599c6328855e5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/index.ts @@ -5,25 +5,4 @@ * 2.0. */ -import { - GetTrustedAppsListResponse, - PostTrustedAppCreateResponse, -} from '../../../../../common/endpoint/types'; - -import { createSampleTrustedApps, createSampleTrustedApp } from '../../trusted_apps/test_utils'; - -export const getMockListResponse: () => GetTrustedAppsListResponse = () => ({ - data: createSampleTrustedApps({}), - per_page: 100, - page: 1, - total: 100, -}); - -export const getMockCreateResponse: () => PostTrustedAppCreateResponse = () => - createSampleTrustedApp(1) as unknown as unknown as PostTrustedAppCreateResponse; - -export const getAPIError = () => ({ - statusCode: 500, - error: 'Internal Server Error', - message: 'Something is not right', -}); +export * from './mocks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/mocks.ts new file mode 100644 index 0000000000000..be38e591dd9da --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/mocks.ts @@ -0,0 +1,123 @@ +/* + * 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 { + composeHttpHandlerMocks, + httpHandlerMockFactory, + ResponseProvidersInterface, +} from '../../../../common/mock/endpoint/http_handler_mock_factory'; +import { + GetTrustedAppsListRequest, + GetTrustedAppsListResponse, + PostTrustedAppCreateResponse, +} from '../../../../../common/endpoint/types'; +import { TRUSTED_APPS_LIST_API } from '../../../../../common/endpoint/constants'; +import { TrustedAppGenerator } from '../../../../../common/endpoint/data_generators/trusted_app_generator'; +import { createSampleTrustedApps, createSampleTrustedApp } from '../../trusted_apps/test_utils'; +import { + PolicyDetailsArtifactsPageListLocationParams, + PolicyDetailsArtifactsPageLocation, +} from '../types'; +import { + fleetGetAgentStatusHttpMock, + FleetGetAgentStatusHttpMockInterface, + fleetGetEndpointPackagePolicyHttpMock, + FleetGetEndpointPackagePolicyHttpMockInterface, + fleetGetEndpointPackagePolicyListHttpMock, + FleetGetEndpointPackagePolicyListHttpMockInterface, +} from '../../mocks'; + +export const getMockListResponse: () => GetTrustedAppsListResponse = () => ({ + data: createSampleTrustedApps({}), + per_page: 100, + page: 1, + total: 100, +}); + +export const getMockPolicyDetailsArtifactsPageLocationUrlParams = ( + overrides: Partial = {} +): PolicyDetailsArtifactsPageLocation => { + return { + page_index: 0, + page_size: 10, + filter: '', + show: undefined, + ...overrides, + }; +}; + +export const getMockPolicyDetailsArtifactListUrlParams = ( + overrides: Partial = {} +): PolicyDetailsArtifactsPageListLocationParams => { + return { + page_index: 0, + page_size: 10, + filter: '', + ...overrides, + }; +}; + +export const getMockCreateResponse: () => PostTrustedAppCreateResponse = () => + createSampleTrustedApp(1) as unknown as unknown as PostTrustedAppCreateResponse; + +export const getAPIError = () => ({ + statusCode: 500, + error: 'Internal Server Error', + message: 'Something is not right', +}); + +type PolicyDetailsTrustedAppsHttpMocksInterface = ResponseProvidersInterface<{ + policyTrustedAppsList: () => GetTrustedAppsListResponse; +}>; + +/** + * HTTP mocks that support the Trusted Apps tab of the Policy Details page + */ +export const policyDetailsTrustedAppsHttpMocks = + httpHandlerMockFactory([ + { + id: 'policyTrustedAppsList', + path: TRUSTED_APPS_LIST_API, + method: 'get', + handler: ({ query }): GetTrustedAppsListResponse => { + const apiQueryParams = query as GetTrustedAppsListRequest; + const generator = new TrustedAppGenerator('seed'); + const perPage = apiQueryParams.per_page ?? 10; + const data = Array.from({ length: Math.min(perPage, 50) }, () => generator.generate()); + + // Change the 3rd entry (index 2) to be policy specific + data[2].effectScope = { + type: 'policy', + policies: [ + // IDs below are those generated by the `fleetGetEndpointPackagePolicyListHttpMock()` mock + 'ddf6570b-9175-4a6d-b288-61a09771c647', + 'b8e616ae-44fc-4be7-846c-ce8fa5c082dd', + ], + }; + + return { + page: apiQueryParams.page ?? 1, + per_page: perPage, + total: 20, + data, + }; + }, + }, + ]); + +export type PolicyDetailsPageAllApiHttpMocksInterface = + FleetGetEndpointPackagePolicyHttpMockInterface & + FleetGetAgentStatusHttpMockInterface & + FleetGetEndpointPackagePolicyListHttpMockInterface & + PolicyDetailsTrustedAppsHttpMocksInterface; +export const policyDetailsPageAllApiHttpMocks = + composeHttpHandlerMocks([ + fleetGetEndpointPackagePolicyHttpMock, + fleetGetAgentStatusHttpMock, + fleetGetEndpointPackagePolicyListHttpMock, + policyDetailsTrustedAppsHttpMocks, + ]); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx new file mode 100644 index 0000000000000..07b62d13e8edc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -0,0 +1,253 @@ +/* + * 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 { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; +import { PolicyTrustedAppsList } from './policy_trusted_apps_list'; +import React from 'react'; +import { policyDetailsPageAllApiHttpMocks } from '../../../test_utils'; +import { isFailedResourceState, isLoadedResourceState } from '../../../../../state'; +import { fireEvent, within, act, waitFor } from '@testing-library/react'; +import { APP_ID } from '../../../../../../../common/constants'; + +describe('when rendering the PolicyTrustedAppsList', () => { + let appTestContext: AppContextTestRender; + let renderResult: ReturnType; + let render: (waitForLoadedState?: boolean) => Promise>; + let mockedApis: ReturnType; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + + const getCardByIndexPosition = (cardIndex: number = 0) => { + const card = renderResult.getAllByTestId('policyTrustedAppsGrid-card')[cardIndex]; + + if (!card) { + throw new Error(`Card at index [${cardIndex}] not found`); + } + + return card; + }; + + const toggleCardExpandCollapse = (cardIndex: number = 0) => { + act(() => { + fireEvent.click( + within(getCardByIndexPosition(cardIndex)).getByTestId( + 'policyTrustedAppsGrid-card-header-expandCollapse' + ) + ); + }); + }; + + const toggleCardActionMenu = async (cardIndex: number = 0) => { + act(() => { + fireEvent.click( + within(getCardByIndexPosition(cardIndex)).getByTestId( + 'policyTrustedAppsGrid-card-header-actions-button' + ) + ); + }); + + await waitFor(() => + expect(renderResult.getByTestId('policyTrustedAppsGrid-card-header-actions-contextMenuPanel')) + ); + }; + + beforeEach(() => { + appTestContext = createAppRootMockRenderer(); + + mockedApis = policyDetailsPageAllApiHttpMocks(appTestContext.coreStart.http); + appTestContext.setExperimentalFlag({ trustedAppsByPolicyEnabled: true }); + waitForAction = appTestContext.middlewareSpy.waitForAction; + + render = async (waitForLoadedState: boolean = true) => { + appTestContext.history.push( + getPolicyDetailsArtifactsListPath('ddf6570b-9175-4a6d-b288-61a09771c647') + ); + const trustedAppDataReceived = waitForLoadedState + ? waitForAction('assignedTrustedAppsListStateChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }) + : Promise.resolve(); + + renderResult = appTestContext.render(); + await trustedAppDataReceived; + + return renderResult; + }; + }); + + // FIXME: implement this test once PR #113802 is merged + it.todo('should show loading spinner if checking to see if trusted apps exist'); + + it('should show total number of of items being displayed', async () => { + await render(); + + expect(renderResult.getByTestId('policyDetailsTrustedAppsCount').textContent).toBe( + 'Showing 20 trusted applications' + ); + }); + + it('should show card grid', async () => { + await render(); + + expect(renderResult.getByTestId('policyTrustedAppsGrid')).toBeTruthy(); + await expect(renderResult.findAllByTestId('policyTrustedAppsGrid-card')).resolves.toHaveLength( + 10 + ); + }); + + it('should expand cards', async () => { + await render(); + // expand + toggleCardExpandCollapse(); + toggleCardExpandCollapse(4); + + await waitFor(() => + expect( + renderResult.queryAllByTestId('policyTrustedAppsGrid-card-criteriaConditions') + ).toHaveLength(2) + ); + }); + + it('should collapse cards', async () => { + await render(); + + // expand + toggleCardExpandCollapse(); + toggleCardExpandCollapse(4); + + await waitFor(() => + expect( + renderResult.queryAllByTestId('policyTrustedAppsGrid-card-criteriaConditions') + ).toHaveLength(2) + ); + + // collapse + toggleCardExpandCollapse(); + toggleCardExpandCollapse(4); + + await waitFor(() => + expect( + renderResult.queryAllByTestId('policyTrustedAppsGrid-card-criteriaConditions') + ).toHaveLength(0) + ); + }); + + it('should show action menu on card', async () => { + await render(); + expect( + renderResult.getAllByTestId('policyTrustedAppsGrid-card-header-actions-button') + ).toHaveLength(10); + }); + + it('should navigate to trusted apps page when view full details action is clicked', async () => { + await render(); + await toggleCardActionMenu(); + act(() => { + fireEvent.click(renderResult.getByTestId('policyTrustedAppsGrid-viewFullDetailsAction')); + }); + + expect(appTestContext.coreStart.application.navigateToApp).toHaveBeenCalledWith( + APP_ID, + expect.objectContaining({ + path: '/administration/trusted_apps?show=edit&id=89f72d8a-05b5-4350-8cad-0dc3661d6e67', + }) + ); + }); + + it('should display policy names on assignment context menu', async () => { + const retrieveAllPolicies = waitForAction('policyDetailsListOfAllPoliciesStateChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }); + await render(); + await retrieveAllPolicies; + act(() => { + fireEvent.click( + within(getCardByIndexPosition(2)).getByTestId( + 'policyTrustedAppsGrid-card-header-effectScope-popupMenu-button' + ) + ); + }); + await waitFor(() => + expect( + renderResult.getByTestId( + 'policyTrustedAppsGrid-card-header-effectScope-popupMenu-popoverPanel' + ) + ) + ); + + expect( + renderResult.getByTestId('policyTrustedAppsGrid-card-header-effectScope-popupMenu-item-0') + .textContent + ).toEqual('Endpoint Policy 0'); + expect( + renderResult.getByTestId('policyTrustedAppsGrid-card-header-effectScope-popupMenu-item-1') + .textContent + ).toEqual('Endpoint Policy 1'); + }); + + it.todo('should navigate to policy details when clicking policy on assignment context menu'); + + it('should handle pagination changes', async () => { + await render(); + + expect(appTestContext.history.location.search).not.toBeTruthy(); + + act(() => { + fireEvent.click(renderResult.getByTestId('pagination-button-next')); + }); + + expect(appTestContext.history.location.search).toMatch('?page_index=1'); + }); + + it('should reset `pageIndex` when a new pageSize is selected', async () => { + await render(); + // page ahead + act(() => { + fireEvent.click(renderResult.getByTestId('pagination-button-next')); + }); + await waitFor(() => { + expect(appTestContext.history.location.search).toBeTruthy(); + }); + + // now change the page size + await act(async () => { + fireEvent.click(renderResult.getByTestId('tablePaginationPopoverButton')); + await waitFor(() => expect(renderResult.getByTestId('tablePagination-50-rows'))); + }); + act(() => { + fireEvent.click(renderResult.getByTestId('tablePagination-50-rows')); + }); + + expect(appTestContext.history.location.search).toMatch('?page_size=50'); + }); + + it('should show toast message if trusted app list api call fails', async () => { + const error = new Error('oh no'); + // @ts-expect-error + mockedApis.responseProvider.policyTrustedAppsList.mockRejectedValue(error); + await render(false); + await act(async () => { + await waitForAction('assignedTrustedAppsListStateChanged', { + validate: ({ payload }) => isFailedResourceState(payload), + }); + }); + + expect(appTestContext.startServices.notifications.toasts.addError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + title: expect.any(String), + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx index 333820b5d81b4..6793bee9c3c01 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx @@ -19,6 +19,7 @@ import { doesPolicyHaveTrustedApps, getCurrentArtifactsLocation, getPolicyTrustedAppList, + getPolicyTrustedAppListError, getPolicyTrustedAppsListPagination, getTrustedAppsAllPoliciesById, isPolicyTrustedAppListLoading, @@ -31,12 +32,17 @@ import { getTrustedAppsListPath, } from '../../../../../common/routing'; import { Immutable, TrustedApp } from '../../../../../../../common/endpoint/types'; -import { useAppUrl } from '../../../../../../common/lib/kibana'; +import { useAppUrl, useToasts } from '../../../../../../common/lib/kibana'; import { APP_ID } from '../../../../../../../common/constants'; import { ContextMenuItemNavByRouterProps } from '../../../../../components/context_menu_with_router_support/context_menu_item_nav_by_router'; import { ArtifactEntryCollapsibleCardProps } from '../../../../../components/artifact_entry_card'; +import { useTestIdGenerator } from '../../../../../components/hooks/use_test_id_generator'; + +const DATA_TEST_SUBJ = 'policyTrustedAppsGrid'; export const PolicyTrustedAppsList = memo(() => { + const getTestId = useTestIdGenerator(DATA_TEST_SUBJ); + const toasts = useToasts(); const history = useHistory(); const { getAppUrl } = useAppUrl(); const policyId = usePolicyDetailsSelector(policyIdFromParams); @@ -47,11 +53,10 @@ export const PolicyTrustedAppsList = memo(() => { const pagination = usePolicyDetailsSelector(getPolicyTrustedAppsListPagination); const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); const allPoliciesById = usePolicyDetailsSelector(getTrustedAppsAllPoliciesById); + const trustedAppsApiError = usePolicyDetailsSelector(getPolicyTrustedAppListError); const [isCardExpanded, setCardExpanded] = useState>({}); - // TODO:PT show load errors if any - const handlePageChange = useCallback( ({ pageIndex, pageSize }) => { history.push( @@ -135,6 +140,7 @@ export const PolicyTrustedAppsList = memo(() => { href: getAppUrl({ appId: APP_ID, path: viewUrlPath }), navigateAppId: APP_ID, navigateOptions: { path: viewUrlPath }, + 'data-test-subj': getTestId('viewFullDetailsAction'), }, ], policies: assignedPoliciesMenuItems, @@ -144,7 +150,7 @@ export const PolicyTrustedAppsList = memo(() => { } return newCardProps; - }, [allPoliciesById, getAppUrl, isCardExpanded, trustedAppItems]); + }, [allPoliciesById, getAppUrl, getTestId, isCardExpanded, trustedAppItems]); const provideCardProps = useCallback['cardComponentProps']>( (item) => { @@ -153,6 +159,17 @@ export const PolicyTrustedAppsList = memo(() => { [cardProps] ); + // if an error occurred while loading the data, show toast + useEffect(() => { + if (trustedAppsApiError) { + toasts.addError(trustedAppsApiError as unknown as Error, { + title: i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.list.apiError', { + defaultMessage: 'Error while retrieving list of trusted applications', + }), + }); + } + }, [toasts, trustedAppsApiError]); + // Anytime a new set of data (trusted apps) is retrieved, reset the card expand state useEffect(() => { setCardExpanded({}); @@ -161,7 +178,11 @@ export const PolicyTrustedAppsList = memo(() => { if (hasTrustedApps.loading || isTrustedAppExistsCheckLoading) { return ( - + ); } @@ -180,8 +201,9 @@ export const PolicyTrustedAppsList = memo(() => { onExpandCollapse={handleExpandCollapse} cardComponentProps={provideCardProps} loading={isLoading} + error={trustedAppsApiError?.message} pagination={pagination as Pagination} - data-test-subj="policyTrustedAppsGrid" + data-test-subj={DATA_TEST_SUBJ} /> ); From c9e3e0e9b5c8703f8dd2b8e815cbd5ea5f0f2c4c Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 12 Oct 2021 08:26:05 -0500 Subject: [PATCH 15/68] Fix GC time calculation (#113992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was in µs. Corrected to be in ms. Also use correct duration formatting on the GC time chart and time spent by dependency chart. --- .../shared/charts/breakdown_chart/index.tsx | 12 +- .../shared/charts/metrics_chart/index.tsx | 16 +- .../gc/fetch_and_transform_gc_metrics.test.ts | 133 ++++++++ .../java/gc/fetch_and_transform_gc_metrics.ts | 9 +- .../tests/metrics_charts/metrics_charts.ts | 316 +++++++++--------- 5 files changed, 317 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.test.ts diff --git a/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx index 9dc2fbd4cc961..213bac40c2248 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx @@ -27,8 +27,8 @@ import { Annotation } from '../../../../../common/annotations'; import { useChartTheme } from '../../../../../../observability/public'; import { asAbsoluteDateTime, - asDuration, asPercent, + getDurationFormatter, } from '../../../../../common/utils/formatters'; import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { useChartPointerEventContext } from '../../../../context/chart_pointer_event/use_chart_pointer_event_context'; @@ -39,6 +39,10 @@ import { ChartContainer } from '../../charts/chart_container'; import { isTimeseriesEmpty, onBrushEnd } from '../../charts/helper/helper'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useTimeRange } from '../../../../hooks/use_time_range'; +import { + getMaxY, + getResponseTimeTickFormatter, +} from '../../../shared/charts/transaction_charts/helper'; interface Props { fetchStatus: FETCH_STATUS; @@ -50,7 +54,6 @@ interface Props { } const asPercentBound = (y: number | null) => asPercent(y, 1); -const asDurationBound = (y: number | null) => asDuration(y); export function BreakdownChart({ fetchStatus, @@ -82,8 +85,11 @@ export function BreakdownChart({ const isEmpty = isTimeseriesEmpty(timeseries); + const maxY = getMaxY(timeseries); const yTickFormat: TickFormatter = - yAxisType === 'duration' ? asDurationBound : asPercentBound; + yAxisType === 'duration' + ? getResponseTimeTickFormatter(getDurationFormatter(maxY)) + : asPercentBound; return ( diff --git a/x-pack/plugins/apm/public/components/shared/charts/metrics_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/metrics_chart/index.tsx index 9ee77cd95ee0d..9f437a95e7dd9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/metrics_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/metrics_chart/index.tsx @@ -9,9 +9,9 @@ import { EuiTitle } from '@elastic/eui'; import React from 'react'; import { asDecimal, - asDuration, asInteger, asPercent, + getDurationFormatter, getFixedByteFormatter, } from '../../../../../common/utils/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -19,22 +19,24 @@ import { GenericMetricsChart } from '../../../../../server/lib/metrics/transform import { Maybe } from '../../../../../typings/common'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { TimeseriesChart } from '../timeseries_chart'; +import { + getMaxY, + getResponseTimeTickFormatter, +} from '../transaction_charts/helper'; function getYTickFormatter(chart: GenericMetricsChart) { + const max = getMaxY(chart.series); + switch (chart.yUnit) { case 'bytes': { - const max = Math.max( - ...chart.series.map(({ data }) => - Math.max(...data.map(({ y }) => y || 0)) - ) - ); return getFixedByteFormatter(max); } case 'percent': { return (y: Maybe) => asPercent(y || 0, 1); } case 'time': { - return asDuration; + const durationFormatter = getDurationFormatter(max); + return getResponseTimeTickFormatter(durationFormatter); } case 'integer': { return asInteger; diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.test.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.test.ts new file mode 100644 index 0000000000000..c22c326473e2c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { + METRIC_JAVA_GC_COUNT, + METRIC_JAVA_GC_TIME, +} from '../../../../../../common/elasticsearch_fieldnames'; +import { Setup } from '../../../../helpers/setup_request'; +import { ChartBase } from '../../../types'; + +import { fetchAndTransformGcMetrics } from './fetch_and_transform_gc_metrics'; + +describe('fetchAndTransformGcMetrics', () => { + describe('given "jvm.gc.time"', () => { + it('converts the value to milliseconds', async () => { + const chartBase = {} as unknown as ChartBase; + const response = { + hits: { total: { value: 1 } }, + aggregations: { + per_pool: { + buckets: [ + { + key: 'Copy', + doc_count: 30, + timeseries: { + buckets: [ + { + key_as_string: '2021-10-05T16:03:30.000Z', + key: 1633449810000, + doc_count: 1, + max: { + value: 23750, + }, + derivative: { + value: 11, + }, + value: { + value: 11, + }, + }, + ], + }, + }, + ], + }, + }, + }; + const setup = { + apmEventClient: { search: () => Promise.resolve(response) }, + config: { 'xpack.gc.metricsInterval': 0 }, + } as unknown as Setup; + const fieldName = METRIC_JAVA_GC_TIME; + + const { series } = await fetchAndTransformGcMetrics({ + chartBase, + environment: 'test environment', + fieldName, + kuery: '', + operationName: 'test operation name', + setup, + serviceName: 'test service name', + start: 1633456140000, + end: 1633457078105, + }); + + expect(series[0].data[0].y).toEqual(22000); + }); + }); + + describe('given "jvm.gc.rate"', () => { + it('does not convert the value to milliseconds', async () => { + const chartBase = {} as unknown as ChartBase; + const response = { + hits: { + total: { + value: 62, + }, + }, + aggregations: { + per_pool: { + buckets: [ + { + key: 'Copy', + doc_count: 31, + timeseries: { + buckets: [ + { + key_as_string: '2021-10-05T18:01:30.000Z', + key: 1633456890000, + doc_count: 1, + max: { + value: 815, + }, + derivative: { + value: 4, + }, + value: { + value: 4, + }, + }, + ], + }, + }, + ], + }, + }, + }; + const setup = { + apmEventClient: { search: () => Promise.resolve(response) }, + config: { 'xpack.gc.metricsInterval': 0 }, + } as unknown as Setup; + const fieldName = METRIC_JAVA_GC_COUNT; + + const { series } = await fetchAndTransformGcMetrics({ + chartBase, + environment: 'test environment', + fieldName, + kuery: '', + operationName: 'test operation name', + setup, + serviceName: 'test service name', + start: 1633456140000, + end: 1633457078105, + }); + + expect(series[0].data[0].y).toEqual(8); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index 8231e4d3c6faa..ba35836452122 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -135,10 +135,17 @@ export async function fetchAndTransformGcMetrics({ const data = timeseriesData.buckets.map((bucket) => { // derivative/value will be undefined for the first hit and if the `max` value is null const bucketValue = bucket.value?.value; - const y = isFiniteNumber(bucketValue) + + const unconvertedY = isFiniteNumber(bucketValue) ? round(bucketValue * (60 / bucketSize), 1) : null; + // convert to milliseconds if we're calculating time, but not for rate + const y = + unconvertedY !== null && fieldName === METRIC_JAVA_GC_TIME + ? unconvertedY * 1000 + : unconvertedY; + return { y, x: bucket.key, diff --git a/x-pack/test/apm_api_integration/tests/metrics_charts/metrics_charts.ts b/x-pack/test/apm_api_integration/tests/metrics_charts/metrics_charts.ts index 8d3a18a44f02e..7b621de111ef8 100644 --- a/x-pack/test/apm_api_integration/tests/metrics_charts/metrics_charts.ts +++ b/x-pack/test/apm_api_integration/tests/metrics_charts/metrics_charts.ts @@ -39,11 +39,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('contains CPU usage and System memory usage chart data', async () => { expect(chartsResponse.status).to.be(200); expectSnapshot(chartsResponse.body.charts.map((chart) => chart.title)).toMatchInline(` - Array [ - "CPU usage", - "System memory usage", - ] - `); + Array [ + "CPU usage", + "System memory usage", + ] + `); }); describe('CPU usage', () => { @@ -57,25 +57,25 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct series', () => { expect(cpuUsageChart).to.not.empty(); expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "System max", - "System average", - "Process max", - "Process average", - ] - `); + Array [ + "System max", + "System average", + "Process max", + "Process average", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 0.714, - 0.3877, - 0.75, - 0.2543, - ] - `); + Array [ + 0.714, + 0.3877, + 0.75, + 0.2543, + ] + `); }); }); @@ -91,21 +91,21 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(systemMemoryUsageChart).to.not.empty(); expectSnapshot(systemMemoryUsageChart?.series.map(({ title }) => title)) .toMatchInline(` - Array [ - "Max", - "Average", - ] - `); + Array [ + "Max", + "Average", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(systemMemoryUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 0.722093920925555, - 0.718173546796348, - ] - `); + Array [ + 0.722093920925555, + 0.718173546796348, + ] + `); }); }); }); @@ -128,16 +128,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct chart data', async () => { expect(chartsResponse.status).to.be(200); expectSnapshot(chartsResponse.body.charts.map((chart) => chart.title)).toMatchInline(` - Array [ - "CPU usage", - "System memory usage", - "Heap Memory", - "Non-Heap Memory", - "Thread Count", - "Garbage collection per minute", - "Garbage collection time spent per minute", - ] - `); + Array [ + "CPU usage", + "System memory usage", + "Heap Memory", + "Non-Heap Memory", + "Thread Count", + "Garbage collection per minute", + "Garbage collection time spent per minute", + ] + `); }); describe('CPU usage', () => { @@ -151,37 +151,37 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct series', () => { expect(cpuUsageChart).to.not.empty(); expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "System max", - "System average", - "Process max", - "Process average", - ] - `); + Array [ + "System max", + "System average", + "Process max", + "Process average", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 0.203, - 0.178777777777778, - 0.01, - 0.009, - ] - `); + Array [ + 0.203, + 0.178777777777778, + 0.01, + 0.009, + ] + `); }); it('has the correct rate', async () => { const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y); expectSnapshot(yValues).toMatchInline(` - Array [ - 0.193, - 0.193, - 0.009, - 0.009, - ] - `); + Array [ + 0.193, + 0.193, + 0.009, + 0.009, + ] + `); }); }); @@ -197,31 +197,31 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(systemMemoryUsageChart).to.not.empty(); expectSnapshot(systemMemoryUsageChart?.series.map(({ title }) => title)) .toMatchInline(` - Array [ - "Max", - "Average", - ] - `); + Array [ + "Max", + "Average", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(systemMemoryUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 0.707924703557837, - 0.705395980841182, - ] - `); + Array [ + 0.707924703557837, + 0.705395980841182, + ] + `); }); it('has the correct rate', async () => { const yValues = systemMemoryUsageChart?.series.map((serie) => first(serie.data)?.y); expectSnapshot(yValues).toMatchInline(` - Array [ - 0.707924703557837, - 0.707924703557837, - ] - `); + Array [ + 0.707924703557837, + 0.707924703557837, + ] + `); }); }); @@ -236,34 +236,34 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct series', () => { expect(cpuUsageChart).to.not.empty(); expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "Avg. used", - "Avg. committed", - "Avg. limit", - ] - `); + Array [ + "Avg. used", + "Avg. committed", + "Avg. limit", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 222501617.777778, - 374341632, - 1560281088, - ] - `); + Array [ + 222501617.777778, + 374341632, + 1560281088, + ] + `); }); it('has the correct rate', async () => { const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y); expectSnapshot(yValues).toMatchInline(` - Array [ - 211472896, - 374341632, - 1560281088, - ] - `); + Array [ + 211472896, + 374341632, + 1560281088, + ] + `); }); }); @@ -278,31 +278,31 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct series', () => { expect(cpuUsageChart).to.not.empty(); expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "Avg. used", - "Avg. committed", - ] - `); + Array [ + "Avg. used", + "Avg. committed", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 138573397.333333, - 147677639.111111, - ] - `); + Array [ + 138573397.333333, + 147677639.111111, + ] + `); }); it('has the correct rate', async () => { const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y); expectSnapshot(yValues).toMatchInline(` - Array [ - 138162752, - 147386368, - ] - `); + Array [ + 138162752, + 147386368, + ] + `); }); }); @@ -317,31 +317,31 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct series', () => { expect(cpuUsageChart).to.not.empty(); expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "Avg. count", - "Max count", - ] - `); + Array [ + "Avg. count", + "Max count", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 44.4444444444444, - 45, - ] - `); + Array [ + 44.4444444444444, + 45, + ] + `); }); it('has the correct rate', async () => { const yValues = cpuUsageChart?.series.map((serie) => first(serie.data)?.y); expectSnapshot(yValues).toMatchInline(` - Array [ - 44, - 44, - ] - `); + Array [ + 44, + 44, + ] + `); }); }); @@ -356,21 +356,21 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct series', () => { expect(cpuUsageChart).to.not.empty(); expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "G1 Old Generation", - "G1 Young Generation", - ] - `); + Array [ + "G1 Old Generation", + "G1 Young Generation", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 0, - 3, - ] - `); + Array [ + 0, + 3, + ] + `); }); }); @@ -385,21 +385,21 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('has correct series', () => { expect(cpuUsageChart).to.not.empty(); expectSnapshot(cpuUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "G1 Old Generation", - "G1 Young Generation", - ] - `); + Array [ + "G1 Old Generation", + "G1 Young Generation", + ] + `); }); it('has correct series overall values', () => { expectSnapshot(cpuUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 0, - 37.5, - ] - `); + Array [ + 0, + 37500, + ] + `); }); }); }); @@ -419,26 +419,26 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(systemMemoryUsageChart).to.not.empty(); expectSnapshot(systemMemoryUsageChart?.series.map(({ title }) => title)).toMatchInline(` - Array [ - "Max", - "Average", - ] - `); + Array [ + "Max", + "Average", + ] + `); expectSnapshot(systemMemoryUsageChart?.series.map(({ overallValue }) => overallValue)) .toMatchInline(` - Array [ - 0.114523896426499, - 0.114002376090415, - ] - `); + Array [ + 0.114523896426499, + 0.114002376090415, + ] + `); const yValues = systemMemoryUsageChart?.series.map((serie) => first(serie.data)?.y); expectSnapshot(yValues).toMatchInline(` - Array [ - 0.11383724014064, - 0.11383724014064, - ] - `); + Array [ + 0.11383724014064, + 0.11383724014064, + ] + `); }); }); } From a054749c7a49c211e5c09dd64b35f76ead569aa7 Mon Sep 17 00:00:00 2001 From: Sandra G Date: Tue, 12 Oct 2021 09:28:37 -0400 Subject: [PATCH 16/68] [Stack Monitoring] ES Overview fix completed recoveries section (#114179) * fix completed recoveries section * fix type * fix type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pages/elasticsearch/overview.tsx | 5 +- .../public/components/elasticsearch/index.ts | 1 + .../elasticsearch/overview/index.ts | 2 + .../elasticsearch/overview/overview_react.js | 66 ++++++++ .../elasticsearch/shard_activity/index.js | 1 + .../shard_activity/parse_props.js | 14 +- .../shard_activity/shard_activity_react.js | 156 ++++++++++++++++++ 7 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/monitoring/public/components/elasticsearch/overview/overview_react.js create mode 100644 x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/shard_activity_react.js diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx index c58aaa5dffb04..d1500d5d9587b 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx @@ -10,7 +10,8 @@ import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { GlobalStateContext } from '../../contexts/global_state_context'; -import { ElasticsearchOverview } from '../../../components/elasticsearch'; +// @ts-ignore +import { ElasticsearchOverviewReact } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; @@ -78,7 +79,7 @@ export const ElasticsearchOverviewPage: React.FC = ({ clusters } const shardActivityData = shardActivity && filterShardActivityData(shardActivity); // no filter on data = null return ( - + + + + + + + + {metricsToShow.map((metric, index) => ( + + + + + ))} + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/index.js index bcdbbe715f86e..8c0b8b4c9c82d 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/index.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/index.js @@ -6,3 +6,4 @@ */ export { ShardActivity } from './shard_activity'; +export { ShardActivityReact } from './shard_activity_react'; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/parse_props.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/parse_props.js index 76f22583af0c8..1f0ed47adf387 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/parse_props.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/parse_props.js @@ -37,18 +37,26 @@ export const parseProps = (props) => { target, translog, type, + timezone, } = props; const { files, size } = index; - const injector = Legacy.shims.getAngularInjector(); - const timezone = injector.get('config').get('dateFormat:tz'); + + let thisTimezone; + // react version passes timezone while Angular uses injector + if (!timezone) { + const injector = Legacy.shims.getAngularInjector(); + thisTimezone = injector.get('config').get('dateFormat:tz'); + } else { + thisTimezone = timezone; + } return { name: indexName || index.name, shard: `${id} / ${isPrimary ? 'Primary' : 'Replica'}`, relocationType: type === 'PRIMARY_RELOCATION' ? 'Primary Relocation' : normalizeString(type), stage: normalizeString(stage), - startTime: formatDateTimeLocal(startTimeInMillis, timezone), + startTime: formatDateTimeLocal(startTimeInMillis, thisTimezone), totalTime: formatMetric(Math.floor(totalTimeInMillis / 1000), '00:00:00'), isCopiedFromPrimary: !isPrimary || type === 'PRIMARY_RELOCATION', sourceName: source.name === undefined ? 'n/a' : source.name, diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/shard_activity_react.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/shard_activity_react.js new file mode 100644 index 0000000000000..cc219ff0fff32 --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_activity/shard_activity_react.js @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment } from 'react'; +import { EuiText, EuiTitle, EuiLink, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiMonitoringTable } from '../../table'; +import { RecoveryIndex } from './recovery_index'; +import { TotalTime } from './total_time'; +import { SourceDestination } from './source_destination'; +import { FilesProgress, BytesProgress, TranslogProgress } from './progress'; +import { parseProps } from './parse_props'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; + +const columns = [ + { + name: i18n.translate('xpack.monitoring.kibana.shardActivity.indexTitle', { + defaultMessage: 'Index', + }), + field: 'name', + render: (_name, shard) => , + }, + { + name: i18n.translate('xpack.monitoring.kibana.shardActivity.stageTitle', { + defaultMessage: 'Stage', + }), + field: 'stage', + }, + { + name: i18n.translate('xpack.monitoring.kibana.shardActivity.totalTimeTitle', { + defaultMessage: 'Total Time', + }), + field: null, + render: (shard) => , + }, + { + name: i18n.translate('xpack.monitoring.kibana.shardActivity.sourceDestinationTitle', { + defaultMessage: 'Source / Destination', + }), + field: null, + render: (shard) => , + }, + { + name: i18n.translate('xpack.monitoring.kibana.shardActivity.filesTitle', { + defaultMessage: 'Files', + }), + field: null, + render: (shard) => , + }, + { + name: i18n.translate('xpack.monitoring.kibana.shardActivity.bytesTitle', { + defaultMessage: 'Bytes', + }), + field: null, + render: (shard) => , + }, + { + name: i18n.translate('xpack.monitoring.kibana.shardActivity.translogTitle', { + defaultMessage: 'Translog', + }), + field: null, + render: (shard) => , + }, +]; + +export const ShardActivityReact = (props) => { + const { + data: rawData, + sorting, + pagination, + onTableChange, + toggleShardActivityHistory, + showShardActivityHistory, + } = props; + const { services } = useKibana(); + const timezone = services.uiSettings?.get('dateFormat:tz'); + const getNoDataMessage = () => { + if (showShardActivityHistory) { + return i18n.translate('xpack.monitoring.elasticsearch.shardActivity.noDataMessage', { + defaultMessage: + 'There are no historical shard activity records for the selected time range.', + }); + } + return ( + + +
+ + + + ), + }} + /> +
+ ); + }; + + const rows = rawData.map((data) => parseProps({ ...data, timezone })); + + return ( + + + +

+ +

+
+
+ + + } + onChange={toggleShardActivityHistory} + checked={showShardActivityHistory} + /> + + +
+ ); +}; From 9e42b32015cd0bdca0aecbfc93c3bfc1b1404e1a Mon Sep 17 00:00:00 2001 From: Sandra G Date: Tue, 12 Oct 2021 09:32:52 -0400 Subject: [PATCH 17/68] [Stack Monitoring] Add breadcrumbs to ES pages after migrating from Angular (#114555) * add breadcrumbs * fix bad merge * fix types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pages/elasticsearch/ccr_page.tsx | 13 +++++++++-- .../pages/elasticsearch/ccr_shard_page.tsx | 22 +++++++++++++++--- .../elasticsearch/index_advanced_page.tsx | 21 +++++++++++++++-- .../pages/elasticsearch/index_page.tsx | 20 ++++++++++++++-- .../pages/elasticsearch/indices_page.tsx | 14 +++++++++-- .../pages/elasticsearch/ml_jobs_page.tsx | 14 +++++++++-- .../elasticsearch/node_advanced_page.tsx | 23 ++++++++++++++++--- .../pages/elasticsearch/node_page.tsx | 22 +++++++++++++++--- 8 files changed, 130 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx index 8a9a736286c3f..cb37705c959aa 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; @@ -18,7 +18,7 @@ import { SetupModeContext } from '../../../components/setup_mode/setup_mode_cont import { AlertsByName } from '../../../alerts/types'; import { fetchAlerts } from '../../../lib/fetch_alerts'; import { ELASTICSEARCH_SYSTEM_ID, RULE_CCR_READ_EXCEPTIONS } from '../../../../common/constants'; - +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; interface SetupModeProps { setupMode: any; flyoutComponent: any; @@ -27,6 +27,7 @@ interface SetupModeProps { export const ElasticsearchCcrPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const { services } = useKibana<{ data: any }>(); const clusterUuid = globalState.cluster_uuid; @@ -37,6 +38,14 @@ export const ElasticsearchCcrPage: React.FC = ({ clusters }) => const [data, setData] = useState({} as any); const [alerts, setAlerts] = useState({}); + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + }); + } + }, [cluster, generateBreadcrumbs]); + const title = i18n.translate('xpack.monitoring.elasticsearch.ccr.title', { defaultMessage: 'Elasticsearch - Ccr', }); diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx index 21f9fd10f0806..29cf9ade8d997 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx @@ -4,8 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { find } from 'lodash'; import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { PageTemplate } from '../page_template'; @@ -19,6 +20,7 @@ import { SetupModeContext } from '../../../components/setup_mode/setup_mode_cont import { AlertsByName } from '../../../alerts/types'; import { fetchAlerts } from '../../../lib/fetch_alerts'; import { ELASTICSEARCH_SYSTEM_ID, RULE_CCR_READ_EXCEPTIONS } from '../../../../common/constants'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; interface SetupModeProps { setupMode: any; @@ -26,14 +28,28 @@ interface SetupModeProps { bottomBarComponent: any; } -export const ElasticsearchCcrShardPage: React.FC = () => { +export const ElasticsearchCcrShardPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); + const [data, setData] = useState({} as any); const { index, shardId }: { index: string; shardId: string } = useParams(); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const clusterUuid = globalState.cluster_uuid; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + name: 'ccr', + instance: `Index: ${index} Shard: ${shardId}`, + }); + } + }, [cluster, generateBreadcrumbs, index, shardId]); const ccs = globalState.ccs; - const [data, setData] = useState({} as any); const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.title', { diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx index 86dba4e2f921c..f2f2ec36b7cd9 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx @@ -4,8 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { GlobalStateContext } from '../../contexts/global_state_context'; @@ -19,9 +20,11 @@ import { AdvancedIndex } from '../../../components/elasticsearch/index/advanced' import { AlertsByName } from '../../../alerts/types'; import { fetchAlerts } from '../../../lib/fetch_alerts'; import { ELASTICSEARCH_SYSTEM_ID, RULE_LARGE_SHARD_SIZE } from '../../../../common/constants'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -export const ElasticsearchIndexAdvancedPage: React.FC = () => { +export const ElasticsearchIndexAdvancedPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const { services } = useKibana<{ data: any }>(); const { index }: { index: string } = useParams(); const { zoomInfo, onBrush } = useCharts(); @@ -29,6 +32,20 @@ export const ElasticsearchIndexAdvancedPage: React.FC = () => { const [data, setData] = useState({} as any); const [alerts, setAlerts] = useState({}); + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + name: 'indices', + instance: index, + }); + } + }, [cluster, generateBreadcrumbs, index]); + const title = i18n.translate('xpack.monitoring.elasticsearch.index.advanced.title', { defaultMessage: 'Elasticsearch - Indices - {indexName} - Advanced', values: { diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx index b73fee0c963cc..8e70a99e67914 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx @@ -4,9 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; +import { find } from 'lodash'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore @@ -23,9 +24,11 @@ import { labels } from '../../../components/elasticsearch/shard_allocation/lib/l import { AlertsByName } from '../../../alerts/types'; import { fetchAlerts } from '../../../lib/fetch_alerts'; import { ELASTICSEARCH_SYSTEM_ID, RULE_LARGE_SHARD_SIZE } from '../../../../common/constants'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -export const ElasticsearchIndexPage: React.FC = () => { +export const ElasticsearchIndexPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const { services } = useKibana<{ data: any }>(); const { index }: { index: string } = useParams(); const { zoomInfo, onBrush } = useCharts(); @@ -34,6 +37,19 @@ export const ElasticsearchIndexPage: React.FC = () => { const [indexLabel, setIndexLabel] = useState(labels.index as any); const [nodesByIndicesData, setNodesByIndicesData] = useState([]); const [alerts, setAlerts] = useState({}); + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + name: 'indices', + instance: index, + }); + } + }, [cluster, generateBreadcrumbs, index]); const title = i18n.translate('xpack.monitoring.elasticsearch.index.overview.title', { defaultMessage: 'Elasticsearch - Indices - {indexName} - Overview', diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx index 44e01cbf66ff3..277bde2ac35cb 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; @@ -19,16 +19,18 @@ import { useLocalStorage } from '../../hooks/use_local_storage'; import { AlertsByName } from '../../../alerts/types'; import { fetchAlerts } from '../../../lib/fetch_alerts'; import { ELASTICSEARCH_SYSTEM_ID, RULE_LARGE_SHARD_SIZE } from '../../../../common/constants'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; export const ElasticsearchIndicesPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const { services } = useKibana<{ data: any }>(); const { getPaginationTableProps } = useTable('elasticsearch.indices'); const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; const cluster = find(clusters, { cluster_uuid: clusterUuid, - }); + }) as any; const [data, setData] = useState({} as any); const [showSystemIndices, setShowSystemIndices] = useLocalStorage( 'showSystemIndices', @@ -36,6 +38,14 @@ export const ElasticsearchIndicesPage: React.FC = ({ clusters }) ); const [alerts, setAlerts] = useState({}); + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + }); + } + }, [cluster, generateBreadcrumbs]); + const title = i18n.translate('xpack.monitoring.elasticsearch.indices.routeTitle', { defaultMessage: 'Elasticsearch - Indices', }); diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx index 7edf15886cc20..b97007f1c1462 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; @@ -16,6 +16,7 @@ import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; import type { MLJobs } from '../../../types'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; interface SetupModeProps { @@ -26,13 +27,22 @@ interface SetupModeProps { export const ElasticsearchMLJobsPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const { services } = useKibana<{ data: any }>(); const { getPaginationTableProps } = useTable('elasticsearch.mlJobs'); const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; const cluster = find(clusters, { cluster_uuid: clusterUuid, - }); + }) as any; + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + }); + } + }, [cluster, generateBreadcrumbs]); const [data, setData] = useState({} as any); const title = i18n.translate('xpack.monitoring.elasticsearch.mlJobs.routeTitle', { diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx index 9c0f5f4627b01..820eb2fb20cd8 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_advanced_page.tsx @@ -4,8 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { find } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ItemTemplate } from './item_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; @@ -24,19 +25,35 @@ import { RULE_DISK_USAGE, RULE_MEMORY_USAGE, } from '../../../../common/constants'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -export const ElasticsearchNodeAdvancedPage: React.FC = () => { +export const ElasticsearchNodeAdvancedPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const { zoomInfo, onBrush } = useCharts(); + const [data, setData] = useState({} as any); const { node }: { node: string } = useParams(); const { services } = useKibana<{ data: any }>(); const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; - const [data, setData] = useState({} as any); const [alerts, setAlerts] = useState({}); + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + name: 'nodes', + instance: data?.nodeSummary?.name, + }); + } + }, [cluster, generateBreadcrumbs, data?.nodeSummary?.name]); + const title = i18n.translate('xpack.monitoring.elasticsearch.node.advanced.title', { defaultMessage: 'Elasticsearch - Nodes - {nodeName} - Advanced', values: { diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx index f5ae4dc29b28e..b2d6fb94183ec 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx @@ -4,8 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext, useState, useCallback, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { find } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ItemTemplate } from './item_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; @@ -30,9 +31,11 @@ import { RULE_DISK_USAGE, RULE_MEMORY_USAGE, } from '../../../../common/constants'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -export const ElasticsearchNodePage: React.FC = () => { +export const ElasticsearchNodePage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); const { zoomInfo, onBrush } = useCharts(); const [showSystemIndices, setShowSystemIndices] = useLocalStorage( 'showSystemIndices', @@ -42,10 +45,23 @@ export const ElasticsearchNodePage: React.FC = () => { const { node }: { node: string } = useParams(); const { services } = useKibana<{ data: any }>(); + const [data, setData] = useState({} as any); const clusterUuid = globalState.cluster_uuid; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inElasticsearch: true, + name: 'nodes', + instance: data?.nodeSummary?.name, + }); + } + }, [cluster, generateBreadcrumbs, data?.nodeSummary?.name]); const ccs = globalState.ccs; - const [data, setData] = useState({} as any); const [nodesByIndicesData, setNodesByIndicesData] = useState([]); const title = i18n.translate('xpack.monitoring.elasticsearch.node.overview.title', { From d37cf3045bed23a05c491a4eebe9928f5b054be5 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 12 Oct 2021 06:35:55 -0700 Subject: [PATCH 18/68] [Reporting] Remove unused settings for 8.0 (#114216) * [Reporting] Remove unused settings for 8.0 * add helpful version comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/reporting/server/config/index.ts | 10 +++------- x-pack/plugins/reporting/server/config/schema.ts | 4 ++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index c7afdb22f8bdb..f8fa47bc00bb0 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -17,13 +17,9 @@ export const config: PluginConfigDescriptor = { exposeToBrowser: { poll: true, roles: true }, schema: ConfigSchema, deprecations: ({ unused }) => [ - unused('capture.browser.chromium.maxScreenshotDimension'), - unused('capture.concurrency'), - unused('capture.settleTime'), - unused('capture.timeout'), - unused('poll.jobCompletionNotifier.intervalErrorMultiplier'), - unused('poll.jobsRefresh.intervalErrorMultiplier'), - unused('kibanaApp'), + unused('capture.browser.chromium.maxScreenshotDimension'), // unused since 7.8 + unused('poll.jobCompletionNotifier.intervalErrorMultiplier'), // unused since 7.10 + unused('poll.jobsRefresh.intervalErrorMultiplier'), // unused since 7.10 (settings, fromPath, addDeprecation) => { const reporting = get(settings, fromPath); if (reporting?.index) { diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index affd8b7bee7ff..832cf6c28e1fa 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -160,11 +160,11 @@ const RolesSchema = schema.object({ const PollSchema = schema.object({ jobCompletionNotifier: schema.object({ interval: schema.number({ defaultValue: 10000 }), - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // unused + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // deprecated as unused since 7.10 }), jobsRefresh: schema.object({ interval: schema.number({ defaultValue: 5000 }), - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // unused + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // deprecated as unused since 7.10 }), }); From 4f893931248fbdf6bbfad3459b0fd33404f65697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 12 Oct 2021 15:37:50 +0200 Subject: [PATCH 19/68] [APM] Add Table of contents to data model docs (#114608) --- x-pack/plugins/apm/dev_docs/apm_queries.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/apm/dev_docs/apm_queries.md b/x-pack/plugins/apm/dev_docs/apm_queries.md index 8508e5a173c85..0fbcd4fc1c8a8 100644 --- a/x-pack/plugins/apm/dev_docs/apm_queries.md +++ b/x-pack/plugins/apm/dev_docs/apm_queries.md @@ -1,7 +1,17 @@ -# Data model +### Table of Contents + - [Transactions](#transactions) + - [System metrics](#system-metrics) + - [Transaction breakdown metrics](#transaction-breakdown-metrics) + - [Span breakdown metrics](#span-breakdown-metrics) + - [Service destination metrics](#service-destination-metrics) + - [Common filters](#common-filters) + +--- + +### Data model Elastic APM agents capture different types of information from within their instrumented applications. These are known as events, and can be spans, transactions, errors, or metrics. You can find more information [here](https://www.elastic.co/guide/en/apm/get-started/current/apm-data-model.html). -# Running examples +### Running examples You can run the example queries on the [edge cluster](https://edge-oblt.elastic.dev/) or any another cluster that contains APM data. # Transactions @@ -307,7 +317,7 @@ The above example is overly simplified. In reality [we do a bit more](https://gi -# Transaction breakdown metrics (`transaction_breakdown`) +# Transaction breakdown metrics A pre-aggregations of transaction documents where `transaction.breakdown.count` is the number of original transactions. @@ -327,7 +337,7 @@ Noteworthy fields: `transaction.name`, `transaction.type` } ``` -# Span breakdown metrics (`span_breakdown`) +# Span breakdown metrics A pre-aggregations of span documents where `span.self_time.count` is the number of original spans. Measures the "self-time" for a span type, and optional subtype, within a transaction group. @@ -482,7 +492,7 @@ GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 } ``` -## Common filters +# Common filters Most Elasticsearch queries will need to have one or more filters. There are a couple of reasons for adding filters: From afe81bb1a2a8da526a1f42f19e442c32e2ab0fd0 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 12 Oct 2021 15:49:17 +0200 Subject: [PATCH 20/68] [Reporting] Fix missing force now behaviour for v2 reports (#114516) * fix missing force now behaviour for v2 reports * added jest test * updated jest test snapshot to match removal of forceNow injection from locator params Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/export_types/common/index.ts | 1 - .../export_types/common/set_force_now.ts | 24 ---------- .../v2/get_full_redirect_app_url.test.ts | 45 +++++++++++++++++++ .../common/v2/get_full_redirect_app_url.ts | 7 ++- .../export_types/png_v2/execute_job.test.ts | 6 +-- .../server/export_types/png_v2/execute_job.ts | 5 +-- .../printable_pdf_v2/execute_job.ts | 3 +- .../printable_pdf_v2/lib/generate_pdf.ts | 5 ++- 8 files changed, 60 insertions(+), 36 deletions(-) delete mode 100644 x-pack/plugins/reporting/server/export_types/common/set_force_now.ts create mode 100644 x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/common/index.ts b/x-pack/plugins/reporting/server/export_types/common/index.ts index 09d3236fa7b54..c35dcb5344e21 100644 --- a/x-pack/plugins/reporting/server/export_types/common/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/index.ts @@ -12,7 +12,6 @@ export { omitBlockedHeaders } from './omit_blocked_headers'; export { validateUrls } from './validate_urls'; export { generatePngObservableFactory } from './generate_png'; export { getCustomLogo } from './get_custom_logo'; -export { setForceNow } from './set_force_now'; export interface TimeRangeParams { min?: Date | string | number | null; diff --git a/x-pack/plugins/reporting/server/export_types/common/set_force_now.ts b/x-pack/plugins/reporting/server/export_types/common/set_force_now.ts deleted file mode 100644 index b4f4b1b0ace05..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/common/set_force_now.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { LocatorParams } from '../../../common/types'; - -/** - * Add `forceNow` to {@link LocatorParams['params']} to enable clients to set the time appropriately when - * reporting navigates to the page in Chromium. - */ -export const setForceNow = - (forceNow: string) => - (locator: LocatorParams): LocatorParams => { - return { - ...locator, - params: { - ...locator.params, - forceNow, - }, - }; - }; diff --git a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.test.ts b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.test.ts new file mode 100644 index 0000000000000..7a2ec5b83e7f4 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash'; +import { ReportingConfig } from '../../../config/config'; +import { getFullRedirectAppUrl } from './get_full_redirect_app_url'; + +describe('getFullRedirectAppUrl', () => { + let config: ReportingConfig; + + beforeEach(() => { + const values = { + server: { + basePath: 'test', + }, + kibanaServer: { + protocol: 'http', + hostname: 'localhost', + port: '1234', + }, + }; + config = { + get: jest.fn((...args: string[]) => get(values, args)), + kbnConfig: { + get: jest.fn((...args: string[]) => get(values, args)), + }, + }; + }); + + test('smoke test', () => { + expect(getFullRedirectAppUrl(config, 'test', undefined)).toBe( + 'http://localhost:1234/test/s/test/app/management/insightsAndAlerting/reporting/r' + ); + }); + + test('adding forceNow', () => { + expect(getFullRedirectAppUrl(config, 'test', 'TEST with a space')).toBe( + 'http://localhost:1234/test/s/test/app/management/insightsAndAlerting/reporting/r?forceNow=TEST%20with%20a%20space' + ); + }); +}); diff --git a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts index bb640eff667e9..9c329db64fa1a 100644 --- a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts +++ b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_redirect_app_url.ts @@ -10,7 +10,11 @@ import { ReportingConfig } from '../../..'; import { getRedirectAppPath } from '../../../../common/constants'; import { buildKibanaPath } from '../../../../common/build_kibana_path'; -export function getFullRedirectAppUrl(config: ReportingConfig, spaceId?: string) { +export function getFullRedirectAppUrl( + config: ReportingConfig, + spaceId?: string, + forceNow?: string +) { const [basePath, protocol, hostname, port] = [ config.kbnConfig.get('server', 'basePath'), config.get('kibanaServer', 'protocol'), @@ -29,5 +33,6 @@ export function getFullRedirectAppUrl(config: ReportingConfig, spaceId?: string) hostname, port, pathname: path, + query: forceNow ? { forceNow } : undefined, }); } diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts index e57eab382468c..3cf3c057e7b9c 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts @@ -102,12 +102,10 @@ test(`passes browserTimezone to generatePng`, async () => { "warning": [Function], }, Array [ - "localhost:80undefined/app/management/insightsAndAlerting/reporting/r", + "localhost:80undefined/app/management/insightsAndAlerting/reporting/r?forceNow=test", Object { "id": "test", - "params": Object { - "forceNow": "test", - }, + "params": Object {}, "version": "test", }, ], diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index 5c2cc66d3d3aa..a7478de1cc96e 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -16,7 +16,6 @@ import { getConditionalHeaders, omitBlockedHeaders, generatePngObservableFactory, - setForceNow, } from '../common'; import { getFullRedirectAppUrl } from '../common/v2/get_full_redirect_app_url'; import { TaskPayloadPNGV2 } from './types'; @@ -38,8 +37,8 @@ export const runTaskFnFactory: RunTaskFnFactory> = map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), mergeMap((conditionalHeaders) => { - const url = getFullRedirectAppUrl(config, job.spaceId); - const [locatorParams] = job.locatorParams.map(setForceNow(job.forceNow)); + const url = getFullRedirectAppUrl(config, job.spaceId, job.forceNow); + const [locatorParams] = job.locatorParams; apmGetAssets?.end(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index e44f5e98fa4fe..2c553295aa840 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -16,7 +16,6 @@ import { getConditionalHeaders, omitBlockedHeaders, getCustomLogo, - setForceNow, } from '../common'; import { generatePdfObservableFactory } from './lib/generate_pdf'; import { TaskPayloadPDFV2 } from './types'; @@ -50,7 +49,7 @@ export const runTaskFnFactory: RunTaskFnFactory> = jobLogger, job, title, - locatorParams.map(setForceNow(job.forceNow)), + locatorParams, browserTimezone, conditionalHeaders, layout, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index 424a347876a1d..9fb31a1104279 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -56,7 +56,10 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { /** * For each locator we get the relative URL to the redirect app */ - const urls = locatorParams.map(() => getFullRedirectAppUrl(reporting.getConfig(), job.spaceId)); + const urls = locatorParams.map(() => + getFullRedirectAppUrl(reporting.getConfig(), job.spaceId, job.forceNow) + ); + const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { logger, urlsOrUrlLocatorTuples: zip(urls, locatorParams) as UrlOrUrlLocatorTuple[], From bc96e408c9a852b3aa78fbea283a6e6ff595c1e9 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 12 Oct 2021 07:02:03 -0700 Subject: [PATCH 21/68] Changes `rewriteBasePath` core config deprecation level to warning (#114566) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/config/deprecation/core_deprecations.test.ts | 7 ++++++- src/core/server/config/deprecation/core_deprecations.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index e08f2216f5cbe..99fe6c7cd1dc4 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -54,7 +54,7 @@ describe('core deprecations', () => { describe('rewriteBasePath', () => { it('logs a warning is server.basePath is set and server.rewriteBasePath is not', () => { - const { messages } = applyCoreDeprecations({ + const { messages, levels } = applyCoreDeprecations({ server: { basePath: 'foo', }, @@ -64,6 +64,11 @@ describe('core deprecations', () => { "You should set server.basePath along with server.rewriteBasePath. Starting in 7.0, Kibana will expect that all requests start with server.basePath rather than expecting you to rewrite the requests in your reverse proxy. Set server.rewriteBasePath to false to preserve the current behavior and silence this warning.", ] `); + expect(levels).toMatchInlineSnapshot(` + Array [ + "warning", + ] + `); }); it('does not log a warning if both server.basePath and server.rewriteBasePath are unset', () => { diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 5adbb338b42e4..79fb2aac60da4 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -16,6 +16,7 @@ const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, addDe 'will expect that all requests start with server.basePath rather than expecting you to rewrite ' + 'the requests in your reverse proxy. Set server.rewriteBasePath to false to preserve the ' + 'current behavior and silence this warning.', + level: 'warning', correctiveActions: { manualSteps: [ `Set 'server.rewriteBasePath' in the config file, CLI flag, or environment variable (in Docker only).`, From d5d364724bb6da744c5708a797a3358040cfc318 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 12 Oct 2021 16:32:40 +0200 Subject: [PATCH 22/68] [Exploratory view] Fix auto apply on date change (#114251) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/date_range_picker.tsx | 10 +- .../configurations/lens_attributes.ts | 6 +- .../exploratory_view/exploratory_view.tsx | 6 +- .../exploratory_view/header/header.test.tsx | 5 +- .../shared/exploratory_view/header/header.tsx | 11 +- .../hooks/use_lens_attributes.ts | 2 +- .../hooks/use_series_storage.tsx | 2 +- .../hooks/use_time_range.test.tsx | 108 ++++++++++++++++++ .../exploratory_view/hooks/use_time_range.ts | 66 +++++++++++ .../exploratory_view/lens_embeddable.tsx | 42 +------ 10 files changed, 194 insertions(+), 64 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.ts diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx index 643f01d570ead..5529f28927028 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx @@ -15,7 +15,7 @@ import { useUiSetting } from '../../../../../../../../src/plugins/kibana_react/p import { SeriesUrl } from '../types'; import { ReportTypes } from '../configurations/constants'; -export const parseAbsoluteDate = (date: string, options = {}) => { +export const parseRelativeDate = (date: string, options = {}) => { return DateMath.parse(date, options)!; }; export function DateRangePicker({ seriesId, series }: { seriesId: number; series: SeriesUrl }) { @@ -27,12 +27,12 @@ export function DateRangePicker({ seriesId, series }: { seriesId: number; series const { from: mainFrom, to: mainTo } = firstSeries!.time; - const startDate = parseAbsoluteDate(seriesFrom ?? mainFrom)!; - const endDate = parseAbsoluteDate(seriesTo ?? mainTo, { roundUp: true })!; + const startDate = parseRelativeDate(seriesFrom ?? mainFrom)!; + const endDate = parseRelativeDate(seriesTo ?? mainTo, { roundUp: true })!; const getTotalDuration = () => { - const mainStartDate = parseAbsoluteDate(mainFrom)!; - const mainEndDate = parseAbsoluteDate(mainTo, { roundUp: true })!; + const mainStartDate = parseRelativeDate(mainFrom)!; + const mainEndDate = parseRelativeDate(mainTo, { roundUp: true })!; return mainEndDate.diff(mainStartDate, 'millisecond'); }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 428ef54277fe2..fa5a8beb0087d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -41,7 +41,7 @@ import { } from './constants'; import { ColumnFilter, SeriesConfig, UrlFilter, URLReportDefinition } from '../types'; import { PersistableFilter } from '../../../../../../lens/common'; -import { parseAbsoluteDate } from '../components/date_range_picker'; +import { parseRelativeDate } from '../components/date_range_picker'; import { getDistributionInPercentageColumn } from './lens_columns/overall_column'; function getLayerReferenceName(layerId: string) { @@ -544,11 +544,11 @@ export class LensAttributes { time: { from }, } = layerConfig; - const inDays = Math.abs(parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'days')); + const inDays = Math.abs(parseRelativeDate(mainFrom).diff(parseRelativeDate(from), 'days')); if (inDays > 1) { return inDays + 'd'; } - const inHours = Math.abs(parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'hours')); + const inHours = Math.abs(parseRelativeDate(mainFrom).diff(parseRelativeDate(from), 'hours')); if (inHours === 0) { return null; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index faf064868dec5..9870d88d1220a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -96,11 +96,7 @@ export function ExploratoryView({ {lens ? ( <> - + + ); getByText('Refresh'); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 59b146ae9af1a..181c8342b87af 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -11,21 +11,18 @@ import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@el import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useSeriesStorage } from '../hooks/use_series_storage'; import { LastUpdated } from './last_updated'; -import { combineTimeRanges } from '../lens_embeddable'; import { ExpViewActionMenu } from '../components/action_menu'; +import { useExpViewTimeRange } from '../hooks/use_time_range'; interface Props { - seriesId?: number; lastUpdated?: number; lensAttributes: TypedLensByValueInput['attributes'] | null; } -export function ExploratoryViewHeader({ seriesId, lensAttributes, lastUpdated }: Props) { - const { getSeries, allSeries, setLastRefresh, reportType } = useSeriesStorage(); +export function ExploratoryViewHeader({ lensAttributes, lastUpdated }: Props) { + const { setLastRefresh } = useSeriesStorage(); - const series = seriesId ? getSeries(seriesId) : undefined; - - const timeRange = combineTimeRanges(reportType, allSeries, series); + const timeRange = useExpViewTimeRange(); return ( <> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index 23eee140b68cf..dbf36777b536f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -94,7 +94,7 @@ export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null if (isEmpty(indexPatterns) || isEmpty(allSeries) || !reportType) { return null; } - + // we only use the data from url to apply, since that get's updated to apply changes const allSeriesT: AllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); const layerConfigs = getLayerConfigs(allSeriesT, reportType, theme, indexPatterns); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index 83042876db2ae..e71c66ba1f11b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -45,7 +45,7 @@ export function convertAllShortSeries(allShortSeries: AllShortSeries) { } export const allSeriesKey = 'sr'; -const reportTypeKey = 'reportType'; +export const reportTypeKey = 'reportType'; export function UrlStorageContextProvider({ children, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.test.tsx new file mode 100644 index 0000000000000..38534b1c79e3e --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { allSeriesKey, reportTypeKey, UrlStorageContextProvider } from './use_series_storage'; +import { renderHook } from '@testing-library/react-hooks'; +import { useExpViewTimeRange } from './use_time_range'; +import { ReportTypes } from '../configurations/constants'; +import { createKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { TRANSACTION_DURATION } from '../configurations/constants/elasticsearch_fieldnames'; + +const mockSingleSeries = [ + { + name: 'performance-distribution', + dataType: 'ux', + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + selectedMetricField: TRANSACTION_DURATION, + reportDefinitions: { 'service.name': ['elastic-co'] }, + }, +]; + +const mockMultipleSeries = [ + ...mockSingleSeries, + { + name: 'kpi-over-time', + dataType: 'synthetics', + breakdown: 'user_agent.name', + time: { from: 'now-30m', to: 'now' }, + selectedMetricField: TRANSACTION_DURATION, + reportDefinitions: { 'service.name': ['elastic-co'] }, + }, +]; + +describe('useExpViewTimeRange', function () { + const storage = createKbnUrlStateStorage({ useHash: false }); + + function Wrapper({ children }: { children: JSX.Element }) { + return {children}; + } + it('should return expected result when there is one series', async function () { + await storage.set(allSeriesKey, mockSingleSeries); + await storage.set(reportTypeKey, ReportTypes.KPI); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: 'now-15m', + to: 'now', + }); + }); + + it('should return expected result when there are multiple KPI series', async function () { + await storage.set(allSeriesKey, mockMultipleSeries); + await storage.set(reportTypeKey, ReportTypes.KPI); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: 'now-15m', + to: 'now', + }); + }); + + it('should return expected result when there are multiple distribution series with relative dates', async function () { + await storage.set(allSeriesKey, mockMultipleSeries); + await storage.set(reportTypeKey, ReportTypes.DISTRIBUTION); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: 'now-30m', + to: 'now', + }); + }); + + it('should return expected result when there are multiple distribution series with absolute dates', async function () { + // from:'2021-10-11T09:55:39.551Z',to:'2021-10-11T10:55:41.516Z'))) + mockMultipleSeries[0].time.from = '2021-10-11T09:55:39.551Z'; + mockMultipleSeries[0].time.to = '2021-10-11T11:55:41.516Z'; + + mockMultipleSeries[1].time.from = '2021-01-11T09:55:39.551Z'; + mockMultipleSeries[1].time.to = '2021-10-11T10:55:41.516Z'; + + await storage.set(allSeriesKey, mockMultipleSeries); + await storage.set(reportTypeKey, ReportTypes.DISTRIBUTION); + + const { result } = renderHook(() => useExpViewTimeRange(), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + from: '2021-01-11T09:55:39.551Z', + to: '2021-10-11T11:55:41.516Z', + }); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.ts new file mode 100644 index 0000000000000..60087cfd0330c --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_time_range.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import { useMemo } from 'react'; +import { + AllSeries, + allSeriesKey, + convertAllShortSeries, + useSeriesStorage, +} from './use_series_storage'; + +import { ReportViewType, SeriesUrl } from '../types'; +import { ReportTypes } from '../configurations/constants'; +import { parseRelativeDate } from '../components/date_range_picker'; + +export const combineTimeRanges = ( + reportType: ReportViewType, + allSeries: SeriesUrl[], + firstSeries?: SeriesUrl +) => { + let to: string = ''; + let from: string = ''; + + if (reportType === ReportTypes.KPI) { + return firstSeries?.time; + } + + allSeries.forEach((series) => { + if ( + series.dataType && + series.selectedMetricField && + !isEmpty(series.reportDefinitions) && + series.time + ) { + const seriesFrom = parseRelativeDate(series.time.from)!; + const seriesTo = parseRelativeDate(series.time.to, { roundUp: true })!; + + if (!to || seriesTo > parseRelativeDate(to, { roundUp: true })) { + to = series.time.to; + } + if (!from || seriesFrom < parseRelativeDate(from)) { + from = series.time.from; + } + } + }); + + return { to, from }; +}; +export const useExpViewTimeRange = () => { + const { storage, reportType, lastRefresh, firstSeries } = useSeriesStorage(); + + return useMemo(() => { + // we only use the data from url to apply, since that get updated to apply changes + const allSeriesFromUrl: AllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); + const firstSeriesT = allSeriesFromUrl?.[0]; + + return firstSeriesT ? combineTimeRanges(reportType, allSeriesFromUrl, firstSeriesT) : undefined; + // we want to keep last refresh in dependencies to force refresh + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportType, storage, lastRefresh, firstSeries]); +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx index 9e4d9486dc155..235790e72862c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx @@ -8,50 +8,16 @@ import { i18n } from '@kbn/i18n'; import React, { Dispatch, SetStateAction, useCallback } from 'react'; import styled from 'styled-components'; -import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../lens/public'; import { useSeriesStorage } from './hooks/use_series_storage'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { ReportViewType, SeriesUrl } from './types'; -import { ReportTypes } from './configurations/constants'; +import { useExpViewTimeRange } from './hooks/use_time_range'; interface Props { lensAttributes: TypedLensByValueInput['attributes']; setLastUpdated: Dispatch>; } -export const combineTimeRanges = ( - reportType: ReportViewType, - allSeries: SeriesUrl[], - firstSeries?: SeriesUrl -) => { - let to: string = ''; - let from: string = ''; - - if (reportType === ReportTypes.KPI) { - return firstSeries?.time; - } - - allSeries.forEach((series) => { - if ( - series.dataType && - series.selectedMetricField && - !isEmpty(series.reportDefinitions) && - series.time - ) { - const seriesTo = new Date(series.time.to); - const seriesFrom = new Date(series.time.from); - if (!to || seriesTo > new Date(to)) { - to = series.time.to; - } - if (!from || seriesFrom < new Date(from)) { - from = series.time.from; - } - } - }); - - return { to, from }; -}; export function LensEmbeddable(props: Props) { const { lensAttributes, setLastUpdated } = props; @@ -62,11 +28,11 @@ export function LensEmbeddable(props: Props) { const LensComponent = lens?.EmbeddableComponent; - const { firstSeries, setSeries, allSeries, reportType } = useSeriesStorage(); + const { firstSeries, setSeries, reportType } = useSeriesStorage(); const firstSeriesId = 0; - const timeRange = firstSeries ? combineTimeRanges(reportType, allSeries, firstSeries) : null; + const timeRange = useExpViewTimeRange(); const onLensLoad = useCallback(() => { setLastUpdated(Date.now()); @@ -93,7 +59,7 @@ export function LensEmbeddable(props: Props) { [reportType, setSeries, firstSeries, notifications?.toasts] ); - if (timeRange === null || !firstSeries) { + if (!timeRange || !firstSeries) { return null; } From ff1b014c7bdcd2ab5aee896bca0cc077736aec58 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Tue, 12 Oct 2021 16:36:18 +0200 Subject: [PATCH 23/68] Update dependency @elastic/charts to v37 (master) (#113968) --- api_docs/charts.json | 4 +-- package.json | 2 +- .../static/utils/transform_click_event.ts | 4 +-- .../apps/main/components/chart/histogram.tsx | 7 +++-- .../vis_types/pie/public/utils/get_layers.ts | 1 - .../components/timelion_vis_component.tsx | 2 ++ .../timelion/public/helpers/panel_utils.ts | 6 ++-- .../xy/public/components/xy_settings.tsx | 1 - .../vis_types/xy/public/config/get_axis.ts | 16 ++--------- .../vis_types/xy/public/utils/domain.ts | 12 ++++---- .../utils/render_all_series.test.mocks.ts | 15 ++++++++-- .../vis_types/xy/public/vis_component.tsx | 6 +++- .../apps/dashboard/dashboard_state.ts | 14 ++++------ .../page_objects/visualize_chart_page.ts | 12 ++++---- .../services/visualizations/pie_chart.ts | 20 +++++++++++-- .../alerting/chart_preview/index.tsx | 2 +- .../RumDashboard/Charts/PageLoadDistChart.tsx | 5 ++-- .../distribution/index.tsx | 12 ++++---- .../transaction_details_tabs.tsx | 4 +-- .../app/transaction_details/types.ts | 4 +-- .../shared/charts/breakdown_chart/index.tsx | 5 +++- .../components/shared/charts/helper/helper.ts | 4 +-- .../shared/charts/timeseries_chart.tsx | 5 +++- .../document_count_chart.tsx | 9 ++++-- .../heatmap_visualization/chart_component.tsx | 3 +- .../pie_visualization/render_function.tsx | 1 - .../__snapshots__/expression.test.tsx.snap | 28 +++++++++---------- .../xy_visualization/expression.test.tsx | 16 +++++++---- .../public/xy_visualization/expression.tsx | 25 +++++++++-------- .../lens/public/xy_visualization/x_domain.tsx | 6 ++-- .../create_calendar.tsx | 8 +++--- .../explorer/swimlane_container.tsx | 23 ++++++++------- .../pages/components/charts/common/utils.ts | 2 +- .../event_rate_chart/event_rate_chart.tsx | 6 +--- .../components/app/section/apm/index.tsx | 12 ++++++-- .../public/components/app/section/helper.ts | 4 +-- .../components/app/section/logs/index.tsx | 12 ++++++-- .../components/app/section/uptime/index.tsx | 3 +- .../alert_types/threshold/visualization.tsx | 7 ++++- .../common/charts/duration_chart.tsx | 2 +- .../watch_visualization.tsx | 7 ++++- yarn.lock | 11 ++++---- 42 files changed, 205 insertions(+), 143 deletions(-) diff --git a/api_docs/charts.json b/api_docs/charts.json index 5d4f047a247e2..83a2a93df42a1 100644 --- a/api_docs/charts.json +++ b/api_docs/charts.json @@ -229,7 +229,7 @@ ", xAccessor: string | number | ", "AccessorFn", ") => ({ x: selectedRange }: ", - "XYBrushArea", + "XYBrushEvent", ") => ", { "pluginId": "charts", @@ -4266,4 +4266,4 @@ } ] } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 705d902d6afef..16422e3fda27e 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "@elastic/apm-generator": "link:bazel-bin/packages/elastic-apm-generator", "@elastic/apm-rum": "^5.9.1", "@elastic/apm-rum-react": "^1.3.1", - "@elastic/charts": "34.2.1", + "@elastic/charts": "37.0.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.21", "@elastic/ems-client": "7.15.0", diff --git a/src/plugins/charts/public/static/utils/transform_click_event.ts b/src/plugins/charts/public/static/utils/transform_click_event.ts index 7fdd59f47988d..d175046b20ebb 100644 --- a/src/plugins/charts/public/static/utils/transform_click_event.ts +++ b/src/plugins/charts/public/static/utils/transform_click_event.ts @@ -9,7 +9,7 @@ import { XYChartSeriesIdentifier, GeometryValue, - XYBrushArea, + XYBrushEvent, Accessor, AccessorFn, Datum, @@ -261,7 +261,7 @@ export const getFilterFromSeriesFn = */ export const getBrushFromChartBrushEventFn = (table: Datatable, xAccessor: Accessor | AccessorFn) => - ({ x: selectedRange }: XYBrushArea): BrushTriggerEvent => { + ({ x: selectedRange }: XYBrushEvent): BrushTriggerEvent => { const [start, end] = selectedRange ?? [0, 0]; const range: [number, number] = [start, end]; const column = table.columns.findIndex(({ id }) => validateAccessorId(id, xAccessor)); diff --git a/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx b/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx index 333050e1ca5e6..350c46591c8b4 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx @@ -14,6 +14,7 @@ import dateMath from '@elastic/datemath'; import { Axis, BrushEndListener, + XYBrushEvent, Chart, ElementClickListener, HistogramBarSeries, @@ -65,8 +66,8 @@ export function DiscoverHistogram({ const timeZone = getTimezone(uiSettings); const { chartData, fetchStatus } = dataState; - const onBrushEnd: BrushEndListener = useCallback( - ({ x }) => { + const onBrushEnd = useCallback( + ({ x }: XYBrushEvent) => { if (!x) { return; } @@ -184,7 +185,7 @@ export function DiscoverHistogram({ { const fillLabel: Partial = { - textInvertible: true, valueFont: { fontWeight: 700, }, diff --git a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx index d7b7bb14723d7..e6d2638bedf48 100644 --- a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx +++ b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx @@ -64,6 +64,8 @@ const DefaultYAxis = () => ( id="left" domain={withStaticPadding({ fit: false, + min: NaN, + max: NaN, })} position={Position.Left} groupId={`${MAIN_GROUP_ID}`} diff --git a/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts b/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts index 3c76b95bd05ca..98be5efc55a26 100644 --- a/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts +++ b/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts @@ -88,8 +88,8 @@ const adaptYaxisParams = (yaxis: IAxis) => { tickFormat: y.tickFormatter, domain: withStaticPadding({ fit: y.min === undefined && y.max === undefined, - min: y.min, - max: y.max, + min: y.min ?? NaN, + max: y.max ?? NaN, }), }; }; @@ -118,6 +118,8 @@ export const extractAllYAxis = (series: Series[]) => { groupId, domain: withStaticPadding({ fit: false, + min: NaN, + max: NaN, }), id: (yaxis?.position || Position.Left) + index, position: Position.Left, diff --git a/src/plugins/vis_types/xy/public/components/xy_settings.tsx b/src/plugins/vis_types/xy/public/components/xy_settings.tsx index 5e02b65822d6c..74aff7535c2d8 100644 --- a/src/plugins/vis_types/xy/public/components/xy_settings.tsx +++ b/src/plugins/vis_types/xy/public/components/xy_settings.tsx @@ -71,7 +71,6 @@ function getValueLabelsStyling() { return { displayValue: { fontSize: { min: VALUE_LABELS_MIN_FONTSIZE, max: VALUE_LABELS_MAX_FONTSIZE }, - fill: { textInverted: false, textContrast: true }, alignment: { horizontal: HorizontalAlignment.Center, vertical: VerticalAlignment.Middle }, }, }; diff --git a/src/plugins/vis_types/xy/public/config/get_axis.ts b/src/plugins/vis_types/xy/public/config/get_axis.ts index b5cc96830e46a..09495725296cd 100644 --- a/src/plugins/vis_types/xy/public/config/get_axis.ts +++ b/src/plugins/vis_types/xy/public/config/get_axis.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { identity, isNil } from 'lodash'; +import { identity } from 'lodash'; import { AxisSpec, TickFormatter, YDomainRange, ScaleType as ECScaleType } from '@elastic/charts'; @@ -171,17 +171,5 @@ function getAxisDomain( const fit = defaultYExtents; const padding = boundsMargin || undefined; - if (!isNil(min) && !isNil(max)) { - return { fit, padding, min, max }; - } - - if (!isNil(min)) { - return { fit, padding, min }; - } - - if (!isNil(max)) { - return { fit, padding, max }; - } - - return { fit, padding }; + return { fit, padding, min: min ?? NaN, max: max ?? NaN }; } diff --git a/src/plugins/vis_types/xy/public/utils/domain.ts b/src/plugins/vis_types/xy/public/utils/domain.ts index fa8dd74e3942a..5b1310863979a 100644 --- a/src/plugins/vis_types/xy/public/utils/domain.ts +++ b/src/plugins/vis_types/xy/public/utils/domain.ts @@ -33,6 +33,8 @@ export const getXDomain = (params: Aspect['params']): DomainRange => { return { minInterval, + min: NaN, + max: NaN, }; }; @@ -74,9 +76,9 @@ export const getAdjustedDomain = ( }; } - return 'interval' in params - ? { - minInterval: params.interval, - } - : {}; + return { + minInterval: 'interval' in params ? params.interval : undefined, + min: NaN, + max: NaN, + }; }; diff --git a/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts b/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts index 5fe1b03dd8b93..c14e313b1e7a4 100644 --- a/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts +++ b/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts @@ -112,7 +112,10 @@ export const getVisConfig = (): VisConfig => { mode: AxisMode.Normal, type: 'linear', }, - domain: {}, + domain: { + min: NaN, + max: NaN, + }, integersOnly: false, }, ], @@ -246,7 +249,10 @@ export const getVisConfigMutipleYaxis = (): VisConfig => { mode: AxisMode.Normal, type: 'linear', }, - domain: {}, + domain: { + min: NaN, + max: NaN, + }, integersOnly: false, }, ], @@ -435,7 +441,10 @@ export const getVisConfigPercentiles = (): VisConfig => { mode: AxisMode.Normal, type: 'linear', }, - domain: {}, + domain: { + min: NaN, + max: NaN, + }, integersOnly: false, }, ], diff --git a/src/plugins/vis_types/xy/public/vis_component.tsx b/src/plugins/vis_types/xy/public/vis_component.tsx index f4d566f49602e..515ad3e7eaf6f 100644 --- a/src/plugins/vis_types/xy/public/vis_component.tsx +++ b/src/plugins/vis_types/xy/public/vis_component.tsx @@ -19,6 +19,7 @@ import { ScaleType, AccessorFn, Accessor, + XYBrushEvent, } from '@elastic/charts'; import { compact } from 'lodash'; @@ -131,7 +132,10 @@ const VisComponent = (props: VisComponentProps) => { ): BrushEndListener | undefined => { if (xAccessor !== null && isInterval) { return (brushArea) => { - const event = getBrushFromChartBrushEventFn(visData, xAccessor)(brushArea); + const event = getBrushFromChartBrushEventFn( + visData, + xAccessor + )(brushArea as XYBrushEvent); props.fireEvent(event); }; } diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index 45ba62749dd77..0cc0fa4806482 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -7,6 +7,7 @@ */ import expect from '@kbn/expect'; +import chroma from 'chroma-js'; import { PIE_CHART_VIS_NAME, AREA_CHART_VIS_NAME } from '../../page_objects/dashboard_page'; import { DEFAULT_PANEL_WIDTH } from '../../../../src/plugins/dashboard/public/application/embeddable/dashboard_constants'; @@ -264,14 +265,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { - const allPieSlicesColor = await pieChart.getAllPieSliceStyles('80,000'); - let whitePieSliceCounts = 0; - allPieSlicesColor.forEach((style) => { - if (style.indexOf('rgb(255, 255, 255)') > -1) { - whitePieSliceCounts++; - } - }); - + const allPieSlicesColor = await pieChart.getAllPieSliceColor('80,000'); + const whitePieSliceCounts = allPieSlicesColor.reduce((count, color) => { + // converting the color to a common format, testing the color, not the string format + return chroma(color).hex().toUpperCase() === '#FFFFFF' ? count + 1 : count; + }, 0); expect(whitePieSliceCounts).to.be(1); }); }); diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index d2e4091f93577..b0e9e21d07b0b 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -7,7 +7,7 @@ */ import { Position } from '@elastic/charts'; -import Color from 'color'; +import chroma from 'chroma-js'; import { FtrService } from '../ftr_provider_context'; @@ -181,17 +181,17 @@ export class VisualizeChartPageObject extends FtrService { return items.some(({ color: c }) => c === color); } - public async doesSelectedLegendColorExistForPie(color: string) { + public async doesSelectedLegendColorExistForPie(matchingColor: string) { if (await this.isNewLibraryChart(pieChartSelector)) { + const hexMatchingColor = chroma(matchingColor).hex().toUpperCase(); const slices = (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; - return slices.some(({ color: c }) => { - const rgbColor = new Color(color).rgb().toString(); - return c === rgbColor; + return slices.some(({ color }) => { + return hexMatchingColor === chroma(color).hex().toUpperCase(); }); } - return await this.testSubjects.exists(`legendSelectedColor-${color}`); + return await this.testSubjects.exists(`legendSelectedColor-${matchingColor}`); } public async expectError() { diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index 7c925318f0211..ff0c24e2830cf 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -7,6 +7,7 @@ */ import expect from '@kbn/expect'; +import { isNil } from 'lodash'; import { FtrService } from '../../ftr_provider_context'; const pieChartSelector = 'visTypePieChart'; @@ -100,8 +101,8 @@ export class PieChartService extends FtrService { return await pieSlice.getAttribute('style'); } - async getAllPieSliceStyles(name: string) { - this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`); + async getAllPieSliceColor(name: string) { + this.log.debug(`VisualizePage.getAllPieSliceColor(${name})`); if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? @@ -112,9 +113,22 @@ export class PieChartService extends FtrService { return selectedSlice.map((slice) => slice.color); } const pieSlices = await this.getAllPieSlices(name); - return await Promise.all( + const slicesStyles = await Promise.all( pieSlices.map(async (pieSlice) => await pieSlice.getAttribute('style')) ); + return slicesStyles + .map( + (styles) => + styles.split(';').reduce>((styleAsObj, style) => { + const stylePair = style.split(':'); + if (stylePair.length !== 2) { + return styleAsObj; + } + styleAsObj[stylePair[0].trim()] = stylePair[1].trim(); + return styleAsObj; + }, {}).fill // in vislib the color is available on the `fill` style prop + ) + .filter((d) => !isNil(d)); } async getPieChartData() { diff --git a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx index 8a54c76df0f69..ee6a58b0dbb76 100644 --- a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx @@ -96,7 +96,7 @@ export function ChartPreview({ position={Position.Left} tickFormat={yTickFormat} ticks={5} - domain={{ max: yMax }} + domain={{ max: yMax, min: NaN }} /> { + const onBrushEnd = ({ x }: XYBrushEvent) => { if (!x) { return; } @@ -99,7 +100,7 @@ export function PageLoadDistChart({ diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx index 97f38a8123a4e..efd01c32b2462 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.tsx @@ -6,7 +6,7 @@ */ import React, { useEffect } from 'react'; -import { BrushEndListener, XYBrushArea } from '@elastic/charts'; +import { BrushEndListener, XYBrushEvent } from '@elastic/charts'; import { EuiBadge, EuiFlexGroup, @@ -61,7 +61,7 @@ export function getFormattedSelection(selection: Selection): string { } interface TransactionDistributionProps { - onChartSelection: BrushEndListener; + onChartSelection: (event: XYBrushEvent) => void; onClearSelection: () => void; selection?: Selection; traceSamples: TabContentProps['traceSamples']; @@ -126,10 +126,8 @@ export function TransactionDistribution({ const trackApmEvent = useUiTracker({ app: 'apm' }); - const onTrackedChartSelection: BrushEndListener = ( - brushArea: XYBrushArea - ) => { - onChartSelection(brushArea); + const onTrackedChartSelection = (brushEvent: XYBrushEvent) => { + onChartSelection(brushEvent); trackApmEvent({ metric: 'transaction_distribution_chart_selection' }); }; @@ -216,7 +214,7 @@ export function TransactionDistribution({ markerCurrentTransaction={markerCurrentTransaction} markerPercentile={DEFAULT_PERCENTILE_THRESHOLD} markerValue={response.percentileThresholdValue ?? 0} - onChartSelection={onTrackedChartSelection} + onChartSelection={onTrackedChartSelection as BrushEndListener} hasData={hasData} selection={selection} status={status} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx index b249161980586..9ccca9886e679 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/transaction_details_tabs.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { omit } from 'lodash'; import { useHistory } from 'react-router-dom'; -import { XYBrushArea } from '@elastic/charts'; +import { XYBrushEvent } from '@elastic/charts'; import { EuiPanel, EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -48,7 +48,7 @@ export function TransactionDetailsTabs() { environment, }); - const selectSampleFromChartSelection = (selection: XYBrushArea) => { + const selectSampleFromChartSelection = (selection: XYBrushEvent) => { if (selection !== undefined) { const { x } = selection; if (Array.isArray(x)) { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/types.ts b/x-pack/plugins/apm/public/components/app/transaction_details/types.ts index 1ccb3d01a9b28..c3d2b9648e82a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/types.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { XYBrushArea } from '@elastic/charts'; +import { XYBrushEvent } from '@elastic/charts'; import type { TraceSample } from '../../../hooks/use_transaction_trace_samples_fetcher'; @@ -14,6 +14,6 @@ export interface TabContentProps { onFilter: () => void; sampleRangeFrom?: number; sampleRangeTo?: number; - selectSampleFromChartSelection: (selection: XYBrushArea) => void; + selectSampleFromChartSelection: (selection: XYBrushEvent) => void; traceSamples: TraceSample[]; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx index 213bac40c2248..16157071affcd 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/breakdown_chart/index.tsx @@ -17,6 +17,7 @@ import { ScaleType, Settings, TickFormatter, + XYBrushEvent, } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -96,7 +97,9 @@ export function BreakdownChart({ onBrushEnd({ x, history })} + onBrushEnd={(event) => + onBrushEnd({ x: (event as XYBrushEvent).x, history }) + } showLegend showLegendExtra legendPosition={Position.Bottom} diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts index d94f2ce8f5c5d..9dccddd509387 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { XYBrushArea } from '@elastic/charts'; +import { XYBrushEvent } from '@elastic/charts'; import { History } from 'history'; import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { fromQuery, toQuery } from '../../Links/url_helpers'; @@ -14,7 +14,7 @@ export const onBrushEnd = ({ x, history, }: { - x: XYBrushArea['x']; + x: XYBrushEvent['x']; history: History; }) => { if (x) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index 65ecdec0f36a5..08e8908d50e7a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -20,6 +20,7 @@ import { ScaleType, Settings, YDomainRange, + XYBrushEvent, } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -115,7 +116,9 @@ export function TimeseriesChart({ onBrushEnd({ x, history })} + onBrushEnd={(event) => + onBrushEnd({ x: (event as XYBrushEvent).x, history }) + } theme={{ ...chartTheme, areaSeriesStyle: { diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx index b8df4defa18a2..6459fc4006cea 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/document_count_chart/document_count_chart.tsx @@ -20,6 +20,7 @@ import { ScaleType, Settings, XYChartElementEvent, + XYBrushEvent, } from '@elastic/charts'; import moment from 'moment'; import { useDataVisualizerKibana } from '../../../../kibana_context'; @@ -91,7 +92,7 @@ export const DocumentCountChart: FC = ({ [data] ); - const onBrushEnd: BrushEndListener = ({ x }) => { + const onBrushEnd = ({ x }: XYBrushEvent) => { if (!x) { return; } @@ -117,7 +118,11 @@ export const DocumentCountChart: FC = ({ height: 120, }} > - + = ({ }; const config: HeatmapSpec['config'] = { - onBrushEnd, grid: { stroke: { width: @@ -338,6 +338,7 @@ export const HeatmapComponent: FC = ({ labelOptions: { maxLines: args.legend.shouldTruncate ? args.legend?.maxLines ?? 1 : 0 }, }, }} + onBrushEnd={onBrushEnd as BrushEndListener} /> = { - textInvertible: true, valueFont: { fontWeight: 700, }, diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index fe3137c905ffb..0fad522624975 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -73,8 +73,8 @@ exports[`xy_expression XYChart component it renders area 1`] = ` domain={ Object { "fit": false, - "max": undefined, - "min": undefined, + "max": NaN, + "min": NaN, } } gridLine={ @@ -302,8 +302,8 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` domain={ Object { "fit": false, - "max": undefined, - "min": undefined, + "max": NaN, + "min": NaN, } } gridLine={ @@ -545,8 +545,8 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` domain={ Object { "fit": false, - "max": undefined, - "min": undefined, + "max": NaN, + "min": NaN, } } gridLine={ @@ -788,8 +788,8 @@ exports[`xy_expression XYChart component it renders line 1`] = ` domain={ Object { "fit": false, - "max": undefined, - "min": undefined, + "max": NaN, + "min": NaN, } } gridLine={ @@ -1017,8 +1017,8 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` domain={ Object { "fit": false, - "max": undefined, - "min": undefined, + "max": NaN, + "min": NaN, } } gridLine={ @@ -1254,8 +1254,8 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` domain={ Object { "fit": false, - "max": undefined, - "min": undefined, + "max": NaN, + "min": NaN, } } gridLine={ @@ -1505,8 +1505,8 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = domain={ Object { "fit": false, - "max": undefined, - "min": undefined, + "max": NaN, + "min": NaN, } } gridLine={ diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 4056aa730c2ab..af2995fb65b71 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -809,8 +809,8 @@ describe('xy_expression', () => { ); expect(component.find(Axis).find('[id="left"]').prop('domain')).toEqual({ fit: true, - min: undefined, - max: undefined, + min: NaN, + max: NaN, }); }); @@ -838,6 +838,8 @@ describe('xy_expression', () => { ); expect(component.find(Axis).find('[id="left"]').prop('domain')).toEqual({ fit: false, + min: NaN, + max: NaN, }); }); @@ -867,8 +869,8 @@ describe('xy_expression', () => { ); expect(component.find(Axis).find('[id="left"]').prop('domain')).toEqual({ fit: false, - min: undefined, - max: undefined, + min: NaN, + max: NaN, }); }); @@ -959,7 +961,11 @@ describe('xy_expression', () => { }} /> ); - expect(component.find(Settings).prop('xDomain')).toEqual({ minInterval: 101 }); + expect(component.find(Settings).prop('xDomain')).toEqual({ + minInterval: 101, + min: NaN, + max: NaN, + }); }); test('disabled legend extra by default', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 5dfad58f50018..87462e71f3cf6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -25,9 +25,12 @@ import { LayoutDirection, ElementClickListener, BrushEndListener, + XYBrushEvent, CurveType, LegendPositionConfig, LabelOverflowConstraint, + DisplayValueStyle, + RecursivePartial, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import type { @@ -169,7 +172,9 @@ export const getXyChartRenderer = (dependencies: { }, }); -function getValueLabelsStyling(isHorizontal: boolean) { +function getValueLabelsStyling(isHorizontal: boolean): { + displayValue: RecursivePartial; +} { const VALUE_LABELS_MAX_FONTSIZE = 12; const VALUE_LABELS_MIN_FONTSIZE = 10; const VALUE_LABELS_VERTICAL_OFFSET = -10; @@ -178,11 +183,9 @@ function getValueLabelsStyling(isHorizontal: boolean) { return { displayValue: { fontSize: { min: VALUE_LABELS_MIN_FONTSIZE, max: VALUE_LABELS_MAX_FONTSIZE }, - fill: { textContrast: true, textInverted: false, textBorder: 0 }, + fill: { textBorder: 0 }, alignment: isHorizontal - ? { - vertical: VerticalAlignment.Middle, - } + ? { vertical: VerticalAlignment.Middle } : { horizontal: HorizontalAlignment.Center }, offsetX: isHorizontal ? VALUE_LABELS_HORIZONTAL_OFFSET : 0, offsetY: isHorizontal ? 0 : VALUE_LABELS_VERTICAL_OFFSET, @@ -388,14 +391,14 @@ export function XYChart({ }) ); const fit = !hasBarOrArea && extent.mode === 'dataBounds'; - let min: undefined | number; - let max: undefined | number; + let min: number = NaN; + let max: number = NaN; if (extent.mode === 'custom') { const { inclusiveZeroError, boundaryError } = validateExtent(hasBarOrArea, extent); if (!inclusiveZeroError && !boundaryError) { - min = extent.lowerBound; - max = extent.upperBound; + min = extent.lowerBound ?? NaN; + max = extent.upperBound ?? NaN; } } else { const axisHasThreshold = thresholdLayers.some(({ yConfig }) => @@ -517,7 +520,7 @@ export function XYChart({ onClickValue(context); }; - const brushHandler: BrushEndListener = ({ x }) => { + const brushHandler = ({ x }: XYBrushEvent) => { if (!x) { return; } @@ -592,7 +595,7 @@ export function XYChart({ allowBrushingLastHistogramBucket={Boolean(isTimeViz)} rotation={shouldRotate ? 90 : 0} xDomain={xDomain} - onBrushEnd={interactive ? brushHandler : undefined} + onBrushEnd={interactive ? (brushHandler as BrushEndListener) : undefined} onElementClick={interactive ? clickHandler : undefined} legendAction={getLegendAction( filteredLayers, diff --git a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx index ccb047d54e369..d5eb8ac1e92ba 100644 --- a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx @@ -26,12 +26,12 @@ export const getXDomain = ( ) => { const baseDomain = isTimeViz ? { - min: data.dateRange?.fromDate.getTime(), - max: data.dateRange?.toDate.getTime(), + min: data.dateRange?.fromDate.getTime() ?? NaN, + max: data.dateRange?.toDate.getTime() ?? NaN, minInterval, } : isHistogram - ? { minInterval } + ? { minInterval, min: NaN, max: NaN } : undefined; if (isHistogram && isFullyQualified(baseDomain)) { diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/create_calendar.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/create_calendar.tsx index b4015d0c0eb92..1fae57c7922ce 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/create_calendar.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/create_calendar.tsx @@ -9,7 +9,7 @@ import React, { FC, Fragment, useCallback, memo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import moment from 'moment'; -import { XYBrushArea } from '@elastic/charts'; +import { XYBrushEvent, BrushEndListener } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem, @@ -57,7 +57,7 @@ export const CreateCalendar: FC = ({ const { euiTheme } = useCurrentEuiTheme(); const onBrushEnd = useCallback( - ({ x }: XYBrushArea) => { + ({ x }: XYBrushEvent) => { if (x && x.length === 2) { const end = x[1] < minSelectableTimeStamp ? null : x[1]; if (end !== null) { @@ -252,7 +252,7 @@ interface ChartProps { eventRateData: LineChartPoint[]; anomalies: Anomaly[]; loading: boolean; - onBrushEnd(area: XYBrushArea): void; + onBrushEnd(area: XYBrushEvent): void; overlayRanges: Array<{ start: number; end: number }>; overlayColor: string; } @@ -272,7 +272,7 @@ const Chart: FC = memo( color: overlayColor, showMarker: false, }))} - onBrushEnd={onBrushEnd} + onBrushEnd={onBrushEnd as BrushEndListener} /> ), (prev: ChartProps, next: ChartProps) => { diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 49bd00d888cf8..ef8e80381293e 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -18,6 +18,7 @@ import { import { throttle } from 'lodash'; import { Chart, + BrushEndListener, Settings, Heatmap, HeatmapElementEvent, @@ -286,16 +287,6 @@ export const SwimlaneContainer: FC = ({ if (!showSwimlane) return {}; const config: HeatmapSpec['config'] = { - onBrushEnd: (e: HeatmapBrushEvent) => { - if (!e.cells.length) return; - - onCellsSelection({ - lanes: e.y as string[], - times: e.x.map((v) => (v as number) / 1000) as [number, number], - type: swimlaneType, - viewByFieldName: swimlaneData.fieldName, - }); - }, grid: { cellHeight: { min: CELL_HEIGHT, @@ -396,6 +387,17 @@ export const SwimlaneContainer: FC = ({ [swimlaneData] ); + const onBrushEnd = (e: HeatmapBrushEvent) => { + if (!e.cells.length) return; + + onCellsSelection({ + lanes: e.y as string[], + times: e.x!.map((v) => (v as number) / 1000) as [number, number], + type: swimlaneType, + viewByFieldName: swimlaneData.fieldName, + }); + }; + // A resize observer is required to compute the bucket span based on the chart width to fetch the data accordingly return ( @@ -427,6 +429,7 @@ export const SwimlaneContainer: FC = ({ xDomain={xDomain} tooltip={tooltipOptions} debugState={window._echDebugStateFlag ?? false} + onBrushEnd={onBrushEnd as BrushEndListener} /> = ({ {showAxis === true && } - {onBrushEnd === undefined ? ( - - ) : ( - - )} + {overlayRanges && overlayRanges.map((range, i) => ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index 7a42e96c3823d..8df14129623f6 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import { Axis, BarSeries, niceTimeFormatter, Position, ScaleType, Settings } from '@elastic/charts'; +import { + Axis, + BarSeries, + niceTimeFormatter, + Position, + ScaleType, + Settings, + XYBrushEvent, +} from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -117,7 +125,7 @@ export function APMSection({ bucketSize }: Props) { onBrushEnd({ x, history })} + onBrushEnd={(event) => onBrushEnd({ x: (event as XYBrushEvent).x, history })} theme={chartTheme} showLegend={false} xDomain={{ min, max }} diff --git a/x-pack/plugins/observability/public/components/app/section/helper.ts b/x-pack/plugins/observability/public/components/app/section/helper.ts index f1b2992386063..077bd67a8590c 100644 --- a/x-pack/plugins/observability/public/components/app/section/helper.ts +++ b/x-pack/plugins/observability/public/components/app/section/helper.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { XYBrushArea } from '@elastic/charts'; +import { XYBrushEvent } from '@elastic/charts'; import { History } from 'history'; import { fromQuery, toQuery } from '../../../utils/url'; -export const onBrushEnd = ({ x, history }: { x: XYBrushArea['x']; history: History }) => { +export const onBrushEnd = ({ x, history }: { x: XYBrushEvent['x']; history: History }) => { if (x) { const start = x[0]; const end = x[1]; diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index da5a8f25045a5..dcd51a531a73d 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import { Axis, BarSeries, niceTimeFormatter, Position, ScaleType, Settings } from '@elastic/charts'; +import { + Axis, + BarSeries, + niceTimeFormatter, + Position, + ScaleType, + Settings, + XYBrushEvent, +} from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem, euiPaletteColorBlind, EuiSpacer, EuiTitle } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -124,7 +132,7 @@ export function LogsSection({ bucketSize }: Props) { onBrushEnd({ x, history })} + onBrushEnd={(event) => onBrushEnd({ x: (event as XYBrushEvent).x, history })} theme={chartTheme} showLegend legendPosition={Position.Right} diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 28cbd12663c1b..8c0f1f8db7c1a 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -13,6 +13,7 @@ import { ScaleType, Settings, TickFormatter, + XYBrushEvent, } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import numeral from '@elastic/numeral'; @@ -123,7 +124,7 @@ export function UptimeSection({ bucketSize }: Props) { {/* Chart section */} onBrushEnd({ x, history })} + onBrushEnd={(event) => onBrushEnd({ x: (event as XYBrushEvent).x, history })} theme={chartTheme} showLegend={false} legendPosition={Position.Right} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx index 1db09d0492e68..68141945d73fd 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx @@ -277,7 +277,12 @@ export const ThresholdVisualization: React.FunctionComponent = ({ showOverlappingTicks={true} tickFormat={dateFormatter} /> - + {alertVisualizationDataKeys.map((key: string) => { return ( getTickFormat(d)} diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx index 468e9dfa68e1b..c995e4a449867 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx @@ -227,7 +227,12 @@ export const WatchVisualization = () => { showOverlappingTicks={true} tickFormat={dateFormatter} /> - + {watchVisualizationDataKeys.map((key: string) => { return ( Date: Tue, 12 Oct 2021 16:36:38 +0200 Subject: [PATCH 24/68] [Fleet] Fix agent count in update modal (#114622) --- .../screens/detail/settings/update_button.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx index 8cdb3ece30621..48569d782a70b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/update_button.tsx @@ -18,12 +18,12 @@ import { EuiConfirmModal, EuiSpacer, } from '@elastic/eui'; -import { sumBy } from 'lodash'; import type { GetAgentPoliciesResponse, PackageInfo, UpgradePackagePolicyDryRunResponse, + PackagePolicy, } from '../../../../../types'; import { InstallStatus } from '../../../../../types'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; @@ -109,10 +109,24 @@ export const UpdateButton: React.FunctionComponent = ({ }, [packagePolicyIds]); const packagePolicyCount = useMemo(() => packagePolicyIds.length, [packagePolicyIds]); + + function isStringArray(arr: unknown | string[]): arr is string[] { + return Array.isArray(arr) && arr.every((p) => typeof p === 'string'); + } + const agentCount = useMemo( - () => sumBy(agentPolicyData?.items, ({ agents }) => agents ?? 0), - [agentPolicyData] + () => + agentPolicyData?.items.reduce((acc, item) => { + const existingPolicies = isStringArray(item?.package_policies) + ? (item?.package_policies as string[]).filter((p) => packagePolicyIds.includes(p)) + : (item?.package_policies as PackagePolicy[]).filter((p) => + packagePolicyIds.includes(p.id) + ); + return (acc += existingPolicies.length > 0 && item?.agents ? item?.agents : 0); + }, 0), + [agentPolicyData, packagePolicyIds] ); + const conflictCount = useMemo( () => dryRunData?.filter((item) => item.hasErrors).length, [dryRunData] From 0b46bb1b93e01137e0c9cd957fa197ae024d7ea0 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Tue, 12 Oct 2021 09:40:54 -0500 Subject: [PATCH 25/68] [Security Solution][Detection Alerts] Fixes follow-up alert refresh bugs (#112169) --- .../detection_alerts/acknowledged.spec.ts | 11 +- .../detection_alerts/closing.spec.ts | 69 +- .../detection_alerts/opening.spec.ts | 15 +- .../cypress/screens/alerts.ts | 6 + .../timeline_actions/alert_context_menu.tsx | 7 +- .../components/take_action_dropdown/index.tsx | 43 +- .../__snapshots__/index.test.tsx.snap | 788 +++++++++--------- .../side_panel/event_details/footer.tsx | 46 +- .../public/container/use_update_alerts.ts | 8 +- .../hooks/use_status_bulk_action_items.tsx | 2 +- 10 files changed, 549 insertions(+), 446 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts index 5d72105178b69..2dad11ac7e937 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts @@ -6,7 +6,11 @@ */ import { getNewRule } from '../../objects/rule'; -import { ALERTS_COUNT, TAKE_ACTION_POPOVER_BTN } from '../../screens/alerts'; +import { + ALERTS_COUNT, + TAKE_ACTION_POPOVER_BTN, + ALERT_COUNT_TABLE_FIRST_ROW_COUNT, +} from '../../screens/alerts'; import { selectNumberOfAlerts, @@ -50,11 +54,16 @@ describe('Marking alerts as acknowledged', () => { markAcknowledgedFirstAlert(); const expectedNumberOfAlerts = +numberOfAlerts - numberOfAlertsToBeMarkedAcknowledged; cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should('have.text', `${expectedNumberOfAlerts}`); goToAcknowledgedAlerts(); waitForAlerts(); cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlertsToBeMarkedAcknowledged} alert`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${numberOfAlertsToBeMarkedAcknowledged}` + ); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts index 602619b056244..860a4e6089a27 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/closing.spec.ts @@ -6,7 +6,13 @@ */ import { getNewRule } from '../../objects/rule'; -import { ALERTS_COUNT, SELECTED_ALERTS, TAKE_ACTION_POPOVER_BTN } from '../../screens/alerts'; +import { + ALERTS_COUNT, + SELECTED_ALERTS, + TAKE_ACTION_POPOVER_BTN, + ALERT_COUNT_TABLE_FIRST_ROW_COUNT, + ALERTS_TREND_SIGNAL_RULE_NAME_PANEL, +} from '../../screens/alerts'; import { closeFirstAlert, @@ -46,6 +52,7 @@ describe('Closing alerts', () => { .then((alertNumberString) => { const numberOfAlerts = alertNumberString.split(' ')[0]; cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should('have.text', `${numberOfAlerts}`); selectNumberOfAlerts(numberOfAlertsToBeClosed); @@ -56,6 +63,10 @@ describe('Closing alerts', () => { const expectedNumberOfAlertsAfterClosing = +numberOfAlerts - numberOfAlertsToBeClosed; cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfAlertsAfterClosing} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${expectedNumberOfAlertsAfterClosing}` + ); goToClosedAlerts(); waitForAlerts(); @@ -75,6 +86,10 @@ describe('Closing alerts', () => { 'have.text', `${expectedNumberOfClosedAlertsAfterOpened} alerts` ); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${expectedNumberOfClosedAlertsAfterOpened}` + ); goToOpenedAlerts(); waitForAlerts(); @@ -83,6 +98,10 @@ describe('Closing alerts', () => { +numberOfAlerts - expectedNumberOfClosedAlertsAfterOpened; cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfOpenedAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${expectedNumberOfOpenedAlerts}` + ); }); }); @@ -103,11 +122,59 @@ describe('Closing alerts', () => { const expectedNumberOfAlerts = +numberOfAlerts - numberOfAlertsToBeClosed; cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should('have.text', `${expectedNumberOfAlerts}`); + + goToClosedAlerts(); + waitForAlerts(); + + cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlertsToBeClosed} alert`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${numberOfAlertsToBeClosed}` + ); + }); + }); + + it('Updates trend histogram whenever alert status is updated in table', () => { + const numberOfAlertsToBeClosed = 1; + cy.get(ALERTS_COUNT) + .invoke('text') + .then((alertNumberString) => { + const numberOfAlerts = alertNumberString.split(' ')[0]; + cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should('have.text', `${numberOfAlerts}`); + + selectNumberOfAlerts(numberOfAlertsToBeClosed); + + cy.get(SELECTED_ALERTS).should('have.text', `Selected ${numberOfAlertsToBeClosed} alert`); + + closeAlerts(); + waitForAlerts(); + + const expectedNumberOfAlertsAfterClosing = +numberOfAlerts - numberOfAlertsToBeClosed; + cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfAlertsAfterClosing} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${expectedNumberOfAlertsAfterClosing}` + ); goToClosedAlerts(); waitForAlerts(); cy.get(ALERTS_COUNT).should('have.text', `${numberOfAlertsToBeClosed} alert`); + + const numberOfAlertsToBeOpened = 1; + selectNumberOfAlerts(numberOfAlertsToBeOpened); + + cy.get(SELECTED_ALERTS).should('have.text', `Selected ${numberOfAlertsToBeOpened} alert`); + cy.get(ALERTS_TREND_SIGNAL_RULE_NAME_PANEL).should('exist'); + + openAlerts(); + waitForAlerts(); + + cy.get(ALERTS_COUNT).should('not.exist'); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should('not.exist'); + cy.get(ALERTS_TREND_SIGNAL_RULE_NAME_PANEL).should('not.exist'); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts index 645abfed8ac0e..87cef27b5b346 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/opening.spec.ts @@ -6,7 +6,12 @@ */ import { getNewRule } from '../../objects/rule'; -import { ALERTS_COUNT, SELECTED_ALERTS, TAKE_ACTION_POPOVER_BTN } from '../../screens/alerts'; +import { + ALERTS_COUNT, + SELECTED_ALERTS, + TAKE_ACTION_POPOVER_BTN, + ALERT_COUNT_TABLE_FIRST_ROW_COUNT, +} from '../../screens/alerts'; import { closeAlerts, @@ -74,6 +79,10 @@ describe('Opening alerts', () => { const expectedNumberOfAlerts = +numberOfAlerts - numberOfAlertsToBeOpened; cy.get(ALERTS_COUNT).should('have.text', `${expectedNumberOfAlerts} alerts`); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${expectedNumberOfAlerts}` + ); goToOpenedAlerts(); waitForAlerts(); @@ -82,6 +91,10 @@ describe('Opening alerts', () => { 'have.text', `${numberOfOpenedAlerts + numberOfAlertsToBeOpened} alerts`.toString() ); + cy.get(ALERT_COUNT_TABLE_FIRST_ROW_COUNT).should( + 'have.text', + `${numberOfOpenedAlerts + numberOfAlertsToBeOpened}` + ); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 675a25641a2bd..d18a8e1ba10ab 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -70,3 +70,9 @@ export const TAKE_ACTION_POPOVER_BTN = '[data-test-subj="selectedShowBulkActions export const TIMELINE_CONTEXT_MENU_BTN = '[data-test-subj="timeline-context-menu-button"]'; export const ATTACH_ALERT_TO_CASE_BUTTON = '[data-test-subj="attach-alert-to-case-button"]'; + +export const ALERT_COUNT_TABLE_FIRST_ROW_COUNT = + '[data-test-subj="alertsCountTable"] tr:nth-child(1) td:nth-child(2) .euiTableCellContent__text'; + +export const ALERTS_TREND_SIGNAL_RULE_NAME_PANEL = + '[data-test-subj="render-content-signal.rule.name"]'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index fc8dd4b024fd9..06d61b3f0b284 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -12,6 +12,7 @@ import { indexOf } from 'lodash'; import { connect, ConnectedProps } from 'react-redux'; import { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; import { get } from 'lodash/fp'; +import { useRouteSpy } from '../../../../common/utils/route/use_route_spy'; import { buildGetAlertByIdQuery } from '../../../../common/components/exceptions/helpers'; import { EventsTdContent } from '../../../../timelines/components/timeline/styles'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../../timelines/components/timeline/helpers'; @@ -63,6 +64,7 @@ const AlertContextMenuComponent: React.FC { const [isPopoverOpen, setPopover] = useState(false); + const [routeProps] = useRouteSpy(); const afterItemSelection = useCallback(() => { setPopover(false); @@ -112,10 +114,13 @@ const AlertContextMenuComponent: React.FC { if (timelineId === TimelineId.active) { refetchQuery([timelineQuery]); + if (routeProps.pageName === 'alerts') { + refetchQuery(globalQuery); + } } else { refetchQuery(globalQuery); } - }, [timelineId, globalQuery, timelineQuery]); + }, [timelineId, globalQuery, timelineQuery, routeProps]); const { exceptionModalType, diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index 200b21bbecc4b..f7d65d1a3f3f4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -9,8 +9,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; import { isEmpty } from 'lodash/fp'; -import { connect, ConnectedProps } from 'react-redux'; -import { TimelineEventsDetailsItem, TimelineId } from '../../../../common'; +import { TimelineEventsDetailsItem } from '../../../../common'; import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations'; import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions'; import { useAlertsActions } from '../alerts_table/timeline_actions/use_alerts_actions'; @@ -24,8 +23,6 @@ import { Status } from '../../../../common/detection_engine/schemas/common/schem import { isAlertFromEndpointAlert } from '../../../common/utils/endpoint_alert_check'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions'; -import { inputsModel, inputsSelectors, State } from '../../../common/store'; - interface ActionsData { alertStatus: Status; eventId: string; @@ -48,7 +45,7 @@ export interface TakeActionDropdownProps { timelineId: string; } -export const TakeActionDropdownComponent = React.memo( +export const TakeActionDropdown = React.memo( ({ detailsData, ecsData, @@ -61,9 +58,7 @@ export const TakeActionDropdownComponent = React.memo( onAddIsolationStatusClick, refetch, timelineId, - globalQuery, - timelineQuery, - }: TakeActionDropdownProps & PropsFromRedux) => { + }: TakeActionDropdownProps) => { const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -146,24 +141,12 @@ export const TakeActionDropdownComponent = React.memo( closePopoverHandler(); }, [closePopoverHandler]); - const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { - newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); - }; - - const refetchAll = useCallback(() => { - if (timelineId === TimelineId.active) { - refetchQuery([timelineQuery]); - } else { - refetchQuery(globalQuery); - } - }, [timelineId, globalQuery, timelineQuery]); - const { actionItems: statusActionItems } = useAlertsActions({ alertStatus: actionsData.alertStatus, closePopover: closePopoverAndFlyout, eventId: actionsData.eventId, indexName, - refetch: refetchAll, + refetch, timelineId, }); @@ -233,21 +216,3 @@ export const TakeActionDropdownComponent = React.memo( ) : null; } ); - -const makeMapStateToProps = () => { - const getGlobalQueries = inputsSelectors.globalQuery(); - const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); - const mapStateToProps = (state: State, { timelineId }: TakeActionDropdownProps) => { - return { - globalQuery: getGlobalQueries(state), - timelineQuery: getTimelineQuery(state, timelineId), - }; - }; - return mapStateToProps; -}; - -const connector = connect(makeMapStateToProps); - -type PropsFromRedux = ConnectedProps; - -export const TakeActionDropdown = connector(React.memo(TakeActionDropdownComponent)); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 137d8d78bcdaa..2bb9da12e44ea 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -1056,7 +1056,7 @@ Array [
- - -
- + +
-
- -
- - -
-
-
- -
-
- +
+
+
+ + + + + , @@ -2093,7 +2095,7 @@ Array [ - - -
- -
- -
- + +
+ +
+ +
- -
-
-
-
-
-
- +
+
+
+
+
+
+
+ , ] diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx index 32c3f5a885346..4ddcd710e0406 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -5,11 +5,12 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { find, get, isEmpty } from 'lodash/fp'; +import { connect, ConnectedProps } from 'react-redux'; import { TakeActionDropdown } from '../../../../detections/components/take_action_dropdown'; -import type { TimelineEventsDetailsItem } from '../../../../../common'; +import { TimelineEventsDetailsItem, TimelineId } from '../../../../../common'; import { useExceptionModal } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_modal'; import { AddExceptionModalWrapper } from '../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; import { EventFiltersModal } from '../../../../management/pages/event_filters/view/components/modal'; @@ -18,6 +19,7 @@ import { getFieldValue } from '../../../../detections/components/host_isolation/ import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { Ecs } from '../../../../../common/ecs'; import { useFetchEcsAlertsData } from '../../../../detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data'; +import { inputsModel, inputsSelectors, State } from '../../../../common/store'; interface EventDetailsFooterProps { detailsData: TimelineEventsDetailsItem[] | null; @@ -41,7 +43,7 @@ interface AddExceptionModalWrapperData { ruleName: string; } -export const EventDetailsFooter = React.memo( +export const EventDetailsFooterComponent = React.memo( ({ detailsData, expandedEvent, @@ -50,7 +52,9 @@ export const EventDetailsFooter = React.memo( loadingEventDetails, onAddIsolationStatusClick, timelineId, - }: EventDetailsFooterProps) => { + globalQuery, + timelineQuery, + }: EventDetailsFooterProps & PropsFromRedux) => { const ruleIndex = useMemo( () => find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values, [detailsData] @@ -78,6 +82,18 @@ export const EventDetailsFooter = React.memo( [expandedEvent?.eventId] ); + const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { + newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); + }; + + const refetchAll = useCallback(() => { + if (timelineId === TimelineId.active) { + refetchQuery([timelineQuery]); + } else { + refetchQuery(globalQuery); + } + }, [timelineId, globalQuery, timelineQuery]); + const { exceptionModalType, onAddExceptionTypeClick, @@ -86,7 +102,7 @@ export const EventDetailsFooter = React.memo( ruleIndices, } = useExceptionModal({ ruleIndex, - refetch: expandedEvent?.refetch, + refetch: refetchAll, timelineId, }); const { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick } = @@ -113,7 +129,7 @@ export const EventDetailsFooter = React.memo( onAddEventFilterClick={onAddEventFilterClick} onAddExceptionTypeClick={onAddExceptionTypeClick} onAddIsolationStatusClick={onAddIsolationStatusClick} - refetch={expandedEvent?.refetch} + refetch={refetchAll} indexName={expandedEvent.indexName} timelineId={timelineId} /> @@ -142,3 +158,21 @@ export const EventDetailsFooter = React.memo( ); } ); + +const makeMapStateToProps = () => { + const getGlobalQueries = inputsSelectors.globalQuery(); + const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); + const mapStateToProps = (state: State, { timelineId }: EventDetailsFooterProps) => { + return { + globalQuery: getGlobalQueries(state), + timelineQuery: getTimelineQuery(state, timelineId), + }; + }; + return mapStateToProps; +}; + +const connector = connect(makeMapStateToProps); + +type PropsFromRedux = ConnectedProps; + +export const EventDetailsFooter = connector(React.memo(EventDetailsFooterComponent)); diff --git a/x-pack/plugins/timelines/public/container/use_update_alerts.ts b/x-pack/plugins/timelines/public/container/use_update_alerts.ts index 7f42ddc6e8211..b38c3b9a71fef 100644 --- a/x-pack/plugins/timelines/public/container/use_update_alerts.ts +++ b/x-pack/plugins/timelines/public/container/use_update_alerts.ts @@ -17,6 +17,8 @@ import { /** * Update alert status by query + * + * @param useDetectionEngine logic flag for using the regular Detection Engine URL or the RAC URL * * @param status to update to('open' / 'closed' / 'acknowledged') * @param index index to be updated @@ -26,7 +28,7 @@ import { * @throws An error if response is not OK */ export const useUpdateAlertsStatus = ( - timelineId: string + useDetectionEngine: boolean = false ): { updateAlertStatus: (params: { status: AlertStatus; @@ -37,7 +39,7 @@ export const useUpdateAlertsStatus = ( const { http } = useKibana().services; return { updateAlertStatus: async ({ status, index, query }) => { - if (['detections-page', 'detections-rules-details-page'].includes(timelineId)) { + if (useDetectionEngine) { return http!.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, { method: 'POST', body: JSON.stringify({ status, query }), @@ -51,5 +53,3 @@ export const useUpdateAlertsStatus = ( }, }; }; - -// diff --git a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx index c9269436646ea..c6e0e13c4dcb4 100644 --- a/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx +++ b/x-pack/plugins/timelines/public/hooks/use_status_bulk_action_items.tsx @@ -28,7 +28,7 @@ export const useStatusBulkActionItems = ({ onUpdateFailure, timelineId, }: StatusBulkActionsProps) => { - const { updateAlertStatus } = useUpdateAlertsStatus(timelineId ?? ''); + const { updateAlertStatus } = useUpdateAlertsStatus(timelineId != null); const { addSuccess, addError, addWarning } = useAppToasts(); const onAlertStatusUpdateSuccess = useCallback( From df971b7dc90b844c83d561faaa57730d8e9810d5 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 12 Oct 2021 16:46:23 +0200 Subject: [PATCH 26/68] [Exploratory view] Render content only on expand (#114237) --- .../shared/exploratory_view/series_editor/series.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx index 11f96afe7ceab..d320b84c6a684 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { EuiFlexItem, EuiFlexGroup, EuiPanel, EuiAccordion, EuiSpacer } from '@elastic/eui'; @@ -47,6 +47,14 @@ export function Series({ item, isExpanded, toggleExpanded }: Props) { seriesId: id, }; + const [isExapndedOnce, setIsExapndedOnce] = useState(false); + + useEffect(() => { + if (isExpanded) { + setIsExapndedOnce(true); + } + }, [isExpanded]); + return ( - + {isExapndedOnce && } From ba8abc41516d8778f11cfdd8e12580adc9d7a79f Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 12 Oct 2021 16:58:37 +0200 Subject: [PATCH 27/68] [Vega] Improve error message in case of invalid $schema URL (#114459) * :bug: Catch the schema parser and provide a better error message * :globe_with_meridians: Add i18n --- .../public/data_model/vega_parser.test.js | 14 +++++++ .../vega/public/data_model/vega_parser.ts | 40 ++++++++++++------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js index cfeed174307ac..13c17b8f4c38f 100644 --- a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js +++ b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js @@ -81,6 +81,20 @@ describe(`VegaParser.parseAsync`, () => { }) ) ); + + test(`should return a specific error in case of $schema URL not valid`, async () => { + const vp = new VegaParser({ + $schema: 'https://vega.github.io/schema/vega-lite/v4.jsonanythingtobreakthis', + mark: 'circle', + encoding: { row: { field: 'a' } }, + }); + + await vp.parseAsync(); + + expect(vp.error).toBe( + 'The URL for the JSON "$schema" is incorrect. Correct the URL, then click Update.' + ); + }); }); describe(`VegaParser._setDefaultValue`, () => { diff --git a/src/plugins/vis_types/vega/public/data_model/vega_parser.ts b/src/plugins/vis_types/vega/public/data_model/vega_parser.ts index 9000fed7f6116..bf2a6be25c71a 100644 --- a/src/plugins/vis_types/vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_types/vega/public/data_model/vega_parser.ts @@ -553,25 +553,37 @@ The URL is an identifier only. Kibana and your browser will never access this UR * @private */ private parseSchema(spec: VegaSpec) { - const schema = schemaParser(spec.$schema); - const isVegaLite = schema.library === 'vega-lite'; - const libVersion = isVegaLite ? vegaLiteVersion : vegaVersion; + try { + const schema = schemaParser(spec.$schema); + const isVegaLite = schema.library === 'vega-lite'; + const libVersion = isVegaLite ? vegaLiteVersion : vegaVersion; - if (versionCompare(schema.version, libVersion) > 0) { - this._onWarning( - i18n.translate('visTypeVega.vegaParser.notValidLibraryVersionForInputSpecWarningMessage', { + if (versionCompare(schema.version, libVersion) > 0) { + this._onWarning( + i18n.translate( + 'visTypeVega.vegaParser.notValidLibraryVersionForInputSpecWarningMessage', + { + defaultMessage: + 'The input spec uses {schemaLibrary} {schemaVersion}, but current version of {schemaLibrary} is {libraryVersion}.', + values: { + schemaLibrary: schema.library, + schemaVersion: schema.version, + libraryVersion: libVersion, + }, + } + ) + ); + } + + return { isVegaLite, libVersion }; + } catch (e) { + throw Error( + i18n.translate('visTypeVega.vegaParser.notValidSchemaForInputSpec', { defaultMessage: - 'The input spec uses {schemaLibrary} {schemaVersion}, but current version of {schemaLibrary} is {libraryVersion}.', - values: { - schemaLibrary: schema.library, - schemaVersion: schema.version, - libraryVersion: libVersion, - }, + 'The URL for the JSON "$schema" is incorrect. Correct the URL, then click Update.', }) ); } - - return { isVegaLite, libVersion }; } /** From f4ef2b116be5f92dc673c385e5968ae4b1fda0f6 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Tue, 12 Oct 2021 11:18:13 -0400 Subject: [PATCH 28/68] [buildkite] buildkite dependencies need to install before print_agent_links (#114573) --- .buildkite/scripts/lifecycle/pre_command.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.buildkite/scripts/lifecycle/pre_command.sh b/.buildkite/scripts/lifecycle/pre_command.sh index be31bb74ef668..cae6a07708d46 100755 --- a/.buildkite/scripts/lifecycle/pre_command.sh +++ b/.buildkite/scripts/lifecycle/pre_command.sh @@ -4,16 +4,17 @@ set -euo pipefail source .buildkite/scripts/common/util.sh -node .buildkite/scripts/lifecycle/print_agent_links.js || true - -echo '--- Job Environment Setup' +BUILDKITE_TOKEN="$(retry 5 5 vault read -field=buildkite_token_all_jobs secret/kibana-issues/dev/buildkite-ci)" +export BUILDKITE_TOKEN +echo '--- Install buildkite dependencies' cd '.buildkite' retry 5 15 yarn install cd - -BUILDKITE_TOKEN="$(retry 5 5 vault read -field=buildkite_token_all_jobs secret/kibana-issues/dev/buildkite-ci)" -export BUILDKITE_TOKEN +node .buildkite/scripts/lifecycle/print_agent_links.js || true + +echo '--- Job Environment Setup' # Set up a custom ES Snapshot Manifest if one has been specified for this build { From 435404e9614edb067778a7cadf5a58a460e86adb Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 12 Oct 2021 10:23:01 -0500 Subject: [PATCH 29/68] [Stack Monitoring] Fix blank page between loading page and overview (#114550) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/application/pages/loading_page.tsx | 10 +++++++++- .../monitoring/public/application/route_init.tsx | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/monitoring/public/application/pages/loading_page.tsx b/x-pack/plugins/monitoring/public/application/pages/loading_page.tsx index d5c1bcf80c23e..ebc43dd5c627e 100644 --- a/x-pack/plugins/monitoring/public/application/pages/loading_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/loading_page.tsx @@ -15,12 +15,20 @@ import { CODE_PATH_ELASTICSEARCH } from '../../../common/constants'; const CODE_PATHS = [CODE_PATH_ELASTICSEARCH]; -export const LoadingPage = () => { +export const LoadingPage = ({ staticLoadingState }: { staticLoadingState?: boolean }) => { const { clusters, loaded } = useClusters(null, undefined, CODE_PATHS); const title = i18n.translate('xpack.monitoring.loading.pageTitle', { defaultMessage: 'Loading', }); + if (staticLoadingState) { + return ( + + ; + + ); + } + return ( {loaded === false ? : renderRedirections(clusters)} diff --git a/x-pack/plugins/monitoring/public/application/route_init.tsx b/x-pack/plugins/monitoring/public/application/route_init.tsx index 8a11df3de50ae..092b3f54036c9 100644 --- a/x-pack/plugins/monitoring/public/application/route_init.tsx +++ b/x-pack/plugins/monitoring/public/application/route_init.tsx @@ -9,6 +9,7 @@ import { Route, Redirect, useLocation } from 'react-router-dom'; import { useClusters } from './hooks/use_clusters'; import { GlobalStateContext } from './contexts/global_state_context'; import { getClusterFromClusters } from '../lib/get_cluster_from_clusters'; +import { LoadingPage } from './pages/loading_page'; export interface ComponentProps { clusters: []; @@ -66,7 +67,9 @@ export const RouteInit: React.FC = ({ - ) : null; + ) : ( + + ); }; const isExpired = (license: any): boolean => { From fa69602b343bde7d5375ed2d2c732bf628b9ab28 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 12 Oct 2021 17:32:32 +0200 Subject: [PATCH 30/68] [Lens] Fix Metric visualization scale (#113956) * :bug: Fix metric rescale * :camera_flash: Restored old snapshots * :bug: Extend the fix to all scenarios * :camera_flash: Refresh snapshots for new fix Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../metric_visualization/expression.test.tsx | 20 ++++++++++++++----- .../metric_visualization/expression.tsx | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx index a3ac5b5837772..db70a7c8508e5 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx @@ -90,7 +90,9 @@ describe('metric_expression', () => { reportDescription="Fancy chart description" reportTitle="My fanci metric chart" > - +
{ reportDescription="Fancy chart description" reportTitle="My fanci metric chart" > - +
{ reportDescription="" reportTitle="" > - +
{ reportDescription="" reportTitle="" > - +
{ reportDescription="" reportTitle="" > - +
- +
{value}
From 917807e7a342de15f704c7674574f2494bc82dce Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Tue, 12 Oct 2021 16:32:44 +0100 Subject: [PATCH 31/68] Telemetry: update security filterlist. (#114495) --- .../server/lib/telemetry/filters.ts | 5 +++++ .../server/lib/telemetry/sender.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts index a29f195ed5ecc..ee162fb76f95b 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts @@ -14,6 +14,7 @@ export interface AllowlistFields { // Allow list process fields within events. This includes "process" and "Target.process".' const allowlistProcessFields: AllowlistFields = { args: true, + entity_id: true, name: true, executable: true, code_signature: true, @@ -30,6 +31,9 @@ const allowlistProcessFields: AllowlistFields = { dll: true, malware_signature: true, memory_region: true, + real: { + entity_id: true, + }, token: { integrity_level_name: true, }, @@ -49,6 +53,7 @@ const allowlistBaseEventFields: AllowlistFields = { original_file_name: true, }, }, + dns: true, event: true, file: { extension: true, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index 21e6b2cf6d9c4..46ed0b1f0bfb6 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -36,6 +36,11 @@ describe('TelemetryEventsSender', () => { event: { kind: 'alert', }, + dns: { + question: { + name: 'test-dns', + }, + }, agent: { name: 'test', }, @@ -79,6 +84,7 @@ describe('TelemetryEventsSender', () => { nope: 'nope', executable: null, // null fields are never allowlisted working_directory: '/some/usr/dir', + entity_id: 'some_entity_id', }, Responses: '{ "result": 0 }', // >= 7.15 Target: { @@ -102,6 +108,11 @@ describe('TelemetryEventsSender', () => { event: { kind: 'alert', }, + dns: { + question: { + name: 'test-dns', + }, + }, agent: { name: 'test', }, @@ -139,6 +150,7 @@ describe('TelemetryEventsSender', () => { process: { name: 'foo.exe', working_directory: '/some/usr/dir', + entity_id: 'some_entity_id', }, Responses: '{ "result": 0 }', Target: { From 2c2b8e388ade50362464924e1e9b19c62b6c06b4 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Tue, 12 Oct 2021 08:49:11 -0700 Subject: [PATCH 32/68] [Security Solution][Platform] - Bug fix when loading a saved query in detections (#114347) Bug in checking === for null when possibility of undefined, added tests. --- .../components/url_state/helpers.test.ts | 57 ++++++++++++++++++- .../common/components/url_state/helpers.ts | 7 ++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.test.ts index 8a678be0616b9..ba806da195461 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.test.ts @@ -6,7 +6,8 @@ */ import { navTabs } from '../../../app/home/home_navigations'; -import { getTitle } from './helpers'; +import { getTitle, isQueryStateEmpty } from './helpers'; +import { CONSTANTS } from './constants'; describe('Helpers Url_State', () => { describe('getTitle', () => { @@ -31,4 +32,58 @@ describe('Helpers Url_State', () => { expect(result).toEqual(''); }); }); + + describe('isQueryStateEmpty', () => { + test('returns true if queryState is undefined', () => { + const result = isQueryStateEmpty(undefined, CONSTANTS.savedQuery); + expect(result).toBeTruthy(); + }); + + test('returns true if queryState is null', () => { + const result = isQueryStateEmpty(null, CONSTANTS.savedQuery); + expect(result).toBeTruthy(); + }); + + test('returns true if url key is "query" and queryState is empty string', () => { + const result = isQueryStateEmpty({}, CONSTANTS.appQuery); + expect(result).toBeTruthy(); + }); + + test('returns false if url key is "query" and queryState is not empty', () => { + const result = isQueryStateEmpty( + { query: { query: '*:*' }, language: 'kuery' }, + CONSTANTS.appQuery + ); + expect(result).toBeFalsy(); + }); + + test('returns true if url key is "filters" and queryState is empty', () => { + const result = isQueryStateEmpty([], CONSTANTS.filters); + expect(result).toBeTruthy(); + }); + + test('returns false if url key is "filters" and queryState is not empty', () => { + const result = isQueryStateEmpty( + [{ query: { query: '*:*' }, meta: { key: '123' } }], + CONSTANTS.filters + ); + expect(result).toBeFalsy(); + }); + + // TODO: Is this a bug, or intended? + test('returns false if url key is "timeline" and queryState is empty', () => { + const result = isQueryStateEmpty({}, CONSTANTS.timeline); + expect(result).toBeFalsy(); + }); + + test('returns true if url key is "timeline" and queryState id is empty string', () => { + const result = isQueryStateEmpty({ id: '', isOpen: true }, CONSTANTS.timeline); + expect(result).toBeTruthy(); + }); + + test('returns false if url key is "timeline" and queryState is not empty', () => { + const result = isQueryStateEmpty({ id: '123', isOpen: true }, CONSTANTS.timeline); + expect(result).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index ba09ed914dc68..5b6bb0400ccdf 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -199,8 +199,11 @@ export const updateTimerangeUrl = ( return timeRange; }; -export const isQueryStateEmpty = (queryState: ValueUrlState | null, urlKey: KeyUrlState) => - queryState === null || +export const isQueryStateEmpty = ( + queryState: ValueUrlState | undefined | null, + urlKey: KeyUrlState +): boolean => + queryState == null || (urlKey === CONSTANTS.appQuery && isEmpty((queryState as Query).query)) || (urlKey === CONSTANTS.filters && isEmpty(queryState)) || (urlKey === CONSTANTS.timeline && (queryState as TimelineUrl).id === ''); From 86af44854c40b5c343ca82c638b3ac13b6dd140c Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Tue, 12 Oct 2021 11:02:41 -0500 Subject: [PATCH 33/68] [DOCS] Reformats the Logs settings tables into definition lists (#114140) --- .../general-infra-logs-ui-settings.asciidoc | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/docs/settings/general-infra-logs-ui-settings.asciidoc b/docs/settings/general-infra-logs-ui-settings.asciidoc index 282239dcf166c..1e6dcf012206b 100644 --- a/docs/settings/general-infra-logs-ui-settings.asciidoc +++ b/docs/settings/general-infra-logs-ui-settings.asciidoc @@ -1,31 +1,28 @@ -[cols="2*<"] -|=== -| `xpack.infra.enabled` - | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] - Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. -| `xpack.infra.sources.default.logAlias` - | Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +`xpack.infra.enabled`:: +deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] +Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. -| `xpack.infra.sources.default.metricAlias` - | Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +`xpack.infra.sources.default.logAlias`:: +Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -| `xpack.infra.sources.default.fields.timestamp` - | Timestamp used to sort log entries. Defaults to `@timestamp`. +`xpack.infra.sources.default.metricAlias`:: +Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -| `xpack.infra.sources.default.fields.message` - | Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. +`xpack.infra.sources.default.fields.timestamp`:: +Timestamp used to sort log entries. Defaults to `@timestamp`. -| `xpack.infra.sources.default.fields.tiebreaker` - | Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. +`xpack.infra.sources.default.fields.message`:: +Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. -| `xpack.infra.sources.default.fields.host` - | Field used to identify hosts. Defaults to `host.name`. +`xpack.infra.sources.default.fields.tiebreaker`:: +Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. -| `xpack.infra.sources.default.fields.container` - | Field used to identify Docker containers. Defaults to `container.id`. +`xpack.infra.sources.default.fields.host`:: +Field used to identify hosts. Defaults to `host.name`. -| `xpack.infra.sources.default.fields.pod` - | Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. +`xpack.infra.sources.default.fields.container`:: +Field used to identify Docker containers. Defaults to `container.id`. -|=== +`xpack.infra.sources.default.fields.pod`:: +Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. \ No newline at end of file From be0a1e6c00ad87dd341e63bdb52ea033dbd5fbc1 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Tue, 12 Oct 2021 11:07:38 -0500 Subject: [PATCH 34/68] [DOCS] Reformats the Machine learning settings tables into definition lists (#114143) --- docs/settings/ml-settings.asciidoc | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/settings/ml-settings.asciidoc b/docs/settings/ml-settings.asciidoc index 59fa236e08275..e67876c76df0d 100644 --- a/docs/settings/ml-settings.asciidoc +++ b/docs/settings/ml-settings.asciidoc @@ -11,18 +11,14 @@ enabled by default. [[general-ml-settings-kb]] ==== General {ml} settings -[cols="2*<"] -|=== -| `xpack.ml.enabled` {ess-icon} - | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] - Set to `true` (default) to enable {kib} {ml-features}. + - + - If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} - instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, - you can still use the {ml} APIs. To disable {ml} entirely, see the - {ref}/ml-settings.html[{es} {ml} settings]. - -|=== +`xpack.ml.enabled` {ess-icon}:: +deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] +Set to `true` (default) to enable {kib} {ml-features}. + ++ +If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} +instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, +you can still use the {ml} APIs. To disable {ml} entirely, refer to +{ref}/ml-settings.html[{es} {ml} settings]. [[advanced-ml-settings-kb]] ==== Advanced {ml} settings From 511c0859449637039d151f3de78a23065e46d908 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Tue, 12 Oct 2021 11:08:02 -0500 Subject: [PATCH 35/68] [DOCS] Reformats the Search sessions settings tables into definition lists (#114145) --- .../search-sessions-settings.asciidoc | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/docs/settings/search-sessions-settings.asciidoc b/docs/settings/search-sessions-settings.asciidoc index abd6a8f12b568..7b03cd23a9023 100644 --- a/docs/settings/search-sessions-settings.asciidoc +++ b/docs/settings/search-sessions-settings.asciidoc @@ -7,37 +7,26 @@ Configure the search session settings in your `kibana.yml` configuration file. +`xpack.data_enhanced.search.sessions.enabled` {ess-icon}:: +Set to `true` (default) to enable search sessions. -[cols="2*<"] -|=== -a| `xpack.data_enhanced.` -`search.sessions.enabled` {ess-icon} -| Set to `true` (default) to enable search sessions. +`xpack.data_enhanced.search.sessions.trackingInterval` {ess-icon}:: +The frequency for updating the state of a search session. The default is `10s`. -a| `xpack.data_enhanced.` -`search.sessions.trackingInterval` {ess-icon} -| The frequency for updating the state of a search session. The default is `10s`. - -a| `xpack.data_enhanced.` -`search.sessions.pageSize` {ess-icon} -| How many search sessions {kib} processes at once while monitoring +`xpack.data_enhanced.search.sessions.pageSize` {ess-icon}:: +How many search sessions {kib} processes at once while monitoring session progress. The default is `100`. -a| `xpack.data_enhanced.` -`search.sessions.notTouchedTimeout` {ess-icon} -| How long {kib} stores search results from unsaved sessions, +`xpack.data_enhanced.search.sessions.notTouchedTimeout` {ess-icon}:: +How long {kib} stores search results from unsaved sessions, after the last search in the session completes. The default is `5m`. -a| `xpack.data_enhanced.` -`search.sessions.notTouchedInProgressTimeout` {ess-icon} -| How long a search session can run after a user navigates away without saving a session. The default is `1m`. +`xpack.data_enhanced.search.sessions.notTouchedInProgressTimeout` {ess-icon}:: +How long a search session can run after a user navigates away without saving a session. The default is `1m`. -a| `xpack.data_enhanced.` -`search.sessions.maxUpdateRetries` {ess-icon} -| How many retries {kib} can perform while attempting to save a search session. The default is `3`. +`xpack.data_enhanced.search.sessions.maxUpdateRetries` {ess-icon}:: +How many retries {kib} can perform while attempting to save a search session. The default is `3`. -a| `xpack.data_enhanced.` -`search.sessions.defaultExpiration` {ess-icon} -| How long search session results are stored before they are deleted. +`xpack.data_enhanced.search.sessions.defaultExpiration` {ess-icon}:: +How long search session results are stored before they are deleted. Extending a search session resets the expiration by the same value. The default is `7d`. -|=== From 9594574d65df746613d485ee3a6676bf3c0816f1 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Tue, 12 Oct 2021 11:08:47 -0500 Subject: [PATCH 36/68] [DOCS] Reformats the Spaces settings tables into definition lists (#114146) --- docs/settings/spaces-settings.asciidoc | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/settings/spaces-settings.asciidoc b/docs/settings/spaces-settings.asciidoc index 8504464da1dfb..30b7beceb70ba 100644 --- a/docs/settings/spaces-settings.asciidoc +++ b/docs/settings/spaces-settings.asciidoc @@ -12,17 +12,13 @@ roles when Security is enabled. [[spaces-settings]] ==== Spaces settings -[cols="2*<"] -|=== -| `xpack.spaces.enabled` - | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] - Set to `true` (default) to enable Spaces in {kib}. - This setting is deprecated. Starting in 8.0, it will not be possible to disable this plugin. +`xpack.spaces.enabled`:: +deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] +Set to `true` (default) to enable Spaces in {kib}. +This setting is deprecated. Starting in 8.0, it will not be possible to disable this plugin. -| `xpack.spaces.maxSpaces` - | The maximum amount of Spaces that can be used with this instance of {kib}. Some operations - in {kib} return all spaces using a single `_search` from {es}, so this must be - set lower than the `index.max_result_window` in {es}. - Defaults to `1000`. - -|=== +`xpack.spaces.maxSpaces`:: +The maximum amount of Spaces that can be used with this instance of {kib}. Some operations +in {kib} return all spaces using a single `_search` from {es}, so this must be +set lower than the `index.max_result_window` in {es}. +Defaults to `1000`. From 103869509c9e2253499c662a22001e38b06e0088 Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Tue, 12 Oct 2021 12:12:34 -0400 Subject: [PATCH 37/68] [Security Solution] [Platform] Utilize SO resolve api for reading rules by `id` (#112478) * added outcome to backend routes * adds so resolved property alias_target_id to response * adds UI portion * working URL redirect on aliasMatch - todo -> update rule details page refresh button to use SO resolve. * cleanup * fix integration tests * fix jest tests * cleanup types * fix eslint.. I think vs code formatted this * WIP - undo me, working index.test.ts function * WIP - also undo me, probably * working test for aliasMatch, need to add test for outcome = conflict * add conflict callout when SO resolve yields conflict outcome * code cleanup * fix type issues * small cleanup, fix jest test after undoing changes for getFailingRuleStatus * cleanup tests * add alias_target_id to response validation too * unit test changes * update tests again * add all dependencies to useEffect and prefer useMemo * add type cast * adds integration tests for different outcomes after mocking a migrated rule leading to an aliasMatch and a migrated rule + accidental inserted rule to lead to a conflict. Also removes the outcome property if it is an exactMatch * remove unused import * fix test * functional WIP * cleanup * cleanup * finishing touches to address PR review comments * remove console.error * fix bug where spaces was not typed correctly in the plugin start method here https://github.com/elastic/kibana/pull/113983 --- .../schemas/common/schemas.ts | 12 + .../schemas/request/rule_schemas.ts | 4 + .../schemas/response/rules_schema.ts | 4 + .../detection_engine/rules/types.ts | 2 + .../rules/details/failure_history.test.tsx | 68 ++- .../rules/details/failure_history.tsx | 12 +- .../rules/details/index.test.tsx | 200 +++++++-- .../detection_engine/rules/details/index.tsx | 50 +++ .../use_hosts_risk_score.ts | 2 +- .../plugins/security_solution/public/types.ts | 2 +- .../routes/__mocks__/request_responses.ts | 20 +- .../routes/rules/delete_rules_route.test.ts | 6 +- .../routes/rules/read_rules_route.test.ts | 41 ++ .../detection_engine/rules/read_rules.test.ts | 18 +- .../lib/detection_engine/rules/read_rules.ts | 12 +- .../lib/detection_engine/rules/types.ts | 4 +- .../rules/update_rules.test.ts | 14 +- .../schemas/rule_converters.ts | 9 +- .../basic/tests/index.ts | 2 +- .../security_and_spaces/tests/index.ts | 1 + .../tests/resolve_read_rules.ts | 160 +++++++ .../detection_engine_api_integration/utils.ts | 33 +- .../resolve_read_rules/7_14/data.json | 101 +++++ .../resolve_read_rules/7_14/mappings.json | 397 ++++++++++++++++++ 24 files changed, 1101 insertions(+), 73 deletions(-) create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts create mode 100644 x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/data.json create mode 100644 x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/mappings.json diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index a9f7d96f1eb2e..3933d7e39275e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -35,6 +35,18 @@ export type Description = t.TypeOf; export const descriptionOrUndefined = t.union([description, t.undefined]); export type DescriptionOrUndefined = t.TypeOf; +// outcome is a property of the saved object resolve api +// will tell us info about the rule after 8.0 migrations +export const outcome = t.union([ + t.literal('exactMatch'), + t.literal('aliasMatch'), + t.literal('conflict'), +]); +export type Outcome = t.TypeOf; + +export const alias_target_id = t.string; +export type AliasTargetId = t.TypeOf; + export const enabled = t.boolean; export type Enabled = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 719337a231c1c..12e72fb6fc697 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -58,6 +58,8 @@ import { tags, interval, enabled, + outcome, + alias_target_id, updated_at, updated_by, created_at, @@ -150,6 +152,8 @@ const baseParams = { building_block_type, note, license, + outcome, + alias_target_id, output_index, timeline_id, timeline_title, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index 247829d5b9e7a..ac9329c3870f1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -70,6 +70,8 @@ import { last_failure_message, filters, meta, + outcome, + alias_target_id, note, building_block_type, license, @@ -174,6 +176,8 @@ export const partialRulesSchema = t.partial({ last_failure_message, filters, meta, + outcome, + alias_target_id, index, namespace, note, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 9faed2d0646e0..ecf68fa207b70 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -108,6 +108,8 @@ export const RuleSchema = t.intersection([ throttle: t.union([t.string, t.null]), }), t.partial({ + outcome: t.union([t.literal('exactMatch'), t.literal('aliasMatch'), t.literal('conflict')]), + alias_target_id: t.string, building_block_type, anomaly_threshold: t.number, filters: t.array(t.unknown), diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.test.tsx index d95b6ca9f3435..c91aade50cbae 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.test.tsx @@ -6,19 +6,79 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; - -import { TestProviders } from '../../../../../common/mock'; +import { shallow, mount } from 'enzyme'; +import { + TestProviders, + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, +} from '../../../../../common/mock'; import { FailureHistory } from './failure_history'; import { useRuleStatus } from '../../../../containers/detection_engine/rules'; jest.mock('../../../../containers/detection_engine/rules'); +import { waitFor } from '@testing-library/react'; + +import '../../../../../common/mock/match_media'; + +import { createStore, State } from '../../../../../common/store'; +import { mockHistory, Router } from '../../../../../common/mock/router'; + +const state: State = { + ...mockGlobalState, +}; +const { storage } = createSecuritySolutionStorageMock(); +const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + +describe('RuleDetailsPageComponent', () => { + beforeAll(() => { + (useRuleStatus as jest.Mock).mockReturnValue([ + false, + { + status: 'succeeded', + last_failure_at: new Date().toISOString(), + last_failure_message: 'my fake failure message', + failures: [ + { + alert_id: 'myfakeid', + status_date: new Date().toISOString(), + status: 'failed', + last_failure_at: new Date().toISOString(), + last_success_at: new Date().toISOString(), + last_failure_message: 'my fake failure message', + last_look_back_date: new Date().toISOString(), // NOTE: This is no longer used on the UI, but left here in case users are using it within the API + }, + ], + }, + ]); + }); + + it('renders reported rule failures correctly', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect(wrapper.find('EuiBasicTable')).toHaveLength(1); + // ensure the expected error message is displayed in the table + expect(wrapper.find('EuiTableRowCell').at(2).find('div').at(1).text()).toEqual( + 'my fake failure message' + ); + }); + }); +}); + describe('FailureHistory', () => { beforeAll(() => { (useRuleStatus as jest.Mock).mockReturnValue([false, null]); }); - it('renders correctly', () => { + it('renders correctly with no statuses', () => { const wrapper = shallow(, { wrappingComponent: TestProviders, }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx index a7db7ab57f6c2..5289e34b10046 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/failure_history.tsx @@ -23,6 +23,12 @@ interface FailureHistoryProps { id?: string | null; } +const renderStatus = () => {i18n.TYPE_FAILED}; +const renderLastFailureAt = (value: string) => ( + +); +const renderLastFailureMessage = (value: string) => <>{value}; + const FailureHistoryComponent: React.FC = ({ id }) => { const [loading, ruleStatus] = useRuleStatus(id); if (loading) { @@ -36,14 +42,14 @@ const FailureHistoryComponent: React.FC = ({ id }) => { const columns: Array> = [ { name: i18n.COLUMN_STATUS_TYPE, - render: () => {i18n.TYPE_FAILED}, + render: renderStatus, truncateText: false, width: '16%', }, { field: 'last_failure_at', name: i18n.COLUMN_FAILED_AT, - render: (value: string) => , + render: renderLastFailureAt, sortable: false, truncateText: false, width: '24%', @@ -51,7 +57,7 @@ const FailureHistoryComponent: React.FC = ({ id }) => { { field: 'last_failure_message', name: i18n.COLUMN_FAILED_MSG, - render: (value: string) => <>{value}, + render: renderLastFailureMessage, sortable: false, truncateText: false, width: '60%', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index 0c67a19e59e32..9c1667e7b4910 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -20,22 +20,51 @@ import { import { RuleDetailsPage } from './index'; import { createStore, State } from '../../../../../common/store'; import { useUserData } from '../../../../components/user_info'; +import { useRuleStatus } from '../../../../containers/detection_engine/rules'; +import { useRuleWithFallback } from '../../../../containers/detection_engine/rules/use_rule_with_fallback'; + import { useSourcererScope } from '../../../../../common/containers/sourcerer'; import { useParams } from 'react-router-dom'; import { mockHistory, Router } from '../../../../../common/mock/router'; -import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin'; + +import { useKibana } from '../../../../../common/lib/kibana'; + +import { fillEmptySeverityMappings } from '../helpers'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar jest.mock('../../../../../common/components/search_bar', () => ({ SiemSearchBar: () => null, })); +jest.mock('../helpers', () => { + const original = jest.requireActual('../helpers'); + return { + ...original, + fillEmptySeverityMappings: jest.fn().mockReturnValue([]), + }; +}); jest.mock('../../../../../common/components/query_bar', () => ({ QueryBar: () => null, })); jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); +jest.mock('../../../../containers/detection_engine/rules', () => { + const original = jest.requireActual('../../../../containers/detection_engine/rules'); + return { + ...original, + useRuleStatus: jest.fn(), + }; +}); +jest.mock('../../../../containers/detection_engine/rules/use_rule_with_fallback', () => { + const original = jest.requireActual( + '../../../../containers/detection_engine/rules/use_rule_with_fallback' + ); + return { + ...original, + useRuleWithFallback: jest.fn(), + }; +}); jest.mock('../../../../../common/containers/sourcerer'); jest.mock('../../../../../common/containers/use_global_time', () => ({ useGlobalTime: jest.fn().mockReturnValue({ @@ -55,41 +84,42 @@ jest.mock('react-router-dom', () => { }; }); -jest.mock('../../../../../common/lib/kibana', () => { - const original = jest.requireActual('../../../../../common/lib/kibana'); +jest.mock('../../../../../common/lib/kibana'); - return { - ...original, - useUiSetting$: jest.fn().mockReturnValue([]), - useKibana: () => ({ - services: { - application: { - ...original.useKibana().services.application, - navigateToUrl: jest.fn(), - capabilities: { - actions: jest.fn().mockReturnValue({}), - siem: { crud_alerts: true, read_alerts: true }, - }, - }, - timelines: { ...mockTimelines }, - data: { - query: { - filterManager: jest.fn().mockReturnValue({}), - }, - }, - }, - }), - useToasts: jest.fn().mockReturnValue({ - addError: jest.fn(), - addSuccess: jest.fn(), - addWarning: jest.fn(), - }), - }; -}); +const mockRedirectLegacyUrl = jest.fn(); +const mockGetLegacyUrlConflict = jest.fn(); const state: State = { ...mockGlobalState, }; + +const mockRule = { + id: 'myfakeruleid', + author: [], + severity_mapping: [], + risk_score_mapping: [], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', + references: [], + actions: [], + enabled: false, + false_positives: [], + max_signals: 100, + tags: [], + threat: [], + throttle: null, + version: 1, + exceptions_list: [], +}; const { storage } = createSecuritySolutionStorageMock(); const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -101,9 +131,108 @@ describe('RuleDetailsPageComponent', () => { indicesExist: true, indexPattern: {}, }); + (useRuleStatus as jest.Mock).mockReturnValue([ + false, + { + status: 'succeeded', + last_failure_at: new Date().toISOString(), + last_failure_message: 'my fake failure message', + failures: [], + }, + ]); + (useRuleWithFallback as jest.Mock).mockReturnValue({ + error: null, + loading: false, + isExistingRule: true, + refresh: jest.fn(), + rule: { ...mockRule }, + }); + (fillEmptySeverityMappings as jest.Mock).mockReturnValue([]); + }); + + async function setup() { + const useKibanaMock = useKibana as jest.Mocked; + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.spaces = { + ui: { + // @ts-expect-error + components: { getLegacyUrlConflict: mockGetLegacyUrlConflict }, + redirectLegacyUrl: mockRedirectLegacyUrl, + }, + }; + } + + it('renders correctly with no outcome property on rule', async () => { + await setup(); + + const wrapper = mount( + + + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); + expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); + }); + }); + + it('renders correctly with outcome === "exactMatch"', async () => { + await setup(); + (useRuleWithFallback as jest.Mock).mockReturnValue({ + error: null, + loading: false, + isExistingRule: true, + refresh: jest.fn(), + rule: { ...mockRule, outcome: 'exactMatch' }, + }); + + const wrapper = mount( + + + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); + expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); + }); }); - it('renders correctly', async () => { + it('renders correctly with outcome === "aliasMatch"', async () => { + await setup(); + (useRuleWithFallback as jest.Mock).mockReturnValue({ + error: null, + loading: false, + isExistingRule: true, + refresh: jest.fn(), + rule: { ...mockRule, outcome: 'aliasMatch' }, + }); + const wrapper = mount( + + + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(`rules/id/myfakeruleid`, `rule`); + }); + }); + + it('renders correctly when outcome = conflict', async () => { + await setup(); + (useRuleWithFallback as jest.Mock).mockReturnValue({ + error: null, + loading: false, + isExistingRule: true, + refresh: jest.fn(), + rule: { ...mockRule, outcome: 'conflict', alias_target_id: 'aliased_rule_id' }, + }); const wrapper = mount( @@ -113,6 +242,13 @@ describe('RuleDetailsPageComponent', () => { ); await waitFor(() => { expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(`rules/id/myfakeruleid`, `rule`); + expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({ + currentObjectId: 'myfakeruleid', + objectNoun: 'rule', + otherObjectId: 'aliased_rule_id', + otherObjectPath: `rules/id/aliased_rule_id`, + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 70d7faa47b9ee..492b8e461fb60 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -19,6 +19,8 @@ import { EuiToolTip, EuiWindowEvent, } from '@elastic/eui'; +import { i18n as i18nTranslate } from '@kbn/i18n'; + import { FormattedMessage } from '@kbn/i18n/react'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -261,6 +263,7 @@ const RuleDetailsPageComponent: React.FC = ({ capabilities: { actions }, }, timelines: timelinesUi, + spaces: spacesApi, }, } = useKibana(); const hasActionsPrivileges = useMemo(() => { @@ -277,6 +280,52 @@ const RuleDetailsPageComponent: React.FC = ({ } }, [maybeRule]); + useEffect(() => { + if (rule) { + const outcome = rule.outcome; + if (spacesApi && outcome === 'aliasMatch') { + // This rule has been resolved from a legacy URL - redirect the user to the new URL and display a toast. + const path = `rules/id/${rule.id}${window.location.search}${window.location.hash}`; + spacesApi.ui.redirectLegacyUrl( + path, + i18nTranslate.translate( + 'xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun', + { + defaultMessage: 'rule', + } + ) + ); + } + } + }, [rule, spacesApi]); + + const getLegacyUrlConflictCallout = useMemo(() => { + const outcome = rule?.outcome; + if (rule != null && spacesApi && outcome === 'conflict') { + const aliasTargetId = rule?.alias_target_id!; // This is always defined if outcome === 'conflict' + // We have resolved to one rule, but there is another one with a legacy URL associated with this page. Display a + // callout with a warning for the user, and provide a way for them to navigate to the other rule. + const otherRulePath = `rules/id/${aliasTargetId}${window.location.search}${window.location.hash}`; + return ( + <> + + {spacesApi.ui.components.getLegacyUrlConflict({ + objectNoun: i18nTranslate.translate( + 'xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun', + { + defaultMessage: 'rule', + } + ), + currentObjectId: rule.id, + otherObjectId: aliasTargetId, + otherObjectPath: otherRulePath, + })} + + ); + } + return null; + }, [rule, spacesApi]); + useEffect(() => { if (!hasIndexRead) { setTabs(ruleDetailTabs.filter(({ id }) => id !== RuleDetailTabs.alerts)); @@ -721,6 +770,7 @@ const RuleDetailsPageComponent: React.FC = ({ {ruleError} + {getLegacyUrlConflictCallout} diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts index af663bb74f54a..15cb7ef7b1c46 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts @@ -98,7 +98,7 @@ export const useHostsRiskScore = ({ useEffect(() => { if (riskyHostsFeatureEnabled && (hostName || timerange)) { - spaces.getActiveSpace().then((space) => { + spaces?.getActiveSpace().then((space) => { start({ data, timerange: timerange diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 1cec87fd35d1f..e595b905b998e 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -68,7 +68,7 @@ export interface StartPlugins { timelines: TimelinesUIStart; uiActions: UiActionsStart; ml?: MlPluginStart; - spaces: SpacesPluginStart; + spaces?: SpacesPluginStart; } export type StartServices = CoreStart & diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index c3c3ac47baf9a..200246ba1a367 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -34,7 +34,7 @@ import { getFinalizeSignalsMigrationSchemaMock } from '../../../../../common/det import { EqlSearchResponse } from '../../../../../common/detection_engine/types'; import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detection_engine/schemas/request/get_signals_migration_status_schema.mock'; import { RuleParams } from '../../schemas/rule_schemas'; -import { Alert } from '../../../../../../alerting/common'; +import { SanitizedAlert, ResolvedSanitizedRule } from '../../../../../../alerting/common'; import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -87,6 +87,13 @@ export const getReadRequest = () => query: { rule_id: 'rule-1' }, }); +export const getReadRequestWithId = (id: string) => + requestMock.create({ + method: 'get', + path: DETECTION_ENGINE_RULES_URL, + query: { id }, + }); + export const getFindRequest = () => requestMock.create({ method: 'get', @@ -362,7 +369,7 @@ export const nonRuleAlert = (isRuleRegistryEnabled: boolean) => ({ export const getAlertMock = ( isRuleRegistryEnabled: boolean, params: T -): Alert => ({ +): SanitizedAlert => ({ id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', name: 'Detect Root/Admin Users', tags: [`${INTERNAL_RULE_ID_KEY}:rule-1`, `${INTERNAL_IMMUTABLE_KEY}:false`], @@ -378,7 +385,6 @@ export const getAlertMock = ( notifyWhen: null, createdBy: 'elastic', updatedBy: 'elastic', - apiKey: null, apiKeyOwner: 'elastic', muteAll: false, mutedInstanceIds: [], @@ -389,6 +395,14 @@ export const getAlertMock = ( }, }); +export const resolveAlertMock = ( + isRuleRegistryEnabled: boolean, + params: T +): ResolvedSanitizedRule => ({ + outcome: 'exactMatch', + ...getAlertMock(isRuleRegistryEnabled, params), +}); + export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts index 35b3ef3d9cf85..7c447660acb45 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts @@ -8,7 +8,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { getEmptyFindResult, - getAlertMock, + resolveAlertMock, getDeleteRequest, getFindResultWithSingleHit, getDeleteRequestById, @@ -45,8 +45,8 @@ describe.each([ }); test('returns 200 when deleting a single rule with a valid actionClient and alertClient by id', async () => { - clients.rulesClient.get.mockResolvedValue( - getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) + clients.rulesClient.resolve.mockResolvedValue( + resolveAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) ); const response = await server.inject(getDeleteRequestById(), context); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/read_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/read_rules_route.test.ts index d6c18088800ba..37b8228ac1e9b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/read_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/read_rules_route.test.ts @@ -12,11 +12,14 @@ import { readRulesRoute } from './read_rules_route'; import { getEmptyFindResult, getReadRequest, + getReadRequestWithId, getFindResultWithSingleHit, nonRuleFindResult, getEmptySavedObjectsResponse, + resolveAlertMock, } from '../__mocks__/request_responses'; import { requestMock, requestContextMock, serverMock } from '../__mocks__'; +import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; describe.each([ ['Legacy', false], @@ -26,6 +29,7 @@ describe.each([ let { clients, context } = requestContextMock.createTools(); let logger: ReturnType; + const myFakeId = '99403909-ca9b-49ba-9d7a-7e5320e68d05'; beforeEach(() => { server = serverMock.create(); logger = loggingSystemMock.createLogger(); @@ -35,6 +39,12 @@ describe.each([ clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse()); // successful transform clients.ruleExecutionLogClient.find.mockResolvedValue([]); + clients.rulesClient.resolve.mockResolvedValue({ + ...resolveAlertMock(isRuleRegistryEnabled, { + ...getQueryRuleParams(), + }), + id: myFakeId, + }); readRulesRoute(server.router, logger, isRuleRegistryEnabled); }); @@ -44,6 +54,37 @@ describe.each([ expect(response.status).toEqual(200); }); + test('returns 200 when reading a single rule outcome === exactMatch', async () => { + const response = await server.inject(getReadRequestWithId(myFakeId), context); + expect(response.status).toEqual(200); + }); + + test('returns 200 when reading a single rule outcome === aliasMatch', async () => { + clients.rulesClient.resolve.mockResolvedValue({ + ...resolveAlertMock(isRuleRegistryEnabled, { + ...getQueryRuleParams(), + }), + id: myFakeId, + outcome: 'aliasMatch', + }); + const response = await server.inject(getReadRequestWithId(myFakeId), context); + expect(response.status).toEqual(200); + }); + + test('returns 200 when reading a single rule outcome === conflict', async () => { + clients.rulesClient.resolve.mockResolvedValue({ + ...resolveAlertMock(isRuleRegistryEnabled, { + ...getQueryRuleParams(), + }), + id: myFakeId, + outcome: 'conflict', + alias_target_id: 'myaliastargetid', + }); + const response = await server.inject(getReadRequestWithId(myFakeId), context); + expect(response.status).toEqual(200); + expect(response.body.alias_target_id).toEqual('myaliastargetid'); + }); + test('returns 404 if alertClient is not available on the route', async () => { context.alerting!.getRulesClient = jest.fn(); const response = await server.inject(getReadRequest(), context); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts index 6f89d725a458e..2e17b91fbcd54 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.test.ts @@ -7,7 +7,11 @@ import { readRules } from './read_rules'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; -import { getAlertMock, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; +import { + resolveAlertMock, + getAlertMock, + getFindResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; export class TestError extends Error { @@ -33,7 +37,9 @@ describe.each([ describe('readRules', () => { test('should return the output from rulesClient if id is set but ruleId is undefined', async () => { const rulesClient = rulesClientMock.create(); - rulesClient.get.mockResolvedValue(getAlertMock(isRuleRegistryEnabled, getQueryRuleParams())); + rulesClient.resolve.mockResolvedValue( + resolveAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) + ); const rule = await readRules({ isRuleRegistryEnabled, @@ -45,10 +51,10 @@ describe.each([ }); test('should return null if saved object found by alerts client given id is not alert type', async () => { const rulesClient = rulesClientMock.create(); - const result = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()); + const result = resolveAlertMock(isRuleRegistryEnabled, getQueryRuleParams()); // @ts-expect-error delete result.alertTypeId; - rulesClient.get.mockResolvedValue(result); + rulesClient.resolve.mockResolvedValue(result); const rule = await readRules({ isRuleRegistryEnabled, @@ -61,7 +67,7 @@ describe.each([ test('should return error if alerts client throws 404 error on get', async () => { const rulesClient = rulesClientMock.create(); - rulesClient.get.mockImplementation(() => { + rulesClient.resolve.mockImplementation(() => { throw new TestError(); }); @@ -76,7 +82,7 @@ describe.each([ test('should return error if alerts client throws error on get', async () => { const rulesClient = rulesClientMock.create(); - rulesClient.get.mockImplementation(() => { + rulesClient.resolve.mockImplementation(() => { throw new Error('Test error'); }); try { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts index 9578e3d4cb6d2..2571791164b6b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/read_rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SanitizedAlert } from '../../../../../alerting/common'; +import { ResolvedSanitizedRule, SanitizedAlert } from '../../../../../alerting/common'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; import { RuleParams } from '../schemas/rule_schemas'; import { findRules } from './find_rules'; @@ -24,11 +24,17 @@ export const readRules = async ({ rulesClient, id, ruleId, -}: ReadRuleOptions): Promise | null> => { +}: ReadRuleOptions): Promise< + SanitizedAlert | ResolvedSanitizedRule | null +> => { if (id != null) { try { - const rule = await rulesClient.get({ id }); + const rule = await rulesClient.resolve({ id }); if (isAlertType(isRuleRegistryEnabled, rule)) { + if (rule?.outcome === 'exactMatch') { + const { outcome, ...restOfRule } = rule; + return restOfRule; + } return rule; } else { return null; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index cceda063e987b..8adf19a53f92b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -100,14 +100,14 @@ import { } from '../../../../common/detection_engine/schemas/common/schemas'; import { RulesClient, PartialAlert } from '../../../../../alerting/server'; -import { Alert, SanitizedAlert } from '../../../../../alerting/common'; +import { SanitizedAlert } from '../../../../../alerting/common'; import { SIGNALS_ID } from '../../../../common/constants'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { IRuleExecutionLogClient } from '../rule_execution_log/types'; import { ruleTypeMappings } from '../signals/utils'; -export type RuleAlertType = Alert; +export type RuleAlertType = SanitizedAlert; // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface IRuleStatusSOAttributes extends Record { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts index 74301f3665ff8..703be3bdd76bd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getAlertMock } from '../routes/__mocks__/request_responses'; +import { getAlertMock, resolveAlertMock } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; import { getUpdateRulesOptionsMock, getUpdateMlRulesOptionsMock } from './update_rules.mock'; import { RulesClientMock } from '../../../../../alerting/server/rules_client.mock'; @@ -18,8 +18,8 @@ describe.each([ it('should call rulesClient.disable if the rule was enabled and enabled is false', async () => { const rulesOptionsMock = getUpdateRulesOptionsMock(isRuleRegistryEnabled); rulesOptionsMock.ruleUpdate.enabled = false; - (rulesOptionsMock.rulesClient as unknown as RulesClientMock).get.mockResolvedValue( - getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) + (rulesOptionsMock.rulesClient as unknown as RulesClientMock).resolve.mockResolvedValue( + resolveAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) ); (rulesOptionsMock.rulesClient as unknown as RulesClientMock).update.mockResolvedValue( getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) @@ -38,8 +38,8 @@ describe.each([ const rulesOptionsMock = getUpdateRulesOptionsMock(isRuleRegistryEnabled); rulesOptionsMock.ruleUpdate.enabled = true; - (rulesOptionsMock.rulesClient as unknown as RulesClientMock).get.mockResolvedValue({ - ...getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()), + (rulesOptionsMock.rulesClient as unknown as RulesClientMock).resolve.mockResolvedValue({ + ...resolveAlertMock(isRuleRegistryEnabled, getQueryRuleParams()), enabled: false, }); (rulesOptionsMock.rulesClient as unknown as RulesClientMock).update.mockResolvedValue( @@ -63,8 +63,8 @@ describe.each([ getAlertMock(isRuleRegistryEnabled, getMlRuleParams()) ); - (rulesOptionsMock.rulesClient as unknown as RulesClientMock).get.mockResolvedValue( - getAlertMock(isRuleRegistryEnabled, getMlRuleParams()) + (rulesOptionsMock.rulesClient as unknown as RulesClientMock).resolve.mockResolvedValue( + resolveAlertMock(isRuleRegistryEnabled, getMlRuleParams()) ); await updateRules(rulesOptionsMock); 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 eef20af0e564d..240a226e86914 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 @@ -27,7 +27,7 @@ import { AppClient } from '../../../types'; import { addTags } from '../rules/add_tags'; import { DEFAULT_MAX_SIGNALS, SERVER_APP_ID, SIGNALS_ID } from '../../../../common/constants'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; -import { SanitizedAlert } from '../../../../../alerting/common'; +import { ResolvedSanitizedRule, SanitizedAlert } from '../../../../../alerting/common'; import { IRuleStatusSOAttributes } from '../rules/types'; import { transformTags } from '../routes/rules/utils'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -281,12 +281,17 @@ export const commonParamsCamelToSnake = (params: BaseRuleParams) => { }; export const internalRuleToAPIResponse = ( - rule: SanitizedAlert, + rule: SanitizedAlert | ResolvedSanitizedRule, ruleStatus?: IRuleStatusSOAttributes, legacyRuleActions?: LegacyRuleActions | null ): FullResponseSchema => { const mergedStatus = ruleStatus ? mergeAlertWithSidecarStatus(rule, ruleStatus) : undefined; + const isResolvedRule = (obj: unknown): obj is ResolvedSanitizedRule => + (obj as ResolvedSanitizedRule).outcome != null; return { + // saved object properties + outcome: isResolvedRule(rule) ? rule.outcome : undefined, + alias_target_id: isResolvedRule(rule) ? rule.alias_target_id : undefined, // Alerting framework params id: rule.id, updated_at: rule.updatedAt.toISOString(), diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/index.ts b/x-pack/test/detection_engine_api_integration/basic/tests/index.ts index 802b1e78930e8..5fa4540bbe854 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/index.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default ({ loadTestFile }: FtrProviderContext): void => { - describe('detection engine api security and spaces enabled', function () { + describe('detection engine api basic license', function () { this.tags('ciGroup1'); loadTestFile(require.resolve('./add_prepackaged_rules')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index b4bd74172920b..1b88c4fe21b49 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -33,6 +33,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./get_prepackaged_rules_status')); loadTestFile(require.resolve('./import_rules')); loadTestFile(require.resolve('./read_rules')); + loadTestFile(require.resolve('./resolve_read_rules')); loadTestFile(require.resolve('./update_rules')); loadTestFile(require.resolve('./update_rules_bulk')); loadTestFile(require.resolve('./patch_rules_bulk')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts new file mode 100644 index 0000000000000..6013398d4695d --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/resolve_read_rules.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex } from '../../utils'; + +const spaceId = '714-space'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + describe('resolve_read_rules', () => { + describe('reading rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await esArchiver.load( + 'x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14' + ); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload( + 'x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14' + ); + }); + + it('should create a "migrated" rule where querying for the new SO _id will resolve the new object and not return the outcome field when outcome === exactMatch', async () => { + // link to the new URL with migrated SO id 74f3e6d7-b7bb-477d-ac28-92ee22728e6e + const URL = `/s/${spaceId}${DETECTION_ENGINE_RULES_URL}?id=90e3ca0e-71f7-513a-b60a-ac678efd8887`; + const readRulesAliasMatchRes = await supertest.get(URL).set('kbn-xsrf', 'true').send(); + expect(readRulesAliasMatchRes.body.outcome).to.eql('aliasMatch'); + + // now that we have the migrated alias_target_id, let's attempt an 'exactMatch' query + // the result of which should have the outcome as undefined when querying the read rules api. + const exactMatchURL = `/s/${spaceId}${DETECTION_ENGINE_RULES_URL}?id=${readRulesAliasMatchRes.body.alias_target_id}`; + const readRulesExactMatchRes = await supertest + .get(exactMatchURL) + .set('kbn-xsrf', 'true') + .send(); + expect(readRulesExactMatchRes.body.outcome).to.eql(undefined); + }); + + it('should create a rule and a "conflicting rule" where the SO _id matches the sourceId (see legacy-url-alias SO) of a migrated rule', async () => { + // mimic a rule SO that was inserted accidentally + // we have to insert this outside of esArchiver otherwise kibana will migrate this + // and we won't have a conflict + await es.index({ + id: 'alert:90e3ca0e-71f7-513a-b60a-ac678efd8887', + index: '.kibana', + refresh: true, + body: { + alert: { + name: 'test 7.14', + tags: [ + '__internal_rule_id:82747bb8-bae0-4b59-8119-7f65ac564e14', + '__internal_immutable:false', + ], + alertTypeId: 'siem.signals', + consumer: 'siem', + params: { + author: [], + description: 'test', + ruleId: '82747bb8-bae0-4b59-8119-7f65ac564e14', + falsePositives: [], + from: 'now-3615s', + immutable: false, + license: '', + outputIndex: '.siem-signals-devin-hurley-714-space', + meta: { + from: '1h', + kibana_siem_app_url: 'http://0.0.0.0:5601/s/714-space/app/security', + }, + maxSignals: 100, + riskScore: 21, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + type: 'query', + language: 'kuery', + index: [ + 'apm-*-transaction*', + 'traces-apm*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + query: '*:*', + filters: [], + }, + schedule: { + interval: '15s', + }, + enabled: true, + actions: [], + throttle: null, + notifyWhen: 'onActiveAlert', + apiKeyOwner: 'elastic', + apiKey: + 'HvwrIJ8NBshJav9vf3BSEEa2P7fXLTpmEKAx2bSyBF51N2cadFkltWLRRcFnj65RXsPzvRm3VKzAde4b1iGzsjxY/IVmfGGyiO0rk6vZVJVLeMSD+CAiflnwweypoKM8WgwXJnI0Oa/SWqKMtrDiFxCcZCwIuAhS0sjenaiEuedbAuStZv513zz/clpqRKFXBydJXKyjJUQLTA==', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: '2021-10-05T19:52:25.865Z', + updatedAt: '2021-10-05T19:52:25.865Z', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastExecutionDate: '2021-10-05T19:52:51.260Z', + error: null, + }, + meta: { + versionApiKeyLastmodified: '7.14.2', + }, + scheduledTaskId: 'c4005e90-2615-11ec-811e-db7211397897', + legacyId: 'c364e1e0-2615-11ec-811e-db7211397897', + }, + type: 'alert', + references: [], + namespaces: [spaceId], + originId: 'c364e1e0-2615-11ec-811e-db7211397897', + migrationVersion: { + alert: '8.0.0', + }, + coreMigrationVersion: '8.0.0', + updated_at: '2021-10-05T19:52:56.014Z', + }, + }); + + // Now that we have a rule id and a legacy-url-alias with the same id, we should have a conflict + const conflictURL = `/s/${spaceId}${DETECTION_ENGINE_RULES_URL}?id=90e3ca0e-71f7-513a-b60a-ac678efd8887`; + const readRulesConflictRes = await supertest + .get(conflictURL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(readRulesConflictRes.body.outcome).to.eql('conflict'); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index ac27f06a149d9..eeae21c3b7bad 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -369,17 +369,31 @@ export const getSimpleRuleOutput = (ruleId = 'rule-1', enabled = false): Partial version: 1, }); +export const resolveSimpleRuleOutput = ( + ruleId = 'rule-1', + enabled = false +): Partial => ({ outcome: 'exactMatch', ...getSimpleRuleOutput(ruleId, enabled) }); + /** * This is the typical output of a simple rule that Kibana will output with all the defaults except * for all the server generated properties such as created_by. Useful for testing end to end tests. */ export const getSimpleRuleOutputWithoutRuleId = (ruleId = 'rule-1'): Partial => { const rule = getSimpleRuleOutput(ruleId); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { rule_id, ...ruleWithoutRuleId } = rule; + const { rule_id: rId, ...ruleWithoutRuleId } = rule; return ruleWithoutRuleId; }; +/** + * This is the typical output of a simple rule that Kibana will output with all the defaults except + * for all the server generated properties such as created_by. Useful for testing end to end tests. + */ +export const resolveSimpleRuleOutputWithoutRuleId = (ruleId = 'rule-1'): Partial => { + const rule = getSimpleRuleOutput(ruleId); + const { rule_id: rId, ...ruleWithoutRuleId } = rule; + return { outcome: 'exactMatch', ...ruleWithoutRuleId }; +}; + export const getSimpleMlRuleOutput = (ruleId = 'rule-1'): Partial => { const rule = getSimpleRuleOutput(ruleId); const { query, language, index, ...rest } = rule; @@ -399,12 +413,17 @@ export const getSimpleMlRuleOutput = (ruleId = 'rule-1'): Partial = * @param supertest The supertest agent. */ export const deleteAllAlerts = async ( - supertest: SuperTest.SuperTest + supertest: SuperTest.SuperTest, + space?: string ): Promise => { await countDownTest( async () => { const { body } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}/_find?per_page=9999`) + .get( + space + ? `/s/${space}${DETECTION_ENGINE_RULES_URL}/_find?per_page=9999` + : `${DETECTION_ENGINE_RULES_URL}/_find?per_page=9999` + ) .set('kbn-xsrf', 'true') .send(); @@ -413,7 +432,11 @@ export const deleteAllAlerts = async ( })); await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .post( + space + ? `/s/${space}${DETECTION_ENGINE_RULES_URL}/_bulk_delete` + : `${DETECTION_ENGINE_RULES_URL}/_bulk_delete` + ) .send(ids) .set('kbn-xsrf', 'true'); diff --git a/x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/data.json b/x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/data.json new file mode 100644 index 0000000000000..498367c913dc0 --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/data.json @@ -0,0 +1,101 @@ +{ + "type" : "doc", + "value": { + "index" : ".kibana_1", + "id" : "space:714-space", + "source" : { + "space" : { + "name" : "714-space", + "initials" : "t", + "color" : "#B9A888", + "disabledFeatures" : [ ], + "imageUrl" : "" + }, + "type" : "space", + "references" : [ ], + "migrationVersion" : { + "space" : "6.6.0" + }, + "updated_at" : "2021-10-11T14:49:07.012Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "714-space:alert:90e3ca0e-71f7-513a-b60a-ac678efd8887", + "index": ".kibana_1", + "source": { + "alert": { + "actions": [ + ], + "alertTypeId" : "siem.signals", + "consumer" : "siem", + "apiKey": "QIUT8u0/kbOakEHSj50jDpVR90MrqOxanEscboYOoa8PxQvcA5jfHash+fqH3b+KNjJ1LpnBcisGuPkufY9j1e32gKzwGZV5Bfys87imHvygJvIM8uKiFF8bQ8Y4NTaxOJO9fAmZPrFy07ZcQMCAQz+DUTgBFqs=", + "apiKeyOwner": "elastic", + "createdAt": "2020-06-17T15:35:38.497Z", + "createdBy": "elastic", + "enabled": true, + "muteAll": false, + "mutedInstanceIds": [ + ], + "name": "always-firing-alert", + "params":{ + "author": [], + "description": "test", + "ruleId": "82747bb8-bae0-4b59-8119-7f65ac564e14", + "falsePositives": [], + "from": "now-3615s", + "immutable": false, + "license": "", + "outputIndex": ".siem-signals-devin-hurley-714-space", + "meta": { + "from": "1h", + "kibana_siem_app_url": "http://0.0.0.0:5601/s/714-space/app/security" + }, + "maxSignals": 100, + "riskScore": 21, + "riskScoreMapping": [], + "severity": "low", + "severityMapping": [], + "threat": [], + "to": "now", + "references": [], + "version": 1, + "exceptionsList": [], + "type": "query", + "language": "kuery", + "index": [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "query": "*:*", + "filters": [] + }, + "schedule": { + "interval": "1m" + }, + "scheduledTaskId": "329798f0-b0b0-11ea-9510-fdf248d5f2a4", + "tags": [ + ], + "throttle": null, + "updatedBy": "elastic" + }, + "migrationVersion": { + "alert": "7.8.0" + }, + "references": [ + ], + "namespace": "714-space", + "type": "alert", + "updated_at": "2020-06-17T15:35:39.839Z" + } + } +} diff --git a/x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/mappings.json b/x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/mappings.json new file mode 100644 index 0000000000000..069f70badce4e --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/resolve_read_rules/7_14/mappings.json @@ -0,0 +1,397 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": {} + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "7b44fba6773e37c806ce290ea9b7024e", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3525d7c22c42bc80f5e6e9cb3f2b26a2", + "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1", + "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "cases": "32aa96a6d3855ddda53010ae2048ac22", + "cases-comments": "c2061fb929f585df57425102fa928b4b", + "cases-configure": "42711cbb311976c0687853f4c1354572", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "ae24d22d5986d04124cc6568f771066f", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", + "inventory-view": "88fc7e12fd1b45b6f0787323ce4f18d2", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "d33c68a69ff1e78c9888dedd2164ac22", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "maps-telemetry": "bfd39d88aadadb4be597ea984d433dbe", + "metrics-explorer-view": "428e319af3e822c80a84cf87123ca35c", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "todo": "082a2cc96a590268344d5cd74c159ac4", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "296a89039fc4260292be36b1b005d8f2", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "fcdb453a30092f022f2642db29523d80", + "url": "b675c3be8d76ecf029294d51dc7ec65d", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "legacyId": { + "type": "keyword" + }, + "createdBy": { + "type": "keyword" + }, + "updatedAt": { + "type": "date" + }, + "executionStatus": { + "properties": { + "error": { + "properties": { + "message": { + "type": "keyword" + }, + "reason": { + "type": "keyword" + } + } + }, + "lastExecutionDate": { + "type": "date" + }, + "status": { + "type": "keyword" + } + } + }, + "enabled": { + "type": "boolean" + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "params": { + "enabled": false, + "type": "object" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "legacy-url-alias": { + "properties": { + "sourceId": { + "type": "text" + }, + "targetNamespace": { + "type": "keyword" + }, + "targetType": { + "type": "keyword" + }, + "targetId": { + "type": "keyword" + }, + "resolveCounter": { + "type": "integer" + }, + "lastResolved": { + "type": "date" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "alert": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "config": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "coreMigrationVersion": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} From 80c152c0eb83c90a2292651b615c8ab005c348bd Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Tue, 12 Oct 2021 12:37:21 -0400 Subject: [PATCH 38/68] [Observability] [Exploratory View] add percentile ranks, show legend always, and fix field labels (#113765) * add percentile ranks, show legend always, and fix field labels * add 50th percentile * replace hard coded values with constant Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../configurations/constants/constants.ts | 16 ++++- .../configurations/lens_attributes.test.ts | 61 ++++++++++++++++++- .../configurations/lens_attributes.ts | 57 +++++++++++++++-- .../rum/kpi_over_time_config.ts | 2 + .../synthetics/kpi_over_time_config.ts | 12 +++- .../test_data/sample_attribute.ts | 1 + .../test_data/sample_attribute_cwv.ts | 1 + .../test_data/sample_attribute_kpi.ts | 1 + .../breakdown/breakdowns.test.tsx | 21 +++++++ .../series_editor/breakdown/breakdowns.tsx | 20 ++++-- .../expanded_series_row.test.tsx | 40 ++++++++++++ .../series_editor/expanded_series_row.tsx | 6 +- 12 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index 68dcd77e98990..e4473b183d729 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { OperationType } from '../../../../../../../lens/public'; import { ReportViewType } from '../../types'; import { CLS_FIELD, @@ -13,6 +13,7 @@ import { LCP_FIELD, TBT_FIELD, TRANSACTION_TIME_TO_FIRST_BYTE, + TRANSACTION_DURATION, } from './elasticsearch_fieldnames'; import { AGENT_HOST_LABEL, @@ -45,6 +46,8 @@ import { TBT_LABEL, URL_LABEL, BACKEND_TIME_LABEL, + MONITORS_DURATION_LABEL, + PAGE_LOAD_TIME_LABEL, LABELS_FIELD, } from './labels'; @@ -69,9 +72,11 @@ export const FieldLabels: Record = { [FID_FIELD]: FID_LABEL, [CLS_FIELD]: CLS_LABEL, [TRANSACTION_TIME_TO_FIRST_BYTE]: BACKEND_TIME_LABEL, + [TRANSACTION_DURATION]: PAGE_LOAD_TIME_LABEL, 'monitor.id': MONITOR_ID_LABEL, 'monitor.status': MONITOR_STATUS_LABEL, + 'monitor.duration.us': MONITORS_DURATION_LABEL, 'agent.hostname': AGENT_HOST_LABEL, 'host.hostname': HOST_NAME_LABEL, @@ -86,6 +91,7 @@ export const FieldLabels: Record = { 'performance.metric': METRIC_LABEL, 'Business.KPI': KPI_LABEL, 'http.request.method': REQUEST_METHOD, + percentile: 'Percentile', LABEL_FIELDS_FILTER: LABELS_FIELD, LABEL_FIELDS_BREAKDOWN: 'Labels field', }; @@ -114,8 +120,16 @@ export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN'; export const FILTER_RECORDS = 'FILTER_RECORDS'; export const TERMS_COLUMN = 'TERMS_COLUMN'; export const OPERATION_COLUMN = 'operation'; +export const PERCENTILE = 'percentile'; export const REPORT_METRIC_FIELD = 'REPORT_METRIC_FIELD'; +export const PERCENTILE_RANKS = [ + '99th' as OperationType, + '95th' as OperationType, + '90th' as OperationType, + '75th' as OperationType, + '50th' as OperationType, +]; export const LABEL_FIELDS_FILTER = 'LABEL_FIELDS_FILTER'; export const LABEL_FIELDS_BREAKDOWN = 'LABEL_FIELDS_BREAKDOWN'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 2781c26954234..139f9fe67c751 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -16,7 +16,7 @@ import { } from './constants/elasticsearch_fieldnames'; import { buildExistsFilter, buildPhrasesFilter } from './utils'; import { sampleAttributeKpi } from './test_data/sample_attribute_kpi'; -import { RECORDS_FIELD, REPORT_METRIC_FIELD, ReportTypes } from './constants'; +import { RECORDS_FIELD, REPORT_METRIC_FIELD, PERCENTILE_RANKS, ReportTypes } from './constants'; describe('Lens Attribute', () => { mockAppIndexPattern(); @@ -75,6 +75,63 @@ describe('Lens Attribute', () => { expect(lnsAttrKpi.getJSON()).toEqual(sampleAttributeKpi); }); + it('should return expected json for percentile breakdowns', function () { + const seriesConfigKpi = getDefaultConfigs({ + reportType: ReportTypes.KPI, + dataType: 'ux', + indexPattern: mockIndexPattern, + }); + + const lnsAttrKpi = new LensAttributes([ + { + filters: [], + seriesConfig: seriesConfigKpi, + time: { + from: 'now-1h', + to: 'now', + }, + indexPattern: mockIndexPattern, + name: 'ux-series-1', + breakdown: 'percentile', + reportDefinitions: {}, + selectedMetricField: 'transaction.duration.us', + color: '#54b399', + }, + ]); + + expect(lnsAttrKpi.getJSON().state.datasourceStates.indexpattern.layers.layer0.columns).toEqual({ + 'x-axis-column-layer0': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { + interval: 'auto', + }, + scale: 'interval', + sourceField: '@timestamp', + }, + ...PERCENTILE_RANKS.reduce((acc: Record, rank, index) => { + acc[`y-axis-column-${index === 0 ? 'layer' + index : index}`] = { + dataType: 'number', + filter: { + language: 'kuery', + query: 'transaction.type: page-load and processor.event: transaction', + }, + isBucketed: false, + label: `${rank} percentile of page load time`, + operationType: 'percentile', + params: { + percentile: Number(rank.slice(0, 2)), + }, + scale: 'ratio', + sourceField: 'transaction.duration.us', + }; + return acc; + }, {}), + }); + }); + it('should return main y axis', function () { expect(lnsAttr.getMainYAxis(layerConfig, 'layer0', '')).toEqual({ dataType: 'number', @@ -413,7 +470,7 @@ describe('Lens Attribute', () => { yConfig: [{ color: 'green', forAccessor: 'y-axis-column-layer0' }], }, ], - legend: { isVisible: true, position: 'right' }, + legend: { isVisible: true, showSingleSeries: true, position: 'right' }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, valueLabels: 'hide', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index fa5a8beb0087d..e3dab3c4e91f0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -37,6 +37,8 @@ import { REPORT_METRIC_FIELD, RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD, + PERCENTILE, + PERCENTILE_RANKS, ReportTypes, } from './constants'; import { ColumnFilter, SeriesConfig, UrlFilter, URLReportDefinition } from '../types'; @@ -249,6 +251,30 @@ export class LensAttributes { }; } + getPercentileBreakdowns( + layerConfig: LayerConfig, + columnFilter?: string + ): Record { + const yAxisColumns = layerConfig.seriesConfig.yAxisColumns; + const { sourceField: mainSourceField, label: mainLabel } = yAxisColumns[0]; + const lensColumns: Record = {}; + + // start at 1, because main y axis will have the first percentile breakdown + for (let i = 1; i < PERCENTILE_RANKS.length; i++) { + lensColumns[`y-axis-column-${i}`] = { + ...this.getColumnBasedOnType({ + sourceField: mainSourceField!, + operationType: PERCENTILE_RANKS[i], + label: mainLabel, + layerConfig, + colIndex: i, + }), + filter: { query: columnFilter || '', language: 'kuery' }, + }; + } + return lensColumns; + } + getPercentileNumberColumn( sourceField: string, percentileValue: string, @@ -258,7 +284,7 @@ export class LensAttributes { ...buildNumberColumn(sourceField), label: i18n.translate('xpack.observability.expView.columns.label', { defaultMessage: '{percentileValue} percentile of {sourceField}', - values: { sourceField: seriesConfig.labels[sourceField], percentileValue }, + values: { sourceField: seriesConfig.labels[sourceField]?.toLowerCase(), percentileValue }, }), operationType: 'percentile', params: { percentile: Number(percentileValue.split('th')[0]) }, @@ -328,6 +354,7 @@ export class LensAttributes { layerConfig: LayerConfig; colIndex?: number; }) { + const { breakdown, seriesConfig } = layerConfig; const { fieldMeta, columnType, fieldName, columnLabel, timeScale, columnFilters } = this.getFieldMeta(sourceField, layerConfig); @@ -348,6 +375,18 @@ export class LensAttributes { if (fieldType === 'date') { return this.getDateHistogramColumn(fieldName); } + + if (fieldType === 'number' && breakdown === PERCENTILE) { + return { + ...this.getPercentileNumberColumn( + fieldName, + operationType || PERCENTILE_RANKS[0], + seriesConfig! + ), + filter: colIndex !== undefined ? columnFilters?.[colIndex] : undefined, + }; + } + if (fieldType === 'number') { return this.getNumberColumn({ sourceField: fieldName, @@ -395,6 +434,7 @@ export class LensAttributes { } getMainYAxis(layerConfig: LayerConfig, layerId: string, columnFilter: string) { + const { breakdown } = layerConfig; const { sourceField, operationType, label } = layerConfig.seriesConfig.yAxisColumns[0]; if (sourceField === RECORDS_PERCENTAGE_FIELD) { @@ -407,7 +447,7 @@ export class LensAttributes { return this.getColumnBasedOnType({ sourceField, - operationType, + operationType: breakdown === PERCENTILE ? PERCENTILE_RANKS[0] : operationType, label, layerConfig, colIndex: 0, @@ -415,6 +455,7 @@ export class LensAttributes { } getChildYAxises(layerConfig: LayerConfig, layerId?: string, columnFilter?: string) { + const { breakdown } = layerConfig; const lensColumns: Record = {}; const yAxisColumns = layerConfig.seriesConfig.yAxisColumns; const { sourceField: mainSourceField, label: mainLabel } = yAxisColumns[0]; @@ -424,7 +465,10 @@ export class LensAttributes { .supportingColumns; } - // 1 means there is only main y axis + if (yAxisColumns.length === 1 && breakdown === PERCENTILE) { + return this.getPercentileBreakdowns(layerConfig, columnFilter); + } + if (yAxisColumns.length === 1) { return lensColumns; } @@ -574,7 +618,7 @@ export class LensAttributes { layers[layerId] = { columnOrder: [ `x-axis-column-${layerId}`, - ...(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN + ...(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN && breakdown !== PERCENTILE ? [`breakdown-column-${layerId}`] : []), `y-axis-column-${layerId}`, @@ -588,7 +632,7 @@ export class LensAttributes { filter: { query: columnFilter, language: 'kuery' }, ...(timeShift ? { timeShift } : {}), }, - ...(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN + ...(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN && breakdown !== PERCENTILE ? // do nothing since this will be used a x axis source { [`breakdown-column-${layerId}`]: this.getBreakdownColumn({ @@ -610,7 +654,7 @@ export class LensAttributes { getXyState(): XYState { return { - legend: { isVisible: true, position: 'right' }, + legend: { isVisible: true, showSingleSeries: true, position: 'right' }, valueLabels: 'hide', fittingFunction: 'Linear', curveType: 'CURVE_MONOTONE_X' as XYCurveType, @@ -636,6 +680,7 @@ export class LensAttributes { ], xAccessor: `x-axis-column-layer${index}`, ...(layerConfig.breakdown && + layerConfig.breakdown !== PERCENTILE && layerConfig.seriesConfig.xAxisColumn.sourceField !== USE_BREAK_DOWN_COLUMN ? { splitAccessor: `breakdown-column-layer${index}` } : {}), diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts index de4f6b2198dbd..000e50d7b3a52 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts @@ -13,6 +13,7 @@ import { OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD, + PERCENTILE, ReportTypes, } from '../constants'; import { buildPhraseFilter } from '../utils'; @@ -81,6 +82,7 @@ export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): SeriesCon USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE, + PERCENTILE, LABEL_FIELDS_BREAKDOWN, ], baseFilters: [ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts index 65b43a83a8fb5..6df9cdcd0503a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts @@ -6,7 +6,13 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, REPORT_METRIC_FIELD, ReportTypes } from '../constants'; +import { + FieldLabels, + OPERATION_COLUMN, + REPORT_METRIC_FIELD, + PERCENTILE, + ReportTypes, +} from '../constants'; import { CLS_LABEL, DCL_LABEL, @@ -44,7 +50,7 @@ export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesCon ], hasOperationType: false, filterFields: ['observer.geo.name', 'monitor.type', 'tags'], - breakdownFields: ['observer.geo.name', 'monitor.type', 'monitor.name'], + breakdownFields: ['observer.geo.name', 'monitor.type', 'monitor.name', PERCENTILE], baseFilters: [], palette: { type: 'palette', name: 'status' }, definitionFields: ['monitor.name', 'url.full'], @@ -98,6 +104,6 @@ export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesCon columnType: OPERATION_COLUMN, }, ], - labels: { ...FieldLabels }, + labels: { ...FieldLabels, [SUMMARY_UP]: UP_LABEL, [SUMMARY_DOWN]: DOWN_LABEL }, }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts index 7e0ea1e575481..8254a5a816921 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts @@ -187,6 +187,7 @@ export const sampleAttribute = { ], legend: { isVisible: true, + showSingleSeries: true, position: 'right', }, preferredSeriesType: 'line', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts index dff3d6b3ad5ef..adc6d4bb14462 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -134,6 +134,7 @@ export const sampleAttributeCoreWebVital = { ], legend: { isVisible: true, + showSingleSeries: true, position: 'right', }, preferredSeriesType: 'line', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts index 6ed9b4face6e3..8fbda9f6adc52 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts @@ -89,6 +89,7 @@ export const sampleAttributeKpi = { ], legend: { isVisible: true, + showSingleSeries: true, position: 'right', }, preferredSeriesType: 'line', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx index cb683119384d9..8ed279ace28f6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.test.tsx @@ -10,6 +10,7 @@ import { fireEvent, screen } from '@testing-library/react'; import { Breakdowns } from './breakdowns'; import { mockIndexPattern, mockUxSeries, render } from '../../rtl_helpers'; import { getDefaultConfigs } from '../../configurations/default_configs'; +import { RECORDS_FIELD } from '../../configurations/constants'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; describe('Breakdowns', function () { @@ -56,6 +57,26 @@ describe('Breakdowns', function () { expect(setSeries).toHaveBeenCalledTimes(1); }); + it('does not show percentile breakdown for records metrics', function () { + const kpiConfig = getDefaultConfigs({ + reportType: 'kpi-over-time', + indexPattern: mockIndexPattern, + dataType: 'ux', + }); + + render( + + ); + + fireEvent.click(screen.getByTestId('seriesBreakdown')); + + expect(screen.queryByText('Percentile')).not.toBeInTheDocument(); + }); + it('should disable breakdowns when a different series has a breakdown', function () { const initSeries = { data: [mockUxSeries, { ...mockUxSeries, breakdown: undefined }], diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx index 7964abdeeddc5..a235cbd8852ad 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/breakdown/breakdowns.tsx @@ -10,7 +10,12 @@ import styled from 'styled-components'; import { EuiSuperSelect, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { LABEL_FIELDS_BREAKDOWN, USE_BREAK_DOWN_COLUMN } from '../../configurations/constants'; +import { + LABEL_FIELDS_BREAKDOWN, + USE_BREAK_DOWN_COLUMN, + RECORDS_FIELD, + PERCENTILE, +} from '../../configurations/constants'; import { SeriesConfig, SeriesUrl } from '../../types'; interface Props { @@ -51,6 +56,7 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) { } const hasUseBreakdownColumn = seriesConfig.xAxisColumn.sourceField === USE_BREAK_DOWN_COLUMN; + const isRecordsMetric = series.selectedMetricField === RECORDS_FIELD; const items = seriesConfig.breakdownFields.map((breakdown) => ({ id: breakdown, @@ -64,11 +70,13 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) { }); } - const options = items.map(({ id, label }) => ({ - inputDisplay: label, - value: id, - dropdownDisplay: label, - })); + const options = items + .map(({ id, label }) => ({ + inputDisplay: label, + value: id, + dropdownDisplay: label, + })) + .filter(({ value }) => !(value === PERCENTILE && isRecordsMetric)); let valueOfSelected = selectedBreakdown || (hasUseBreakdownColumn ? options[0].value : NO_BREAKDOWN); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx new file mode 100644 index 0000000000000..83958840f63d9 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { ExpandedSeriesRow } from './expanded_series_row'; +import { mockIndexPattern, mockUxSeries, render } from '../rtl_helpers'; +import { getDefaultConfigs } from '../configurations/default_configs'; +import { PERCENTILE } from '../configurations/constants'; + +describe('ExpandedSeriesRow', function () { + const dataViewSeries = getDefaultConfigs({ + reportType: 'kpi-over-time', + indexPattern: mockIndexPattern, + dataType: 'ux', + }); + + it('should render properly', async function () { + render(); + + expect(screen.getByText('Breakdown by')).toBeInTheDocument(); + expect(screen.getByText('Operation')).toBeInTheDocument(); + }); + + it('should not display operation field when percentile breakdowns are applied', async function () { + render( + + ); + + expect(screen.queryByText('Operation')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx index ac71f4ff5abe0..180be1ac0414f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiHorizontalRule } from '@elastic/eui'; import { SeriesConfig, SeriesUrl } from '../types'; +import { PERCENTILE } from '../configurations/constants'; import { ReportDefinitionCol } from './columns/report_definition_col'; import { OperationTypeSelect } from './columns/operation_type_select'; import { parseCustomFieldName } from '../configurations/lens_attributes'; @@ -42,6 +43,9 @@ export function ExpandedSeriesRow(seriesProps: Props) { const columnType = getColumnType(seriesConfig, selectedMetricField); + // if the breakdown field is percentiles, we can't apply further operations + const hasPercentileBreakdown = series.breakdown === PERCENTILE; + return (
@@ -69,7 +73,7 @@ export function ExpandedSeriesRow(seriesProps: Props) { - {(hasOperationType || columnType === 'operation') && ( + {(hasOperationType || (columnType === 'operation' && !hasPercentileBreakdown)) && ( Date: Tue, 12 Oct 2021 12:39:43 -0400 Subject: [PATCH 39/68] [Uptime] [Synthetics Integration] add new advanced options (#112454) * refactor common fields * add ignore_https_errors and journey filters options * adjust formatters and normalizers * adjust content and hide fields when zip url is not defined * adjust content again * update tests * adjust tests * adjust tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../browser/advanced_fields.test.tsx | 45 ++++++- .../fleet_package/browser/advanced_fields.tsx | 102 +++++++++++++++- .../fleet_package/browser/formatters.ts | 5 + .../fleet_package/browser/normalizers.ts | 7 ++ .../fleet_package/browser/simple_fields.tsx | 93 +------------- .../fleet_package/common/common_fields.tsx | 113 ++++++++++++++++++ .../contexts/browser_context_advanced.tsx | 3 + ..._context.tsx => http_context_advanced.tsx} | 0 .../fleet_package/contexts/index.ts | 4 +- ...p_context.tsx => tcp_context_advanced.tsx} | 0 .../fleet_package/http/simple_fields.tsx | 92 +------------- .../fleet_package/icmp/simple_fields.tsx | 90 +------------- .../fleet_package/tcp/simple_fields.tsx | 93 +------------- .../public/components/fleet_package/types.tsx | 8 +- 14 files changed, 289 insertions(+), 366 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/common/common_fields.tsx rename x-pack/plugins/uptime/public/components/fleet_package/contexts/{advanced_fields_http_context.tsx => http_context_advanced.tsx} (100%) rename x-pack/plugins/uptime/public/components/fleet_package/contexts/{advanced_fields_tcp_context.tsx => tcp_context_advanced.tsx} (100%) diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.test.tsx index aa1f7ca07e3d8..fabf6da49cf47 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.test.tsx @@ -9,10 +9,12 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; import { render } from '../../../lib/helper/rtl_helpers'; import { BrowserAdvancedFields } from './advanced_fields'; -import { ConfigKeys, IBrowserAdvancedFields } from '../types'; +import { ConfigKeys, IBrowserAdvancedFields, IBrowserSimpleFields } from '../types'; import { BrowserAdvancedFieldsContextProvider, + BrowserSimpleFieldsContextProvider, defaultBrowserAdvancedFields as defaultConfig, + defaultBrowserSimpleFields, } from '../contexts'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ @@ -20,11 +22,19 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ })); describe('', () => { - const WrappedComponent = ({ defaultValues }: { defaultValues?: IBrowserAdvancedFields }) => { + const WrappedComponent = ({ + defaultValues = defaultConfig, + defaultSimpleFields = defaultBrowserSimpleFields, + }: { + defaultValues?: IBrowserAdvancedFields; + defaultSimpleFields?: IBrowserSimpleFields; + }) => { return ( - - - + + + + + ); }; @@ -46,4 +56,29 @@ describe('', () => { expect(screenshots.value).toEqual('off'); }); + + it('only displayed filter options when zip url is truthy', () => { + const { queryByText, getByText, rerender } = render(); + + expect( + queryByText( + /Use these options to apply the selected monitor settings to a subset of the tests in your suite./ + ) + ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( + getByText( + /Use these options to apply the selected monitor settings to a subset of the tests in your suite./ + ) + ).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx index 28e2e39c79554..61af9f8ec6143 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx @@ -10,13 +10,15 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiAccordion, EuiSelect, + EuiFieldText, + EuiCheckbox, EuiFormRow, EuiDescribedFormGroup, EuiSpacer, } from '@elastic/eui'; import { ComboBox } from '../combo_box'; -import { useBrowserAdvancedFieldsContext } from '../contexts'; +import { useBrowserAdvancedFieldsContext, useBrowserSimpleFieldsContext } from '../contexts'; import { ConfigKeys, ScreenshotOption } from '../types'; @@ -24,6 +26,7 @@ import { OptionalLabel } from '../optional_label'; export const BrowserAdvancedFields = () => { const { fields, setFields } = useBrowserAdvancedFieldsContext(); + const { fields: simpleFields } = useBrowserSimpleFieldsContext(); const handleInputChange = useCallback( ({ value, configKey }: { value: unknown; configKey: ConfigKeys }) => { @@ -39,6 +42,75 @@ export const BrowserAdvancedFields = () => { data-test-subj="syntheticsBrowserAdvancedFieldsAccordion" > + {simpleFields[ConfigKeys.SOURCE_ZIP_URL] && ( + + + + } + description={ + + } + > + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ + value: event.target.value, + configKey: ConfigKeys.JOURNEY_FILTERS_MATCH, + }) + } + data-test-subj="syntheticsBrowserJourneyFiltersMatch" + /> + + + } + labelAppend={} + helpText={ + + } + > + + handleInputChange({ value, configKey: ConfigKeys.JOURNEY_FILTERS_TAGS }) + } + data-test-subj="syntheticsBrowserJourneyFiltersTags" + /> + + + )} @@ -56,6 +128,34 @@ export const BrowserAdvancedFields = () => { } > + + + + } + data-test-subj="syntheticsBrowserIgnoreHttpsErrors" + > + + } + onChange={(event) => + handleInputChange({ + value: event.target.checked, + configKey: ConfigKeys.IGNORE_HTTPS_ERRORS, + }) + } + /> + arrayToJsonFormatter(fields[ConfigKeys.SYNTHETICS_ARGS]), + [ConfigKeys.JOURNEY_FILTERS_MATCH]: (fields) => + stringToJsonFormatter(fields[ConfigKeys.JOURNEY_FILTERS_MATCH]), + [ConfigKeys.JOURNEY_FILTERS_TAGS]: (fields) => + arrayToJsonFormatter(fields[ConfigKeys.JOURNEY_FILTERS_TAGS]), + [ConfigKeys.IGNORE_HTTPS_ERRORS]: null, ...commonFormatters, }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts index 53bbf611d490c..0107fb3884f41 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts @@ -39,5 +39,12 @@ export const browserNormalizers: BrowserNormalizerMap = { [ConfigKeys.PARAMS]: getBrowserNormalizer(ConfigKeys.PARAMS), [ConfigKeys.SCREENSHOTS]: getBrowserNormalizer(ConfigKeys.SCREENSHOTS), [ConfigKeys.SYNTHETICS_ARGS]: getBrowserJsonToJavascriptNormalizer(ConfigKeys.SYNTHETICS_ARGS), + [ConfigKeys.JOURNEY_FILTERS_MATCH]: getBrowserJsonToJavascriptNormalizer( + ConfigKeys.JOURNEY_FILTERS_MATCH + ), + [ConfigKeys.JOURNEY_FILTERS_TAGS]: getBrowserJsonToJavascriptNormalizer( + ConfigKeys.JOURNEY_FILTERS_TAGS + ), + [ConfigKeys.IGNORE_HTTPS_ERRORS]: getBrowserNormalizer(ConfigKeys.IGNORE_HTTPS_ERRORS), ...commonNormalizers, }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx index 0e2f10b96fe6d..7c7a6b199adcb 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx @@ -7,13 +7,12 @@ import React, { memo, useMemo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFormRow, EuiFieldText, EuiFieldNumber } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; import { ConfigKeys, Validation } from '../types'; import { useBrowserSimpleFieldsContext } from '../contexts'; -import { ComboBox } from '../combo_box'; -import { OptionalLabel } from '../optional_label'; import { ScheduleField } from '../schedule_field'; import { SourceField } from './source_field'; +import { CommonFields } from '../common/common_fields'; interface Props { validate: Validation; @@ -91,93 +90,7 @@ export const BrowserSimpleFields = memo(({ validate }) => { )} /> - - } - labelAppend={} - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.APM_SERVICE_NAME, - }) - } - data-test-subj="syntheticsAPMServiceName" - /> - - - } - isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} - error={ - parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( - - ) : ( - - ) - } - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.TIMEOUT, - }) - } - step={'any'} - /> - - - } - labelAppend={} - helpText={ - - } - > - handleInputChange({ value, configKey: ConfigKeys.TAGS })} - data-test-subj="syntheticsTags" - /> - + ); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/common_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/common/common_fields.tsx new file mode 100644 index 0000000000000..57d5094958ca3 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/common_fields.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiFieldText, EuiFieldNumber } from '@elastic/eui'; +import { ConfigKeys, Validation, ICommonFields } from '../types'; +import { ComboBox } from '../combo_box'; +import { OptionalLabel } from '../optional_label'; + +interface Props { + validate: Validation; + fields: ICommonFields; + onChange: ({ value, configKey }: { value: string | string[]; configKey: ConfigKeys }) => void; +} + +export function CommonFields({ fields, onChange, validate }: Props) { + return ( + <> + + } + labelAppend={} + helpText={ + + } + > + + onChange({ + value: event.target.value, + configKey: ConfigKeys.APM_SERVICE_NAME, + }) + } + data-test-subj="syntheticsAPMServiceName" + /> + + + } + isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} + error={ + parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( + + ) : ( + + ) + } + helpText={ + + } + > + + onChange({ + value: event.target.value, + configKey: ConfigKeys.TIMEOUT, + }) + } + step={'any'} + /> + + + } + labelAppend={} + helpText={ + + } + > + onChange({ value, configKey: ConfigKeys.TAGS })} + data-test-subj="syntheticsTags" + /> + + + ); +} diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context_advanced.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context_advanced.tsx index 3f3bb8f14c269..bc766462f18ae 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context_advanced.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context_advanced.tsx @@ -22,6 +22,9 @@ interface IBrowserAdvancedFieldsContextProvider { export const initialValues: IBrowserAdvancedFields = { [ConfigKeys.SCREENSHOTS]: ScreenshotOption.ON, [ConfigKeys.SYNTHETICS_ARGS]: [], + [ConfigKeys.JOURNEY_FILTERS_MATCH]: '', + [ConfigKeys.JOURNEY_FILTERS_TAGS]: [], + [ConfigKeys.IGNORE_HTTPS_ERRORS]: false, }; const defaultContext: IBrowserAdvancedFieldsContext = { diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/http_context_advanced.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_http_context.tsx rename to x-pack/plugins/uptime/public/components/fleet_package/contexts/http_context_advanced.tsx diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts index e955d2d7d4d50..4d76a6d8f8d67 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts @@ -21,7 +21,7 @@ export { HTTPAdvancedFieldsContextProvider, initialValues as defaultHTTPAdvancedFields, useHTTPAdvancedFieldsContext, -} from './advanced_fields_http_context'; +} from './http_context_advanced'; export { TCPSimpleFieldsContext, TCPSimpleFieldsContextProvider, @@ -39,7 +39,7 @@ export { TCPAdvancedFieldsContextProvider, initialValues as defaultTCPAdvancedFields, useTCPAdvancedFieldsContext, -} from './advanced_fields_tcp_context'; +} from './tcp_context_advanced'; export { BrowserSimpleFieldsContext, BrowserSimpleFieldsContextProvider, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_context_advanced.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/fleet_package/contexts/advanced_fields_tcp_context.tsx rename to x-pack/plugins/uptime/public/components/fleet_package/contexts/tcp_context_advanced.tsx diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx index c4de1d53fe998..90f94324fe657 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/http/simple_fields.tsx @@ -10,9 +10,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiFieldText, EuiFieldNumber } from '@elastic/eui'; import { ConfigKeys, Validation } from '../types'; import { useHTTPSimpleFieldsContext } from '../contexts'; -import { ComboBox } from '../combo_box'; import { OptionalLabel } from '../optional_label'; import { ScheduleField } from '../schedule_field'; +import { CommonFields } from '../common/common_fields'; interface Props { validate: Validation; @@ -50,7 +50,7 @@ export const HTTPSimpleFields = memo(({ validate }) => { /> (({ validate }) => { unit={fields[ConfigKeys.SCHEDULE].unit} /> - - } - labelAppend={} - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.APM_SERVICE_NAME, - }) - } - data-test-subj="syntheticsAPMServiceName" - /> - (({ validate }) => { } /> - - } - isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} - error={ - parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( - - ) : ( - - ) - } - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.TIMEOUT, - }) - } - step={'any'} - /> - - - } - labelAppend={} - helpText={ - - } - > - handleInputChange({ value, configKey: ConfigKeys.TAGS })} - data-test-subj="syntheticsTags" - /> - + ); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx index 92afe4c5072e1..32c843f1ce114 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/icmp/simple_fields.tsx @@ -10,9 +10,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiFieldText, EuiFieldNumber } from '@elastic/eui'; import { ConfigKeys, Validation } from '../types'; import { useICMPSimpleFieldsContext } from '../contexts'; -import { ComboBox } from '../combo_box'; import { OptionalLabel } from '../optional_label'; import { ScheduleField } from '../schedule_field'; +import { CommonFields } from '../common/common_fields'; interface Props { validate: Validation; @@ -113,93 +113,7 @@ export const ICMPSimpleFields = memo(({ validate }) => { step={'any'} /> - - } - labelAppend={} - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.APM_SERVICE_NAME, - }) - } - data-test-subj="syntheticsAPMServiceName" - /> - - - } - isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} - error={ - parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( - - ) : ( - - ) - } - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.TIMEOUT, - }) - } - step={'any'} - /> - - - } - labelAppend={} - helpText={ - - } - > - handleInputChange({ value, configKey: ConfigKeys.TAGS })} - data-test-subj="syntheticsTags" - /> - + ); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx index 37f0c82595e02..53a0074a47d73 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp/simple_fields.tsx @@ -7,12 +7,11 @@ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFormRow, EuiFieldText, EuiFieldNumber } from '@elastic/eui'; +import { EuiFormRow, EuiFieldText } from '@elastic/eui'; import { ConfigKeys, Validation } from '../types'; import { useTCPSimpleFieldsContext } from '../contexts'; -import { ComboBox } from '../combo_box'; -import { OptionalLabel } from '../optional_label'; import { ScheduleField } from '../schedule_field'; +import { CommonFields } from '../common/common_fields'; interface Props { validate: Validation; @@ -80,93 +79,7 @@ export const TCPSimpleFields = memo(({ validate }) => { unit={fields[ConfigKeys.SCHEDULE].unit} /> - - } - labelAppend={} - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.APM_SERVICE_NAME, - }) - } - data-test-subj="syntheticsAPMServiceName" - /> - - - } - isInvalid={!!validate[ConfigKeys.TIMEOUT]?.(fields)} - error={ - parseInt(fields[ConfigKeys.TIMEOUT], 10) < 0 ? ( - - ) : ( - - ) - } - helpText={ - - } - > - - handleInputChange({ - value: event.target.value, - configKey: ConfigKeys.TIMEOUT, - }) - } - step={'any'} - /> - - - } - labelAppend={} - helpText={ - - } - > - handleInputChange({ value, configKey: ConfigKeys.TAGS })} - data-test-subj="syntheticsTags" - /> - + ); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx index 89581bf993339..db736f1bae4d2 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx @@ -76,9 +76,13 @@ export enum ScreenshotOption { export enum ConfigKeys { APM_SERVICE_NAME = 'service.name', HOSTS = 'hosts', + IGNORE_HTTPS_ERRORS = 'ignore_https_errors', + JOURNEY_FILTERS_MATCH = 'filter_journeys.match', + JOURNEY_FILTERS_TAGS = 'filter_journeys.tags', MAX_REDIRECTS = 'max_redirects', MONITOR_TYPE = 'type', NAME = 'name', + PARAMS = 'params', PASSWORD = 'password', PROXY_URL = 'proxy_url', PROXY_USE_LOCAL_RESOLVER = 'proxy_use_local_resolver', @@ -101,7 +105,6 @@ export enum ConfigKeys { SOURCE_ZIP_PASSWORD = 'source.zip_url.password', SOURCE_ZIP_FOLDER = 'source.zip_url.folder', SYNTHETICS_ARGS = 'synthetics_args', - PARAMS = 'params', TLS_CERTIFICATE_AUTHORITIES = 'ssl.certificate_authorities', TLS_CERTIFICATE = 'ssl.certificate', TLS_KEY = 'ssl.key', @@ -198,6 +201,9 @@ export type IBrowserSimpleFields = { export interface IBrowserAdvancedFields { [ConfigKeys.SYNTHETICS_ARGS]: string[]; [ConfigKeys.SCREENSHOTS]: string; + [ConfigKeys.JOURNEY_FILTERS_MATCH]: string; + [ConfigKeys.JOURNEY_FILTERS_TAGS]: string[]; + [ConfigKeys.IGNORE_HTTPS_ERRORS]: boolean; } export type HTTPFields = IHTTPSimpleFields & IHTTPAdvancedFields & ITLSFields; From ec0fdee81451de6eea9327264acbff8a3abfdf0f Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Tue, 12 Oct 2021 12:28:14 -0500 Subject: [PATCH 40/68] [Workplace Search] Wire up write view for Sync Frequency (#114522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor to use correct kea way of doing reset The listener is not needed as the actions can set the state themselves * Wire up reset button for Frequency section * Extract shareble updateServerSettings method We use a PATCH call to the server to update the state. We can extract this to a shared actions.updateServerSettings method that can be used in the main toggle on the Synchronization landing page, the Frequency section and the Object and assets section. As a part of this commit, I refactored `updateSyncEnabled` to use this method as well. In doing so, to simplify things, I removed the granular enabled/disabled message in favor of the generic “"Source synchronization settings updated.” message that the other sections use. The state of the toggle is a good enough indication of the state the server is in. I also renamed the updateSyncSettings method to updateObjectsAndAssetsSettings, as it was incorrectly named. * Add schema for schedules * Use mutable schedule in component Originally used the immutable version directly on the content source for initial read view. We now use a mutable one found in SynchronizationLogic * Set local copies of schedule after persisting to server * Wire up form change handlers We pass the ‘type’ to the component to inform the logic file which section is being changed. We then update the mutable `schedule` reducer with the correct value * Wire up save button and persist changes * Add type and make server prop optional If there are no blciked windows, we send undefined, so the type has been updated. * Add unsaved changes propmpt Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../applications/workplace_search/types.ts | 2 + .../components/synchronization/frequency.tsx | 19 +- .../synchronization/frequency_item.test.tsx | 49 ++++++ .../synchronization/frequency_item.tsx | 20 ++- .../objects_and_assets.test.tsx | 4 +- .../synchronization/objects_and_assets.tsx | 8 +- .../sync_frequency_tab.test.tsx | 7 +- .../synchronization/sync_frequency_tab.tsx | 32 ++-- .../synchronization_logic.test.ts | 163 ++++++++++-------- .../synchronization/synchronization_logic.ts | 163 +++++++++++++----- .../views/content_sources/constants.ts | 14 -- .../server/routes/workplace_search/sources.ts | 4 + 12 files changed, 331 insertions(+), 154 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index f81672e71e013..72bcf850fbcd9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -148,6 +148,8 @@ export interface IndexingSchedule extends SyncIndexItem { blockedWindows?: BlockedWindow[]; } +export type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years'; + export type SyncJobType = 'full' | 'incremental' | 'delete' | 'permissions'; export const DAYS_OF_WEEK_VALUES = [ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx index 914ec9dfe6eff..2ada5b64be889 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency.tsx @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { SAVE_BUTTON_LABEL } from '../../../../../shared/constants'; +import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { NAV, RESET_BUTTON } from '../../../../constants'; import { DIFFERENT_SYNC_TYPES_DOCS_URL, SYNC_BEST_PRACTICES_DOCS_URL } from '../../../../routes'; @@ -30,6 +31,7 @@ import { BLOCKED_TIME_WINDOWS_TITLE, DIFFERENT_SYNC_TYPES_LINK_LABEL, SYNC_BEST_PRACTICES_LINK_LABEL, + SYNC_UNSAVED_CHANGES_MESSAGE, } from '../../constants'; import { SourceLogic } from '../../source_logic'; import { SourceLayout } from '../source_layout'; @@ -44,7 +46,10 @@ interface FrequencyProps { export const Frequency: React.FC = ({ tabId }) => { const { contentSource } = useValues(SourceLogic); - const { handleSelectedTabChanged } = useActions(SynchronizationLogic({ contentSource })); + const { hasUnsavedFrequencyChanges } = useValues(SynchronizationLogic({ contentSource })); + const { handleSelectedTabChanged, resetSyncSettings, updateFrequencySettings } = useActions( + SynchronizationLogic({ contentSource }) + ); const tabs = [ { @@ -66,10 +71,14 @@ export const Frequency: React.FC = ({ tabId }) => { const actions = ( - {RESET_BUTTON}{' '} + + {RESET_BUTTON} + - {SAVE_BUTTON_LABEL}{' '} + + {SAVE_BUTTON_LABEL} + ); @@ -98,6 +107,10 @@ export const Frequency: React.FC = ({ tabId }) => { pageViewTelemetry="source_synchronization_frequency" isLoading={false} > + { + const setSyncFrequency = jest.fn(); + const mockActions = { + setSyncFrequency, + }; + const mockValues = { + contentSource: fullContentSources[0], + }; + + beforeEach(() => { + setMockActions(mockActions); + setMockValues(mockValues); + }); + const estimate = { duration: 'PT3D', nextStart: '2021-09-27T21:39:24+00:00', @@ -22,6 +41,7 @@ describe('FrequencyItem', () => { }; const props = { + type: 'full' as SyncJobType, label: 'Item', description: 'My item', duration: 'PT2D', @@ -73,4 +93,33 @@ describe('FrequencyItem', () => { ).toEqual('in 2 days'); }); }); + + describe('onChange handlers', () => { + it('calls setSyncFrequency for "days"', () => { + const wrapper = shallow(); + wrapper + .find('[data-test-subj="durationDays"]') + .simulate('change', { target: { value: '3' } }); + + expect(setSyncFrequency).toHaveBeenCalledWith('full', '3', 'days'); + }); + + it('calls setSyncFrequency for "hours"', () => { + const wrapper = shallow(); + wrapper + .find('[data-test-subj="durationHours"]') + .simulate('change', { target: { value: '3' } }); + + expect(setSyncFrequency).toHaveBeenCalledWith('full', '3', 'hours'); + }); + + it('calls setSyncFrequency for "minutes"', () => { + const wrapper = shallow(); + wrapper + .find('[data-test-subj="durationMinutes"]') + .simulate('change', { target: { value: '3' } }); + + expect(setSyncFrequency).toHaveBeenCalledWith('full', '3', 'minutes'); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx index 618f5c73d6099..a51500e3076a8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/frequency_item.tsx @@ -7,6 +7,7 @@ import React from 'react'; +import { useActions, useValues } from 'kea'; import moment from 'moment'; import { @@ -24,18 +25,30 @@ import { HOURS_UNIT_LABEL, DAYS_UNIT_LABEL, } from '../../../../../shared/constants'; -import { SyncEstimate } from '../../../../types'; +import { SyncEstimate, SyncJobType } from '../../../../types'; import { NEXT_SYNC_RUNNING_MESSAGE } from '../../constants'; +import { SourceLogic } from '../../source_logic'; + +import { SynchronizationLogic } from './synchronization_logic'; interface Props { + type: SyncJobType; label: string; description: string; duration: string; estimate: SyncEstimate; } -export const FrequencyItem: React.FC = ({ label, description, duration, estimate }) => { +export const FrequencyItem: React.FC = ({ + type, + label, + description, + duration, + estimate, +}) => { + const { contentSource } = useValues(SourceLogic); + const { setSyncFrequency } = useActions(SynchronizationLogic({ contentSource })); const { lastRun, nextStart, duration: durationEstimate } = estimate; const estimateDisplay = durationEstimate && moment.duration(durationEstimate).humanize(); const nextStartIsPast = moment().isAfter(nextStart); @@ -107,6 +120,7 @@ export const FrequencyItem: React.FC = ({ label, description, duration, e data-test-subj="durationDays" value={moment.duration(duration).days()} append={DAYS_UNIT_LABEL} + onChange={(e) => setSyncFrequency(type, e.target.value, 'days')} /> @@ -114,6 +128,7 @@ export const FrequencyItem: React.FC = ({ label, description, duration, e data-test-subj="durationHours" value={moment.duration(duration).hours()} append={HOURS_UNIT_LABEL} + onChange={(e) => setSyncFrequency(type, e.target.value, 'hours')} /> @@ -121,6 +136,7 @@ export const FrequencyItem: React.FC = ({ label, description, duration, e data-test-subj="durationMinutes" value={moment.duration(duration).minutes()} append={MINUTES_UNIT_LABEL} + onChange={(e) => setSyncFrequency(type, e.target.value, 'minutes')} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.test.tsx index 42a08084db418..ee865e4c7c4ea 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.test.tsx @@ -21,14 +21,14 @@ import { ObjectsAndAssets } from './objects_and_assets'; describe('ObjectsAndAssets', () => { const setThumbnailsChecked = jest.fn(); const setContentExtractionChecked = jest.fn(); - const updateSyncSettings = jest.fn(); + const updateObjectsAndAssetsSettings = jest.fn(); const resetSyncSettings = jest.fn(); const contentSource = fullContentSources[0]; const mockActions = { setThumbnailsChecked, setContentExtractionChecked, - updateSyncSettings, + updateObjectsAndAssetsSettings, resetSyncSettings, }; const mockValues = { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx index 98abdb8bf67ea..d59c812d9ffa0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/objects_and_assets.tsx @@ -47,7 +47,7 @@ export const ObjectsAndAssets: React.FC = () => { const { setThumbnailsChecked, setContentExtractionChecked, - updateSyncSettings, + updateObjectsAndAssetsSettings, resetSyncSettings, } = useActions(SynchronizationLogic({ contentSource })); @@ -61,7 +61,11 @@ export const ObjectsAndAssets: React.FC = () => { - + {SAVE_BUTTON_LABEL} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.test.tsx index 6c382b0addab8..c85f2df1adb03 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.test.tsx @@ -22,9 +22,12 @@ describe('SyncFrequency', () => { const sourceWithNoDLP = cloneDeep(contentSource); sourceWithNoDLP.indexing.schedule.permissions = undefined as any; sourceWithNoDLP.indexing.schedule.estimates.permissions = undefined as any; + const { + indexing: { schedule }, + } = contentSource; it('renders with DLP', () => { - setMockValues({ contentSource }); + setMockValues({ contentSource, schedule }); const wrapper = shallow(); expect(wrapper.find(FrequencyItem)).toHaveLength(4); @@ -32,7 +35,7 @@ describe('SyncFrequency', () => { it('renders without DLP', () => { setMockValues({ - contentSource: sourceWithNoDLP, + schedule: sourceWithNoDLP.indexing.schedule, }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.tsx index 2a0ccb1fdb2c9..4e79f22977855 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/sync_frequency_tab.tsx @@ -24,31 +24,30 @@ import { import { SourceLogic } from '../../source_logic'; import { FrequencyItem } from './frequency_item'; +import { SynchronizationLogic } from './synchronization_logic'; export const SyncFrequency: React.FC = () => { + const { contentSource } = useValues(SourceLogic); const { - contentSource: { - indexing: { - schedule: { - full: fullDuration, - incremental: incrementalDuration, - delete: deleteDuration, - permissions: permissionsDuration, - estimates: { - full: fullEstimate, - incremental: incrementalEstimate, - delete: deleteEstimate, - permissions: permissionsEstimate, - }, - }, + schedule: { + full: fullDuration, + incremental: incrementalDuration, + delete: deleteDuration, + permissions: permissionsDuration, + estimates: { + full: fullEstimate, + incremental: incrementalEstimate, + delete: deleteEstimate, + permissions: permissionsEstimate, }, }, - } = useValues(SourceLogic); + } = useValues(SynchronizationLogic({ contentSource })); return ( <> { /> { /> { <> { const defaultValues = { navigatingBetweenTabs: false, hasUnsavedObjectsAndAssetsChanges: false, - hasUnsavedFrequencyChanges: true, + hasUnsavedFrequencyChanges: false, contentExtractionChecked: true, thumbnailsChecked: true, blockedWindows: [], @@ -83,6 +83,35 @@ describe('SynchronizationLogic', () => { expect(SynchronizationLogic.values.contentExtractionChecked).toEqual(false); }); + + it('resetSyncSettings', () => { + SynchronizationLogic.actions.setContentExtractionChecked(false); + SynchronizationLogic.actions.setThumbnailsChecked(false); + SynchronizationLogic.actions.resetSyncSettings(); + + expect(SynchronizationLogic.values.thumbnailsChecked).toEqual(true); + expect(SynchronizationLogic.values.contentExtractionChecked).toEqual(true); + }); + + describe('setSyncFrequency', () => { + it('sets "days"', () => { + SynchronizationLogic.actions.setSyncFrequency('full', '1', 'days'); + + expect(SynchronizationLogic.values.schedule.full).toEqual('P1D'); + }); + + it('sets "hours"', () => { + SynchronizationLogic.actions.setSyncFrequency('full', '10', 'hours'); + + expect(SynchronizationLogic.values.schedule.full).toEqual('P1DT10H'); + }); + + it('sets "minutes"', () => { + SynchronizationLogic.actions.setSyncFrequency('full', '30', 'minutes'); + + expect(SynchronizationLogic.values.schedule.full).toEqual('P1DT30M'); + }); + }); }); describe('listeners', () => { @@ -110,103 +139,91 @@ describe('SynchronizationLogic', () => { }); describe('updateSyncEnabled', () => { - it('calls API and sets values for false value', async () => { - const setContentSourceSpy = jest.spyOn(SourceLogic.actions, 'setContentSource'); - const promise = Promise.resolve(contentSource); - http.patch.mockReturnValue(promise); + it('calls updateServerSettings method', async () => { + const updateServerSettingsSpy = jest.spyOn( + SynchronizationLogic.actions, + 'updateServerSettings' + ); SynchronizationLogic.actions.updateSyncEnabled(false); - expect(http.patch).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/123/settings', - { - body: JSON.stringify({ - content_source: { - indexing: { enabled: false }, - }, - }), - } - ); - await promise; - expect(setContentSourceSpy).toHaveBeenCalledWith(contentSource); - expect(flashSuccessToast).toHaveBeenCalledWith('Source synchronization disabled.'); + expect(updateServerSettingsSpy).toHaveBeenCalledWith({ + content_source: { + indexing: { enabled: false }, + }, + }); }); + }); - it('calls API and sets values for true value', async () => { - const promise = Promise.resolve(contentSource); - http.patch.mockReturnValue(promise); - SynchronizationLogic.actions.updateSyncEnabled(true); - - expect(http.patch).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/123/settings', - { - body: JSON.stringify({ - content_source: { - indexing: { enabled: true }, - }, - }), - } + describe('updateObjectsAndAssetsSettings', () => { + it('calls updateServerSettings method', async () => { + const updateServerSettingsSpy = jest.spyOn( + SynchronizationLogic.actions, + 'updateServerSettings' ); - await promise; - expect(flashSuccessToast).toHaveBeenCalledWith('Source synchronization enabled.'); - }); - - it('handles error', async () => { - const error = { - response: { - error: 'this is an error', - status: 400, + SynchronizationLogic.actions.updateObjectsAndAssetsSettings(); + + expect(updateServerSettingsSpy).toHaveBeenCalledWith({ + content_source: { + indexing: { + features: { + content_extraction: { enabled: true }, + thumbnails: { enabled: true }, + }, + }, }, - }; - const promise = Promise.reject(error); - http.patch.mockReturnValue(promise); - SynchronizationLogic.actions.updateSyncEnabled(false); - await expectedAsyncError(promise); - - expect(flashAPIErrors).toHaveBeenCalledWith(error); + }); }); }); - describe('resetSyncSettings', () => { - it('calls methods', async () => { - const setThumbnailsCheckedSpy = jest.spyOn( + describe('updateFrequencySettings', () => { + it('calls updateServerSettings method', async () => { + const updateServerSettingsSpy = jest.spyOn( SynchronizationLogic.actions, - 'setThumbnailsChecked' + 'updateServerSettings' ); - const setContentExtractionCheckedSpy = jest.spyOn( - SynchronizationLogic.actions, - 'setContentExtractionChecked' - ); - SynchronizationLogic.actions.resetSyncSettings(); - - expect(setThumbnailsCheckedSpy).toHaveBeenCalledWith(true); - expect(setContentExtractionCheckedSpy).toHaveBeenCalledWith(true); + SynchronizationLogic.actions.updateFrequencySettings(); + + expect(updateServerSettingsSpy).toHaveBeenCalledWith({ + content_source: { + indexing: { + schedule: { + full: 'P1D', + incremental: 'PT2H', + delete: 'PT10M', + }, + }, + }, + }); }); }); - describe('updateSyncSettings', () => { + describe('updateServerSettings', () => { + const body = { + content_source: { + indexing: { + features: { + content_extraction: { enabled: true }, + thumbnails: { enabled: true }, + }, + }, + }, + }; it('calls API and sets values', async () => { const setContentSourceSpy = jest.spyOn(SourceLogic.actions, 'setContentSource'); + const setServerScheduleSpy = jest.spyOn(SynchronizationLogic.actions, 'setServerSchedule'); const promise = Promise.resolve(contentSource); http.patch.mockReturnValue(promise); - SynchronizationLogic.actions.updateSyncSettings(); + SynchronizationLogic.actions.updateServerSettings(body); expect(http.patch).toHaveBeenCalledWith( '/internal/workplace_search/org/sources/123/settings', { - body: JSON.stringify({ - content_source: { - indexing: { - features: { - content_extraction: { enabled: true }, - thumbnails: { enabled: true }, - }, - }, - }, - }), + body: JSON.stringify(body), } ); await promise; expect(setContentSourceSpy).toHaveBeenCalledWith(contentSource); + expect(setServerScheduleSpy).toHaveBeenCalledWith(contentSource.indexing.schedule); expect(flashSuccessToast).toHaveBeenCalledWith('Source synchronization settings updated.'); }); @@ -219,7 +236,7 @@ describe('SynchronizationLogic', () => { }; const promise = Promise.reject(error); http.patch.mockReturnValue(promise); - SynchronizationLogic.actions.updateSyncSettings(); + SynchronizationLogic.actions.updateServerSettings(body); await expectedAsyncError(promise); expect(flashAPIErrors).toHaveBeenCalledWith(error); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts index 3aaa7f5fdfbf3..95dbb8c75fce4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/synchronization/synchronization_logic.ts @@ -20,24 +20,54 @@ import { BLOCKED_TIME_WINDOWS_PATH, getContentSourcePath, } from '../../../../routes'; -import { BlockedWindow, IndexingSchedule } from '../../../../types'; +import { BlockedWindow, IndexingSchedule, SyncJobType, TimeUnit } from '../../../../types'; -import { - SYNC_ENABLED_MESSAGE, - SYNC_DISABLED_MESSAGE, - SYNC_SETTINGS_UPDATED_MESSAGE, -} from '../../constants'; +import { SYNC_SETTINGS_UPDATED_MESSAGE } from '../../constants'; import { SourceLogic } from '../../source_logic'; +interface ServerBlockedWindow { + job_type: string; + day: string; + start: string; + end: string; +} + +interface ServerSyncSettingsBody { + content_source: { + indexing: { + enabled?: boolean; + features?: { + content_extraction: { enabled: boolean }; + thumbnails: { enabled: boolean }; + }; + schedule?: { + full: string; + incremental: string; + delete: string; + permissions?: string; + blocked_windows?: ServerBlockedWindow[]; + }; + }; + }; +} + interface SynchronizationActions { setNavigatingBetweenTabs(navigatingBetweenTabs: boolean): boolean; handleSelectedTabChanged(tabId: TabId): TabId; addBlockedWindow(): void; - updateSyncSettings(): void; + updateFrequencySettings(): void; + updateObjectsAndAssetsSettings(): void; resetSyncSettings(): void; updateSyncEnabled(enabled: boolean): boolean; setThumbnailsChecked(checked: boolean): boolean; + setSyncFrequency( + type: SyncJobType, + value: string, + unit: TimeUnit + ): { type: SyncJobType; value: number; unit: TimeUnit }; setContentExtractionChecked(checked: boolean): boolean; + setServerSchedule(schedule: IndexingSchedule): IndexingSchedule; + updateServerSettings(body: ServerSyncSettingsBody): ServerSyncSettingsBody; } interface SynchronizationValues { @@ -67,8 +97,16 @@ export const SynchronizationLogic = kea< handleSelectedTabChanged: (tabId: TabId) => tabId, updateSyncEnabled: (enabled: boolean) => enabled, setThumbnailsChecked: (checked: boolean) => checked, + setSyncFrequency: (type: SyncJobType, value: string, unit: TimeUnit) => ({ + type, + value, + unit, + }), setContentExtractionChecked: (checked: boolean) => checked, - updateSyncSettings: true, + updateServerSettings: (body: ServerSyncSettingsBody) => body, + setServerSchedule: (schedule: IndexingSchedule) => schedule, + updateFrequencySettings: true, + updateObjectsAndAssetsSettings: true, resetSyncSettings: true, addBlockedWindow: true, }, @@ -89,16 +127,57 @@ export const SynchronizationLogic = kea< props.contentSource.indexing.features.thumbnails.enabled, { setThumbnailsChecked: (_, thumbnailsChecked) => thumbnailsChecked, + resetSyncSettings: () => props.contentSource.indexing.features.thumbnails.enabled, }, ], contentExtractionChecked: [ props.contentSource.indexing.features.contentExtraction.enabled, { setContentExtractionChecked: (_, contentExtractionChecked) => contentExtractionChecked, + resetSyncSettings: () => props.contentSource.indexing.features.contentExtraction.enabled, + }, + ], + cachedSchedule: [ + stripScheduleSeconds(props.contentSource.indexing.schedule), + { + setServerSchedule: (_, schedule) => schedule, + }, + ], + schedule: [ + stripScheduleSeconds(props.contentSource.indexing.schedule), + { + resetSyncSettings: () => stripScheduleSeconds(props.contentSource.indexing.schedule), + setServerSchedule: (_, schedule) => schedule, + setSyncFrequency: (state, { type, value, unit }) => { + let currentValue; + const schedule = cloneDeep(state); + const duration = schedule[type]; + + switch (unit) { + case 'days': + currentValue = moment.duration(duration).days(); + break; + case 'hours': + currentValue = moment.duration(duration).hours(); + break; + default: + currentValue = moment.duration(duration).minutes(); + break; + } + + // momentJS doesn't seem to have a way to simply set the minutes/hours/days, so we have + // to subtract the current value and then add the new value. + // https://momentjs.com/docs/#/durations/ + schedule[type] = moment + .duration(duration) + .subtract(currentValue, unit) + .add(value, unit) + .toISOString(); + + return schedule; + }, }, ], - cachedSchedule: [stripScheduleSeconds(props.contentSource.indexing.schedule)], - schedule: [stripScheduleSeconds(props.contentSource.indexing.schedule)], }), selectors: ({ selectors }) => ({ hasUnsavedObjectsAndAssetsChanges: [ @@ -125,7 +204,7 @@ export const SynchronizationLogic = kea< ], hasUnsavedFrequencyChanges: [ () => [selectors.cachedSchedule, selectors.schedule], - (cachedSchedule, schedule) => isEqual(cachedSchedule, schedule), + (cachedSchedule, schedule) => !isEqual(cachedSchedule, schedule), ], }), listeners: ({ actions, values, props }) => ({ @@ -149,46 +228,48 @@ export const SynchronizationLogic = kea< actions.setNavigatingBetweenTabs(false); }, updateSyncEnabled: async (enabled) => { - const { id: sourceId } = props.contentSource; - const route = `/internal/workplace_search/org/sources/${sourceId}/settings`; - const successMessage = enabled ? SYNC_ENABLED_MESSAGE : SYNC_DISABLED_MESSAGE; - - try { - const response = await HttpLogic.values.http.patch(route, { - body: JSON.stringify({ content_source: { indexing: { enabled } } }), - }); - - SourceLogic.actions.setContentSource(response); - flashSuccessToast(successMessage); - } catch (e) { - flashAPIErrors(e); - } + actions.updateServerSettings({ + content_source: { + indexing: { enabled }, + }, + }); }, - resetSyncSettings: () => { - actions.setThumbnailsChecked(props.contentSource.indexing.features.thumbnails.enabled); - actions.setContentExtractionChecked( - props.contentSource.indexing.features.contentExtraction.enabled - ); + updateObjectsAndAssetsSettings: () => { + actions.updateServerSettings({ + content_source: { + indexing: { + features: { + content_extraction: { enabled: values.contentExtractionChecked }, + thumbnails: { enabled: values.thumbnailsChecked }, + }, + }, + }, + }); + }, + updateFrequencySettings: () => { + actions.updateServerSettings({ + content_source: { + indexing: { + schedule: { + full: values.schedule.full, + incremental: values.schedule.incremental, + delete: values.schedule.delete, + }, + }, + }, + }); }, - updateSyncSettings: async () => { + updateServerSettings: async (body: ServerSyncSettingsBody) => { const { id: sourceId } = props.contentSource; const route = `/internal/workplace_search/org/sources/${sourceId}/settings`; try { const response = await HttpLogic.values.http.patch(route, { - body: JSON.stringify({ - content_source: { - indexing: { - features: { - content_extraction: { enabled: values.contentExtractionChecked }, - thumbnails: { enabled: values.thumbnailsChecked }, - }, - }, - }, - }), + body: JSON.stringify(body), }); SourceLogic.actions.setContentSource(response); + SynchronizationLogic.actions.setServerSchedule(response.indexing.schedule); flashSuccessToast(SYNC_SETTINGS_UPDATED_MESSAGE); } catch (e) { flashAPIErrors(e); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 1f76d667949fb..91e32834f3fbd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -705,20 +705,6 @@ export const BLOCKED_EMPTY_STATE_DESCRIPTION = i18n.translate( } ); -export const SYNC_ENABLED_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.syncEnabledMessage', - { - defaultMessage: 'Source synchronization enabled.', - } -); - -export const SYNC_DISABLED_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.syncDisabledMessage', - { - defaultMessage: 'Source synchronization disabled.', - } -); - export const SYNC_SETTINGS_UPDATED_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.syncSettingsUpdatedMessage', { diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 69a6470b5b9ce..660294a5e1ddd 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -79,6 +79,10 @@ const sourceSettingsSchema = schema.object({ ), schedule: schema.maybe( schema.object({ + full: schema.maybe(schema.string()), + incremental: schema.maybe(schema.string()), + delete: schema.maybe(schema.string()), + permissions: schema.maybe(schema.string()), blocked_windows: schema.maybe( schema.arrayOf( schema.object({ From 0fa95fdfcb3167d0fc29462684b84512f441f65c Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 12 Oct 2021 13:30:48 -0400 Subject: [PATCH 41/68] [Fleet] Handle existing default output in preconfiguration output service (#114631) --- .../server/services/preconfiguration.test.ts | 64 +++++++++++++++++++ .../fleet/server/services/preconfiguration.ts | 16 ++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index d0ae995358632..102b059515151 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -503,6 +503,8 @@ describe('output preconfiguration', () => { beforeEach(() => { mockedOutputService.create.mockReset(); mockedOutputService.update.mockReset(); + mockedOutputService.delete.mockReset(); + mockedOutputService.getDefaultOutputId.mockReset(); mockedOutputService.getDefaultESHosts.mockReturnValue(['http://default-es:9200']); mockedOutputService.bulkGet.mockImplementation(async (soClient, id): Promise => { return [ @@ -537,6 +539,26 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); }); + it('should delete existing default output if a new preconfigured output is added', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + mockedOutputService.getDefaultOutputId.mockResolvedValue('default-output-123'); + await ensurePreconfiguredOutputs(soClient, esClient, [ + { + id: 'non-existing-default-output-1', + name: 'Output 1', + type: 'elasticsearch', + is_default: true, + hosts: ['http://test.fr'], + }, + ]); + + expect(mockedOutputService.delete).toBeCalled(); + expect(mockedOutputService.create).toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + it('should set default hosts if hosts is not set output that does not exists', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -572,6 +594,48 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); }); + it('should delete default output if preconfigured output exists and another default output exists', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + mockedOutputService.getDefaultOutputId.mockResolvedValue('default-123'); + await ensurePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-output-1', + is_default: true, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://newhostichanged.co:9201'], // field that changed + }, + ]); + + expect(mockedOutputService.delete).toBeCalled(); + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + + it('should not delete default output if preconfigured default output exists and changed', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + mockedOutputService.getDefaultOutputId.mockResolvedValue('existing-output-1'); + await ensurePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-output-1', + is_default: true, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://newhostichanged.co:9201'], // field that changed + }, + ]); + + expect(mockedOutputService.delete).not.toBeCalled(); + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + const SCENARIOS: Array<{ name: string; data: PreconfiguredOutput }> = [ { name: 'no changes', diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index a444f8bdaa4da..a878af64aa05e 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -95,9 +95,21 @@ export async function ensurePreconfiguredOutputs( data.hosts = outputService.getDefaultESHosts(); } - if (!existingOutput) { + const isCreate = !existingOutput; + const isUpdateWithNewData = + existingOutput && isPreconfiguredOutputDifferentFromCurrent(existingOutput, data); + // If a default output already exists, delete it in favor of the preconfigured one + if (isCreate || isUpdateWithNewData) { + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + + if (defaultOutputId && defaultOutputId !== output.id) { + await outputService.delete(soClient, defaultOutputId); + } + } + + if (isCreate) { await outputService.create(soClient, data, { id, overwrite: true }); - } else if (isPreconfiguredOutputDifferentFromCurrent(existingOutput, data)) { + } else if (isUpdateWithNewData) { await outputService.update(soClient, id, data); // Bump revision of all policies using that output if (outputData.is_default) { From e9f0d7b9b466123da25dcbe5f5cbab063843f5db Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Tue, 12 Oct 2021 19:46:58 +0200 Subject: [PATCH 42/68] Short url docs (#113084) --- docs/api/short-urls.asciidoc | 9 ++ docs/api/short-urls/create-short-url.asciidoc | 86 +++++++++++++++++++ docs/api/short-urls/delete-short-url.asciidoc | 39 +++++++++ docs/api/short-urls/get-short-url.asciidoc | 56 ++++++++++++ .../api/short-urls/resolve-short-url.asciidoc | 56 ++++++++++++ docs/api/url-shortening.asciidoc | 62 ------------- docs/user/api.asciidoc | 16 ++-- 7 files changed, 254 insertions(+), 70 deletions(-) create mode 100644 docs/api/short-urls.asciidoc create mode 100644 docs/api/short-urls/create-short-url.asciidoc create mode 100644 docs/api/short-urls/delete-short-url.asciidoc create mode 100644 docs/api/short-urls/get-short-url.asciidoc create mode 100644 docs/api/short-urls/resolve-short-url.asciidoc delete mode 100644 docs/api/url-shortening.asciidoc diff --git a/docs/api/short-urls.asciidoc b/docs/api/short-urls.asciidoc new file mode 100644 index 0000000000000..ded639c897f3f --- /dev/null +++ b/docs/api/short-urls.asciidoc @@ -0,0 +1,9 @@ +[[short-urls-api]] +== Short URLs APIs + +Manage {kib} short URLs. + +include::short-urls/create-short-url.asciidoc[] +include::short-urls/get-short-url.asciidoc[] +include::short-urls/delete-short-url.asciidoc[] +include::short-urls/resolve-short-url.asciidoc[] diff --git a/docs/api/short-urls/create-short-url.asciidoc b/docs/api/short-urls/create-short-url.asciidoc new file mode 100644 index 0000000000000..a9138a4c555da --- /dev/null +++ b/docs/api/short-urls/create-short-url.asciidoc @@ -0,0 +1,86 @@ +[[short-urls-api-create]] +=== Create short URL API +++++ +Create short URL +++++ + +experimental[] Create a {kib} short URL. {kib} URLs may be long and cumbersome, short URLs are much +easier to remember and share. + +Short URLs are created by specifying the locator ID and locator parameters. When a short URL is +resolved, the locator ID and locator parameters are used to redirect user to the right {kib} page. + + +[[short-urls-api-create-request]] +==== Request + +`POST :/api/short_url` + + +[[short-urls-api-create-request-body]] +==== Request body + +`locatorId`:: + (Required, string) ID of the locator. + +`params`:: + (Required, object) Object which contains all necessary parameters for the given locator to resolve + to a {kib} location. ++ +WARNING: When you create a short URL, locator params are not validated, which allows you to pass +arbitrary and ill-formed data into the API that can break {kib}. Make sure +any data that you send to the API is properly formed. + +`slug`:: + (Optional, string) A custom short URL slug. Slug is the part of the short URL that identifies it. + You can provide a custom slug which consists of latin alphabet letters, numbers and `-._` + characters. The slug must be at least 3 characters long, but no longer than 255 characters. + +`humanReadableSlug`:: + (Optional, boolean) When the `slug` parameter is omitted, the API will generate a random + human-readable slug, if `humanReadableSlug` is set to `true`. + + +[[short-urls-api-create-response-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-create-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/short_url -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d ' +{ + "locatorId": "LOCATOR_ID", + "params": {}, + "humanReadableSlug": true +}' +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", <1> + "slug": "adjective-adjective-noun", <2> + "locator": { + "id": "LOCATOR_ID", + "version": "x.x.x", <3> + "state": {} <4> + }, + "accessCount": 0, + "accessDate": 1632680100000, + "createDate": 1632680100000 +} +-------------------------------------------------- + +<1> A random ID is automatically generated. +<2> A random human-readable slug is automatically generated if the `humanReadableSlug` parameter is set to `true`. If set to `false` a random short string is generated. +<3> The version of {kib} when short URL was created is stored. +<4> Locator params provided as `params` property are stored. diff --git a/docs/api/short-urls/delete-short-url.asciidoc b/docs/api/short-urls/delete-short-url.asciidoc new file mode 100644 index 0000000000000..f405ccf1a7472 --- /dev/null +++ b/docs/api/short-urls/delete-short-url.asciidoc @@ -0,0 +1,39 @@ +[[short-urls-api-delete]] +=== Delete short URL API +++++ +Delete short URL +++++ + +experimental[] Delete a {kib} short URL. + + +[[short-urls-api-delete-request]] +==== Request + +`DELETE :/api/short_url/` + + +[[short-urls-api-delete-path-params]] +==== Path parameters + +`id`:: + (Required, string) The short URL ID that you want to remove. + + +[[short-urls-api-delete-response-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-delete-example]] +==== Example + +Delete a short URL `12345` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X DELETE api/short_url/12345 +-------------------------------------------------- +// KIBANA diff --git a/docs/api/short-urls/get-short-url.asciidoc b/docs/api/short-urls/get-short-url.asciidoc new file mode 100644 index 0000000000000..bf4303d442fb0 --- /dev/null +++ b/docs/api/short-urls/get-short-url.asciidoc @@ -0,0 +1,56 @@ +[[short-urls-api-get]] +=== Get short URL API +++++ +Get short URL +++++ + +experimental[] Retrieve a single {kib} short URL. + +[[short-urls-api-get-request]] +==== Request + +`GET :/api/short_url/` + + +[[short-urls-api-get-params]] +==== Path parameters + +`id`:: + (Required, string) The ID of the short URL. + + +[[short-urls-api-get-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-get-example]] +==== Example + +Retrieve the short URL with the `12345` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/short_url/12345 +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "12345", + "slug": "adjective-adjective-noun", + "locator": { + "id": "LOCATOR_ID", + "version": "x.x.x", + "state": {} + }, + "accessCount": 0, + "accessDate": 1632680100000, + "createDate": 1632680100000 +} +-------------------------------------------------- diff --git a/docs/api/short-urls/resolve-short-url.asciidoc b/docs/api/short-urls/resolve-short-url.asciidoc new file mode 100644 index 0000000000000..32ad7ba7625c0 --- /dev/null +++ b/docs/api/short-urls/resolve-short-url.asciidoc @@ -0,0 +1,56 @@ +[[short-urls-api-resolve]] +=== Resolve short URL API +++++ +Resolve short URL +++++ + +experimental[] Resolve a single {kib} short URL by its slug. + +[[short-urls-api-resolve-request]] +==== Request + +`GET :/api/short_url/_slug/` + + +[[short-urls-api-resolve-params]] +==== Path parameters + +`slug`:: + (Required, string) The slug of the short URL. + + +[[short-urls-api-resolve-codes]] +==== Response code + +`200`:: + Indicates a successful call. + + +[[short-urls-api-resolve-example]] +==== Example + +Retrieve the short URL with the `hello-world` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/short_url/_slug/hello-world +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "12345", + "slug": "hello-world", + "locator": { + "id": "LOCATOR_ID", + "version": "x.x.x", + "state": {} + }, + "accessCount": 0, + "accessDate": 1632680100000, + "createDate": 1632680100000 +} +-------------------------------------------------- diff --git a/docs/api/url-shortening.asciidoc b/docs/api/url-shortening.asciidoc deleted file mode 100644 index ffe1d925e5dcb..0000000000000 --- a/docs/api/url-shortening.asciidoc +++ /dev/null @@ -1,62 +0,0 @@ -[[url-shortening-api]] -== Shorten URL API -++++ -Shorten URL -++++ - -experimental[] Convert a {kib} URL into a token. {kib} URLs contain the state of the application, which makes them long and cumbersome. -Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the {kib} URL. - -Short URLs are designed to make sharing {kib} URLs easier. - -[float] -[[url-shortening-api-request]] -=== Request - -`POST :/api/shorten_url` - -[float] -[[url-shortening-api-request-body]] -=== Request body - -`url`:: - (Required, string) The {kib} URL that you want to shorten, relative to `/app/kibana`. - -[float] -[[url-shortening-api-response-body]] -=== Response body - -urlId:: A top-level property that contains the shortened URL token for the provided request body. - -[float] -[[url-shortening-api-codes]] -=== Response code - -`200`:: - Indicates a successful call. - -[float] -[[url-shortening-api-example]] -=== Example - -[source,sh] --------------------------------------------------- -$ curl -X POST api/shorten_url -{ - "url": "/app/kibana#/dashboard?_g=()&_a=(description:'',filters:!(),fullScreenMode:!f,options:(hidePanelTitles:!f,useMargins:!t),panels:!((embeddableConfig:(),gridData:(h:15,i:'1',w:24,x:0,y:0),id:'8f4d0c00-4c86-11e8-b3d7-01146121b73d',panelIndex:'1',type:visualization,version:'7.0.0-alpha1')),query:(language:lucene,query:''),timeRestore:!f,title:'New%20Dashboard',viewMode:edit)" -} --------------------------------------------------- -// KIBANA - -The API returns the following: - -[source,sh] --------------------------------------------------- -{ - "urlId": "f73b295ff92718b26bc94edac766d8e3" -} --------------------------------------------------- - -For easy sharing, construct the shortened {kib} URL: - -`http://localhost:5601/goto/f73b295ff92718b26bc94edac766d8e3` diff --git a/docs/user/api.asciidoc b/docs/user/api.asciidoc index 12e200bb0ba27..e17d52675437e 100644 --- a/docs/user/api.asciidoc +++ b/docs/user/api.asciidoc @@ -1,8 +1,8 @@ [[api]] = REST API -Some {kib} features are provided via a REST API, which is ideal for creating an -integration with {kib}, or automating certain aspects of configuring and +Some {kib} features are provided via a REST API, which is ideal for creating an +integration with {kib}, or automating certain aspects of configuring and deploying {kib}. [float] @@ -18,15 +18,15 @@ NOTE: The {kib} Console supports only Elasticsearch APIs. You are unable to inte [float] [[api-authentication]] === Authentication -The {kib} APIs support key- and token-based authentication. +The {kib} APIs support key- and token-based authentication. [float] [[token-api-authentication]] ==== Token-based authentication -To use token-based authentication, you use the same username and password that you use to log into Elastic. -In a given HTTP tool, and when available, you can select to use its 'Basic Authentication' option, -which is where the username and password are stored in order to be passed as part of the call. +To use token-based authentication, you use the same username and password that you use to log into Elastic. +In a given HTTP tool, and when available, you can select to use its 'Basic Authentication' option, +which is where the username and password are stored in order to be passed as part of the call. [float] [[key-authentication]] @@ -65,7 +65,7 @@ For all APIs, you must use a request header. The {kib} APIs support the `kbn-xsr * XSRF protections are disabled using the <> setting `Content-Type: application/json`:: - Applicable only when you send a payload in the API request. {kib} API requests and responses use JSON. + Applicable only when you send a payload in the API request. {kib} API requests and responses use JSON. Typically, if you include the `kbn-xsrf` header, you must also include the `Content-Type` header. Request header example: @@ -97,6 +97,6 @@ include::{kib-repo-dir}/api/actions-and-connectors.asciidoc[] include::{kib-repo-dir}/api/dashboard-api.asciidoc[] include::{kib-repo-dir}/api/logstash-configuration-management.asciidoc[] include::{kib-repo-dir}/api/machine-learning.asciidoc[] -include::{kib-repo-dir}/api/url-shortening.asciidoc[] +include::{kib-repo-dir}/api/short-urls.asciidoc[] include::{kib-repo-dir}/api/task-manager/health.asciidoc[] include::{kib-repo-dir}/api/upgrade-assistant.asciidoc[] From b4010c86ad7787ee0df8e0a59ac46a5fc2063096 Mon Sep 17 00:00:00 2001 From: Kate Farrar Date: Tue, 12 Oct 2021 11:49:25 -0600 Subject: [PATCH 43/68] Move interval help text to be a tooltip on the datepicker (#108761) * moving interval help text as a tooltip on the datepicker * [Metrics UI] Refactor Snapshot data for use in FilterBar Co-authored-by: Chris Cowan --- .../inventory_view/components/filter_bar.tsx | 4 +- .../inventory_view/components/layout.tsx | 43 ++++++------------- .../inventory_view/components/layout_view.tsx | 12 +++++- .../components/snapshot_container.tsx | 43 +++++++++++++++++++ .../waffle/waffle_time_controls.tsx | 38 ++++++++++------ .../pages/metrics/inventory_view/index.tsx | 16 ++++++- 6 files changed, 106 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/components/snapshot_container.tsx diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/filter_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/filter_bar.tsx index f29f87191bc13..deef63892ac95 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/filter_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/filter_bar.tsx @@ -11,13 +11,13 @@ import React from 'react'; import { WaffleTimeControls } from './waffle/waffle_time_controls'; import { SearchBar } from './search_bar'; -export const FilterBar = () => ( +export const FilterBar = ({ interval }: { interval: string }) => ( - + ); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index f241f5d118147..de0a56c5be73d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -9,13 +9,12 @@ import React, { useCallback, useEffect, useState } from 'react'; import useInterval from 'react-use/lib/useInterval'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { SnapshotNode } from '../../../../../common/http_api'; import { SavedView } from '../../../../containers/saved_view/saved_view'; import { AutoSizer } from '../../../../components/auto_sizer'; -import { convertIntervalToString } from '../../../../utils/convert_interval_to_string'; import { NodesOverview } from './nodes_overview'; import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes'; import { PageContent } from '../../../../components/page'; -import { useSnapshot } from '../hooks/use_snaphot'; import { useWaffleTimeContext } from '../hooks/use_waffle_time'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; import { DEFAULT_LEGEND, useWaffleOptionsContext } from '../hooks/use_waffle_options'; @@ -24,30 +23,30 @@ import { InfraFormatterType } from '../../../../lib/lib'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { Toolbar } from './toolbars/toolbar'; import { ViewSwitcher } from './waffle/view_switcher'; -import { IntervalLabel } from './waffle/interval_label'; import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_formatter'; import { createLegend } from '../lib/create_legend'; import { useWaffleViewState } from '../hooks/use_waffle_view_state'; import { BottomDrawer } from './bottom_drawer'; import { Legend } from './waffle/legend'; +interface Props { + shouldLoadDefault: boolean; + currentView: SavedView | null; + reload: () => Promise; + interval: string; + nodes: SnapshotNode[]; + loading: boolean; +} + export const Layout = React.memo( - ({ - shouldLoadDefault, - currentView, - }: { - shouldLoadDefault: boolean; - currentView: SavedView | null; - }) => { + ({ shouldLoadDefault, currentView, reload, interval, nodes, loading }: Props) => { const [showLoading, setShowLoading] = useState(true); - const { sourceId, source } = useSourceContext(); + const { source } = useSourceContext(); const { metric, groupBy, sort, nodeType, - accountId, - region, changeView, view, autoBounds, @@ -55,19 +54,7 @@ export const Layout = React.memo( legend, } = useWaffleOptionsContext(); const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); - const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext(); - const { loading, nodes, reload, interval } = useSnapshot( - filterQueryAsJson, - [metric], - groupBy, - nodeType, - sourceId, - currentTime, - accountId, - region, - false - ); - + const { applyFilterQuery } = useWaffleFiltersContext(); const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; @@ -91,7 +78,6 @@ export const Layout = React.memo( isAutoReloading ? 5000 : null ); - const intervalAsString = convertIntervalToString(interval); const dataBounds = calculateBoundsFromNodes(nodes); const bounds = autoBounds ? dataBounds : boundsOverride; /* eslint-disable-next-line react-hooks/exhaustive-deps */ @@ -151,9 +137,6 @@ export const Layout = React.memo( gutterSize="m" > - - - diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout_view.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout_view.tsx index 1e66fe22ac45e..af9c9ab5e2b30 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout_view.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout_view.tsx @@ -6,10 +6,18 @@ */ import React from 'react'; +import { SnapshotNode } from '../../../../../common/http_api'; import { useSavedViewContext } from '../../../../containers/saved_view/saved_view'; import { Layout } from './layout'; -export const LayoutView = () => { +interface Props { + reload: () => Promise; + interval: string; + nodes: SnapshotNode[]; + loading: boolean; +} + +export const LayoutView = (props: Props) => { const { shouldLoadDefault, currentView } = useSavedViewContext(); - return ; + return ; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/snapshot_container.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/snapshot_container.tsx new file mode 100644 index 0000000000000..e64e79c99f6fc --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/snapshot_container.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSourceContext } from '../../../../containers/metrics_source'; +import { SnapshotNode } from '../../../../../common/http_api'; +import { useSnapshot } from '../hooks/use_snaphot'; +import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; +import { useWaffleOptionsContext } from '../hooks/use_waffle_options'; +import { useWaffleTimeContext } from '../hooks/use_waffle_time'; + +interface RenderProps { + reload: () => Promise; + interval: string; + nodes: SnapshotNode[]; + loading: boolean; +} + +interface Props { + render: React.FC; +} +export const SnapshotContainer = ({ render }: Props) => { + const { sourceId } = useSourceContext(); + const { metric, groupBy, nodeType, accountId, region } = useWaffleOptionsContext(); + const { currentTime } = useWaffleTimeContext(); + const { filterQueryAsJson } = useWaffleFiltersContext(); + const { loading, nodes, reload, interval } = useSnapshot( + filterQueryAsJson, + [metric], + groupBy, + nodeType, + sourceId, + currentTime, + accountId, + region, + false + ); + return render({ loading, nodes, reload, interval }); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_time_controls.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_time_controls.tsx index 39150a98c2e89..7ac618987b422 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_time_controls.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_time_controls.tsx @@ -5,22 +5,25 @@ * 2.0. */ -import { EuiButton, EuiDatePicker, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButton, EuiDatePicker, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import moment, { Moment } from 'moment'; import React, { useCallback } from 'react'; +import { convertIntervalToString } from '../../../../../utils/convert_interval_to_string'; import { withTheme, EuiTheme } from '../../../../../../../../../src/plugins/kibana_react/common'; import { useWaffleTimeContext } from '../../hooks/use_waffle_time'; interface Props { theme: EuiTheme | undefined; + interval: string; } -export const WaffleTimeControls = withTheme(({ theme }: Props) => { +export const WaffleTimeControls = withTheme(({ interval }: Props) => { const { currentTime, isAutoReloading, startAutoReload, stopAutoReload, jumpToTime } = useWaffleTimeContext(); const currentMoment = moment(currentTime); + const intervalAsString = convertIntervalToString(interval); const liveStreamingButton = isAutoReloading ? ( @@ -50,18 +53,25 @@ export const WaffleTimeControls = withTheme(({ theme }: Props) => { return ( - + + + {liveStreamingButton} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 67e39a11c12e7..87765088d6343 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -26,6 +26,7 @@ import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common' import { APP_WRAPPER_CLASS } from '../../../../../../../src/core/public'; import { inventoryTitle } from '../../../translations'; import { SavedViews } from './components/saved_views'; +import { SnapshotContainer } from './components/snapshot_container'; export const SnapshotPage = () => { const { @@ -78,8 +79,19 @@ export const SnapshotPage = () => { paddingSize: 'none', }} > - - + ( + <> + + + + )} + /> From 396ed0925929310c59694c8ab49b5d9a706cc148 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 12 Oct 2021 12:54:39 -0500 Subject: [PATCH 44/68] [Monitoring] Fix Set Up Monitoring redirect to setup mode (#114354) * [Monitoring] Fix Set Up Monitoring redirect to setup mode * Fix initt setup mode from route init --- .../pages/elasticsearch/elasticsearch_template.tsx | 6 +++--- .../plugins/monitoring/public/application/route_init.tsx | 6 +++--- .../monitoring/public/application/setup_mode/index.ts | 8 ++++++++ .../public/application/setup_mode/setup_mode.tsx | 4 ++-- 4 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/monitoring/public/application/setup_mode/index.ts diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/elasticsearch_template.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/elasticsearch_template.tsx index 13e21912df896..aa7ca97219206 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/elasticsearch_template.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/elasticsearch_template.tsx @@ -12,7 +12,7 @@ import { TabMenuItem, PageTemplateProps } from '../page_template'; import { ML_SUPPORTED_LICENSES } from '../../../../common/constants'; interface ElasticsearchTemplateProps extends PageTemplateProps { - cluster: any; + cluster?: any; } export const ElasticsearchTemplate: React.FC = ({ @@ -43,7 +43,7 @@ export const ElasticsearchTemplate: React.FC = ({ }, ]; - if (mlIsSupported(cluster.license)) { + if (cluster && mlIsSupported(cluster.license)) { tabs.push({ id: 'ml', label: i18n.translate('xpack.monitoring.esNavigation.jobsLinkText', { @@ -53,7 +53,7 @@ export const ElasticsearchTemplate: React.FC = ({ }); } - if (cluster.isCcrEnabled) { + if (cluster?.isCcrEnabled) { tabs.push({ id: 'ccr', label: i18n.translate('xpack.monitoring.esNavigation.ccrLinkText', { diff --git a/x-pack/plugins/monitoring/public/application/route_init.tsx b/x-pack/plugins/monitoring/public/application/route_init.tsx index 092b3f54036c9..c620229eb059a 100644 --- a/x-pack/plugins/monitoring/public/application/route_init.tsx +++ b/x-pack/plugins/monitoring/public/application/route_init.tsx @@ -9,6 +9,7 @@ import { Route, Redirect, useLocation } from 'react-router-dom'; import { useClusters } from './hooks/use_clusters'; import { GlobalStateContext } from './contexts/global_state_context'; import { getClusterFromClusters } from '../lib/get_cluster_from_clusters'; +import { isInSetupMode } from './setup_mode'; import { LoadingPage } from './pages/loading_page'; export interface ComponentProps { @@ -35,13 +36,12 @@ export const RouteInit: React.FC = ({ const { clusters, loaded } = useClusters(clusterUuid, undefined, codePaths); - // TODO: we will need this when setup mode is migrated - // const inSetupMode = isInSetupMode(); + const inSetupMode = isInSetupMode(undefined, globalState); const cluster = getClusterFromClusters(clusters, globalState, unsetGlobalState); // TODO: check for setupMode too when the setup mode is migrated - if (loaded && !cluster) { + if (loaded && !cluster && !inSetupMode) { return ; } diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/index.ts b/x-pack/plugins/monitoring/public/application/setup_mode/index.ts new file mode 100644 index 0000000000000..1bcdcdef09c28 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/setup_mode/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './setup_mode'; diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx index bfdf96ef5b2c1..828d5a2d20ae6 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx @@ -177,7 +177,7 @@ export const initSetupModeState = async ( } }; -export const isInSetupMode = (context?: ISetupModeContext) => { +export const isInSetupMode = (context?: ISetupModeContext, gState: GlobalState = globalState) => { if (context?.setupModeSupported === false) { return false; } @@ -185,7 +185,7 @@ export const isInSetupMode = (context?: ISetupModeContext) => { return true; } - return globalState.inSetupMode; + return gState.inSetupMode; }; export const isSetupModeFeatureEnabled = (feature: SetupModeFeature) => { From 7ffebf1fa31253d816b7715587267db896898258 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 12 Oct 2021 20:58:45 +0300 Subject: [PATCH 45/68] [Connectors] ServiceNow ITSM & SIR Application (#105440) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/developer/plugin-list.asciidoc | 3 +- docs/management/action-types.asciidoc | 6 +- .../action-types/servicenow-sir.asciidoc | 89 +++ .../action-types/servicenow.asciidoc | 14 +- .../images/servicenow-sir-params-test.png | Bin 190659 -> 46762 bytes docs/management/connectors/index.asciidoc | 1 + x-pack/plugins/actions/README.md | 105 ++- .../server/builtin_action_types/pagerduty.ts | 4 +- .../servicenow/api.test.ts | 50 ++ .../builtin_action_types/servicenow/api.ts | 11 +- .../servicenow/api_sir.test.ts | 286 +++++++ .../servicenow/api_sir.ts | 154 ++++ .../servicenow/config.test.ts | 40 + .../builtin_action_types/servicenow/config.ts | 37 + .../builtin_action_types/servicenow/index.ts | 56 +- .../builtin_action_types/servicenow/mocks.ts | 107 ++- .../builtin_action_types/servicenow/schema.ts | 23 +- .../servicenow/service.test.ts | 706 +++++++++++++----- .../servicenow/service.ts | 233 +++--- .../servicenow/service_sir.test.ts | 129 ++++ .../servicenow/service_sir.ts | 104 +++ .../builtin_action_types/servicenow/types.ts | 122 ++- .../servicenow/utils.test.ts | 84 +++ .../builtin_action_types/servicenow/utils.ts | 46 ++ .../actions/server/constants/connectors.ts | 12 + .../saved_objects/actions_migrations.test.ts | 57 ++ .../saved_objects/actions_migrations.ts | 37 +- x-pack/plugins/cases/README.md | 12 +- x-pack/plugins/cases/common/ui/types.ts | 3 + .../public/common/mock/register_connectors.ts | 27 + .../all_cases/all_cases_generic.test.tsx | 10 +- .../components/all_cases/columns.test.tsx | 10 +- .../components/all_cases/index.test.tsx | 10 +- .../configure_cases/connectors.test.tsx | 38 +- .../components/configure_cases/connectors.tsx | 13 +- .../connectors_dropdown.test.tsx | 81 +- .../configure_cases/connectors_dropdown.tsx | 20 +- .../configure_cases/translations.ts | 14 + .../components/connectors/card.test.tsx | 10 +- .../connectors/deprecated_callout.test.tsx | 32 + .../connectors/deprecated_callout.tsx | 42 ++ .../servicenow_itsm_case_fields.test.tsx | 13 +- .../servicenow_itsm_case_fields.tsx | 190 +++-- .../servicenow_sir_case_fields.test.tsx | 21 +- .../servicenow/servicenow_sir_case_fields.tsx | 237 +++--- .../connectors/servicenow/translations.ts | 8 +- .../connectors/servicenow/validator.test.ts | 37 + .../connectors/servicenow/validator.ts | 26 + .../components/create/connector.test.tsx | 10 +- .../plugins/cases/public/components/types.ts | 4 +- .../plugins/cases/public/components/utils.ts | 25 + .../cases/public/containers/configure/mock.ts | 10 + .../connectors/servicenow/itsm_format.test.ts | 9 +- .../connectors/servicenow/itsm_format.ts | 10 +- .../connectors/servicenow/sir_format.test.ts | 55 +- .../connectors/servicenow/sir_format.ts | 23 +- .../server/connectors/servicenow/types.ts | 19 +- .../security_solution/common/constants.ts | 1 + .../integration/cases/connectors.spec.ts | 13 +- .../security_solution/cypress/objects/case.ts | 16 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../email/email_connector.test.tsx | 18 + .../es_index/es_index_connector.test.tsx | 2 + .../jira/jira_connectors.test.tsx | 10 + .../pagerduty/pagerduty_connectors.test.tsx | 8 + .../resilient/resilient_connectors.test.tsx | 10 + .../servicenow/api.test.ts | 113 ++- .../builtin_action_types/servicenow/api.ts | 44 ++ .../application_required_callout.test.tsx | 30 + .../application_required_callout.tsx | 60 ++ .../builtin_action_types/servicenow/config.ts | 9 + .../servicenow/credentials.tsx | 191 +++++ .../servicenow/deprecated_callout.test.tsx | 35 + .../servicenow/deprecated_callout.tsx | 55 ++ .../servicenow/helpers.test.ts | 47 ++ .../servicenow/helpers.ts | 33 +- .../servicenow/installation_callout.test.tsx | 27 + .../servicenow/installation_callout.tsx | 32 + .../servicenow/servicenow.test.tsx | 3 + .../servicenow/servicenow.tsx | 1 + .../servicenow/servicenow_connectors.test.tsx | 13 +- .../servicenow/servicenow_connectors.tsx | 260 +++---- .../servicenow_itsm_params.test.tsx | 12 +- .../servicenow/servicenow_itsm_params.tsx | 81 +- .../servicenow/servicenow_sir_params.test.tsx | 12 +- .../servicenow/servicenow_sir_params.tsx | 63 +- .../servicenow/sn_store_button.test.tsx | 27 + .../servicenow/sn_store_button.tsx | 27 + .../servicenow/translations.ts | 135 +++- .../builtin_action_types/servicenow/types.ts | 15 + .../servicenow/update_connector_modal.tsx | 156 ++++ .../servicenow/use_get_app_info.test.tsx | 95 +++ .../servicenow/use_get_app_info.tsx | 69 ++ .../slack/slack_connectors.test.tsx | 8 + .../builtin_action_types/swimlane/api.test.ts | 25 +- .../swimlane/swimlane_connectors.test.tsx | 14 + .../teams/teams_connectors.test.tsx | 8 + .../webhook/webhook_connectors.test.tsx | 8 + .../action_connector_form.test.tsx | 2 + .../action_connector_form.tsx | 7 + .../action_connector_form/action_form.tsx | 14 +- .../connector_add_flyout.tsx | 15 + .../connector_add_modal.tsx | 14 + .../connector_edit_flyout.tsx | 36 +- .../components/actions_connectors_list.tsx | 31 + .../public/common/constants/index.ts | 2 - .../triggers_actions_ui/public/types.ts | 10 + .../uptime/public/state/api/alert_actions.ts | 2 + .../alerting_api_integration/common/config.ts | 1 + .../actions_simulators/server/plugin.ts | 13 +- .../server/servicenow_simulation.ts | 331 ++++---- .../server/swimlane_simulation.ts | 2 +- .../{servicenow.ts => servicenow_itsm.ts} | 196 +++-- .../builtin_action_types/servicenow_sir.ts | 544 ++++++++++++++ .../tests/actions/index.ts | 3 +- .../case_api_integration/common/lib/utils.ts | 24 +- .../tests/trial/cases/push_case.ts | 60 +- .../user_actions/get_all_user_actions.ts | 26 +- .../tests/trial/configure/get_configure.ts | 25 +- .../tests/trial/configure/get_connectors.ts | 6 +- .../tests/trial/configure/patch_configure.ts | 26 +- .../tests/trial/configure/post_configure.ts | 24 +- .../tests/trial/cases/push_case.ts | 31 +- .../tests/trial/cases/push_case.ts | 34 +- .../tests/trial/configure/get_configure.ts | 27 +- .../tests/trial/configure/get_connectors.ts | 6 +- .../tests/trial/configure/patch_configure.ts | 26 +- .../tests/trial/configure/post_configure.ts | 24 +- 129 files changed, 5592 insertions(+), 1293 deletions(-) create mode 100644 docs/management/connectors/action-types/servicenow-sir.asciidoc create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts create mode 100644 x-pack/plugins/actions/server/constants/connectors.ts create mode 100644 x-pack/plugins/cases/public/common/mock/register_connectors.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx rename x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/{servicenow.ts => servicenow_itsm.ts} (76%) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 0e728a4dada24..1ef00aa9de115 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -362,7 +362,8 @@ The plugin exposes the static DefaultEditorController class to consume. |{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases] -|Case management in Kibana +|[![Issues][issues-shield]][issues-url] +[![Pull Requests][pr-shield]][pr-url] |{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud] diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 92adbaf97d8c5..93d0ee3d2cab6 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -35,10 +35,14 @@ a| <> | Add a message to a Kibana log. -a| <> +a| <> | Create an incident in ServiceNow. +a| <> + +| Create a security incident in ServiceNow. + a| <> | Send a message to a Slack channel or user. diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc new file mode 100644 index 0000000000000..4556746284d5b --- /dev/null +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -0,0 +1,89 @@ +[role="xpack"] +[[servicenow-sir-action-type]] +=== ServiceNow connector and action +++++ +ServiceNow SecOps +++++ + +The ServiceNow SecOps connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow security incidents. + +[float] +[[servicenow-sir-connector-configuration]] +==== Connector configuration + +ServiceNow SecOps connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: ServiceNow instance URL. +Username:: Username for HTTP Basic authentication. +Password:: Password for HTTP Basic authentication. + +The ServiceNow user requires at minimum read, create, and update access to the Security Incident table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render. + +[float] +[[servicenow-sir-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-servicenow-sir-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-servicenow-sir: + name: preconfigured-servicenow-connector-type + actionTypeId: .servicenow-sir + config: + apiUrl: https://dev94428.service-now.com/ + secrets: + username: testuser + password: passwordkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`username`:: A string that corresponds to *Username*. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. + +[float] +[[define-servicenow-sir-ui]] +==== Define connector in Stack Management + +Define ServiceNow SecOps connector properties. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-connector.png[ServiceNow SecOps connector] + +Test ServiceNow SecOps action parameters. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow SecOps params test] + +[float] +[[servicenow-sir-action-configuration]] +==== Action configuration + +ServiceNow SecOps actions have the following configuration properties. + +Short description:: A short description for the incident, used for searching the contents of the knowledge base. +Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident. +Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident. +Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident. +Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident. +Priority:: The priority of the incident. +Category:: The category of the incident. +Subcategory:: The subcategory of the incident. +Description:: The details about the incident. +Additional comments:: Additional information for the client, such as how to troubleshoot the issue. + +[float] +[[configuring-servicenow-sir]] +==== Configure ServiceNow SecOps + +ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 3a4134cbf982e..cf5244a9e3f9e 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -2,16 +2,16 @@ [[servicenow-action-type]] === ServiceNow connector and action ++++ -ServiceNow +ServiceNow ITSM ++++ -The ServiceNow connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. +The ServiceNow ITSM connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. [float] [[servicenow-connector-configuration]] ==== Connector configuration -ServiceNow connectors have the following configuration properties. +ServiceNow ITSM connectors have the following configuration properties. Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. URL:: ServiceNow instance URL. @@ -55,12 +55,12 @@ Secrets defines sensitive information for the connector type. [[define-servicenow-ui]] ==== Define connector in Stack Management -Define ServiceNow connector properties. +Define ServiceNow ITSM connector properties. [role="screenshot"] image::management/connectors/images/servicenow-connector.png[ServiceNow connector] -Test ServiceNow action parameters. +Test ServiceNow ITSM action parameters. [role="screenshot"] image::management/connectors/images/servicenow-params-test.png[ServiceNow params test] @@ -69,11 +69,13 @@ image::management/connectors/images/servicenow-params-test.png[ServiceNow params [[servicenow-action-configuration]] ==== Action configuration -ServiceNow actions have the following configuration properties. +ServiceNow ITSM actions have the following configuration properties. Urgency:: The extent to which the incident resolution can delay. Severity:: The severity of the incident. Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question. +Category:: The category of the incident. +Subcategory:: The category of the incident. Short description:: A short description for the incident, used for searching the contents of the knowledge base. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/images/servicenow-sir-params-test.png b/docs/management/connectors/images/servicenow-sir-params-test.png index 16ea83c60b3c328f4580bdd08d4f2544e8c8cffc..80103a4272bfacd0a6ccb23bb138b98efe327f51 100644 GIT binary patch literal 46762 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ì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%0AwWoSpN;xE=Y5Ue-?=mI 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 diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 033b1c3ac150e..536d05705181d 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] +include::action-types/servicenow-sir.asciidoc[] include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index b19e89a599840..0d66c9d30f8b9 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -33,29 +33,36 @@ Table of Contents - [actionsClient.execute(options)](#actionsclientexecuteoptions) - [Example](#example-2) - [Built-in Action Types](#built-in-action-types) - - [ServiceNow](#servicenow) + - [ServiceNow ITSM](#servicenow-itsm) - [`params`](#params) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice) - [`subActionParams (getFields)`](#subactionparams-getfields) - [`subActionParams (getIncident)`](#subactionparams-getincident) - [`subActionParams (getChoices)`](#subactionparams-getchoices) - - [Jira](#jira) + - [ServiceNow Sec Ops](#servicenow-sec-ops) - [`params`](#params-1) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [`subActionParams (getFields)`](#subactionparams-getfields-1) - [`subActionParams (getIncident)`](#subactionparams-getincident-1) + - [`subActionParams (getChoices)`](#subactionparams-getchoices-1) + - [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-) + - [Jira](#jira) + - [`params`](#params-2) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) + - [`subActionParams (getIncident)`](#subactionparams-getincident-2) - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) - [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype) - [`subActionParams (issues)`](#subactionparams-issues) - [`subActionParams (issue)`](#subactionparams-issue) - - [`subActionParams (getFields)`](#subactionparams-getfields-1) - - [IBM Resilient](#ibm-resilient) - - [`params`](#params-2) - - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [`subActionParams (getFields)`](#subactionparams-getfields-2) + - [IBM Resilient](#ibm-resilient) + - [`params`](#params-3) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3) + - [`subActionParams (getFields)`](#subactionparams-getfields-3) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) - [Swimlane](#swimlane) - - [`params`](#params-3) + - [`params`](#params-4) - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -246,9 +253,9 @@ Kibana ships with a set of built-in action types. See [Actions and connector typ In addition to the documented configurations, several built in action type offer additional `params` configurations. -## ServiceNow +## ServiceNow ITSM -The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. +The [ServiceNow ITSM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. ### `params` | Property | Description | Type | @@ -265,16 +272,18 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | -| short_description | The title of the incident. | string | -| description | The description of the incident. | string _(optional)_ | -| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| severity | The severity in ServiceNow. | string _(optional)_ | -| urgency | The urgency in ServiceNow. | string _(optional)_ | -| impact | The impact in ServiceNow. | string _(optional)_ | -| category | The category in ServiceNow. | string _(optional)_ | -| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| Property | Description | Type | +| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | +| short_description | The title of the incident. | string | +| description | The description of the incident. | string _(optional)_ | +| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | +| severity | The severity in ServiceNow. | string _(optional)_ | +| urgency | The urgency in ServiceNow. | string _(optional)_ | +| impact | The impact in ServiceNow. | string _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the incident. | string _(optional)_ | +| correlation_display | The correlation display of the ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` @@ -289,12 +298,64 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`. #### `subActionParams (getChoices)` -| Property | Description | Type | -| -------- | ------------------------------------------------------------ | -------- | -| fields | An array of fields. Example: `[priority, category, impact]`. | string[] | +| Property | Description | Type | +| -------- | -------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[category, impact]`. | string[] | --- +## ServiceNow Sec Ops + +The [ServiceNow SecOps user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-sir-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. + +### `params` + +| Property | Description | Type | +| --------------- | -------------------------------------------------------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices`. | string | +| subActionParams | The parameters of the subaction. | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- | +| incident | The ServiceNow security incident. | object | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ | + +The following table describes the properties of the `incident` object. + +| Property | Description | Type | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| short_description | The title of the security incident. | string | +| description | The description of the security incident. | string _(optional)_ | +| externalId | The ID of the security incident in ServiceNow. If present, the security incident is updated. Otherwise, a new security incident is created. | string _(optional)_ | +| priority | The priority in ServiceNow. | string _(optional)_ | +| dest_ip | A list of destination IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| source_ip | A list of source IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_hash | A list of malware hashes related to the security incident. The hashes will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_url | A list of malware URLs related to the security incident. The URLs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the security incident. | string _(optional)_ | +| correlation_display | The correlation display of the security incident. | string _(optional)_ | + +#### `subActionParams (getFields)` + +No parameters for the `getFields` subaction. Provide an empty object `{}`. + +#### `subActionParams (getIncident)` + +| Property | Description | Type | +| ---------- | ---------------------------------------------- | ------ | +| externalId | The ID of the security incident in ServiceNow. | string | + + +#### `subActionParams (getChoices)` + +| Property | Description | Type | +| -------- | ---------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[priority, category]`. | string[] | +--- ## Jira The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/master/jira-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index 5d83b658111e4..7710ff79d08b4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -143,7 +143,7 @@ export function getActionType({ }), validate: { config: schema.object(configSchemaProps, { - validate: curry(valdiateActionTypeConfig)(configurationUtilities), + validate: curry(validateActionTypeConfig)(configurationUtilities), }), secrets: SecretsSchema, params: ParamsSchema, @@ -152,7 +152,7 @@ export function getActionType({ }; } -function valdiateActionTypeConfig( +function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType ) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 8d24e48d4d515..e1f66263729e2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -25,6 +25,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -57,6 +58,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -78,6 +80,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, commentFieldKey: 'comments', @@ -93,6 +96,9 @@ describe('api', () => { caller_id: 'elastic', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', + opened_by: 'elastic', }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); @@ -103,6 +109,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -118,6 +125,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -132,6 +141,8 @@ describe('api', () => { comments: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -142,6 +153,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -157,6 +169,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -171,6 +185,8 @@ describe('api', () => { work_notes: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -182,6 +198,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params: apiParams, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -210,6 +227,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -228,6 +246,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -243,6 +262,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -253,6 +274,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -267,6 +289,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -281,6 +305,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); @@ -291,6 +317,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -305,6 +332,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -319,6 +348,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); @@ -344,4 +375,23 @@ describe('api', () => { expect(res).toEqual(serviceNowChoices); }); }); + + describe('getIncident', () => { + test('it gets the incident correctly', async () => { + const res = await api.getIncident({ + externalService, + params: { + externalId: 'incident-1', + }, + }); + expect(res).toEqual({ + description: 'description from servicenow', + id: 'incident-1', + pushedDate: '2020-03-10T12:24:20.000Z', + short_description: 'title from servicenow', + title: 'INC01', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 4120c07c32303..88cdfd069cf1b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -6,7 +6,7 @@ */ import { - ExternalServiceApi, + ExternalServiceAPI, GetChoicesHandlerArgs, GetChoicesResponse, GetCommonFieldsHandlerArgs, @@ -19,7 +19,11 @@ import { } from './types'; const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; -const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {}; +const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => { + const { externalId: id } = params; + const res = await externalService.getIncident(id); + return res; +}; const pushToServiceHandler = async ({ externalService, @@ -42,6 +46,7 @@ const pushToServiceHandler = async ({ incident: { ...incident, caller_id: secrets.username, + opened_by: secrets.username, }, }); } @@ -84,7 +89,7 @@ const getChoicesHandler = async ({ return res; }; -export const api: ExternalServiceApi = { +export const api: ExternalServiceAPI = { getChoices: getChoicesHandler, getFields: getFieldsHandler, getIncident: getIncidentHandler, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts new file mode 100644 index 0000000000000..358af7cd2e9ef --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts @@ -0,0 +1,286 @@ +/* + * 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 { Logger } from '../../../../../../src/core/server'; +import { externalServiceSIRMock, sirParams } from './mocks'; +import { ExternalServiceSIR, ObservableTypes } from './types'; +import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir'; +let mockedLogger: jest.Mocked; + +describe('api_sir', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceSIRMock.create(); + jest.clearAllMocks(); + }); + + describe('combineObservables', () => { + test('it returns an empty array when both arguments are an empty array', async () => { + expect(combineObservables([], [])).toEqual([]); + }); + + test('it returns an empty array when both arguments are an empty string', async () => { + expect(combineObservables('', '')).toEqual([]); + }); + + test('it returns an empty array when a="" and b=[]', async () => { + expect(combineObservables('', [])).toEqual([]); + }); + + test('it returns an empty array when a=[] and b=""', async () => { + expect(combineObservables([], '')).toEqual([]); + }); + + test('it returns a if b is empty', async () => { + expect(combineObservables('a', '')).toEqual(['a']); + }); + + test('it returns b if a is empty', async () => { + expect(combineObservables([], ['b'])).toEqual(['b']); + }); + + test('it combines two strings', async () => { + expect(combineObservables('a,b', 'c,d')).toEqual(['a', 'b', 'c', 'd']); + }); + + test('it combines two arrays', async () => { + expect(combineObservables(['a'], ['b'])).toEqual(['a', 'b']); + }); + + test('it combines a string with an array', async () => { + expect(combineObservables('a', ['b'])).toEqual(['a', 'b']); + }); + + test('it combines an array with a string ', async () => { + expect(combineObservables(['a'], 'b')).toEqual(['a', 'b']); + }); + + test('it combines a "," concatenated string', async () => { + expect(combineObservables(['a'], 'b,c,d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b,c,d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "|" concatenated string', async () => { + expect(combineObservables(['a'], 'b|c|d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b|c|d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a space concatenated string', async () => { + expect(combineObservables(['a'], 'b c d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b c d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\n" concatenated string', async () => { + expect(combineObservables(['a'], 'b\nc\nd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\nc\nd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\r" concatenated string', async () => { + expect(combineObservables(['a'], 'b\rc\rd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\rc\rd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\t" concatenated string', async () => { + expect(combineObservables(['a'], 'b\tc\td')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\tc\td', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines two strings with different delimiter', async () => { + expect(combineObservables('a|b|c', 'd e f')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); + }); + }); + + describe('formatObservables', () => { + test('it formats array observables correctly', async () => { + const expectedTypes: Array<[ObservableTypes, string]> = [ + [ObservableTypes.ip4, 'ipv4-addr'], + [ObservableTypes.sha256, 'SHA256'], + [ObservableTypes.url, 'URL'], + ]; + + for (const type of expectedTypes) { + expect(formatObservables(['a', 'b', 'c'], type[0])).toEqual([ + { type: type[1], value: 'a' }, + { type: type[1], value: 'b' }, + { type: type[1], value: 'c' }, + ]); + } + }); + + test('it removes duplicates from array observables correctly', async () => { + expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it formats an empty array correctly', async () => { + expect(formatObservables([], ObservableTypes.ip4)).toEqual([]); + }); + + test('it removes empty observables correctly', async () => { + expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + }); + + describe('prepareParams', () => { + test('it prepares the params correctly when the connector is legacy', async () => { + expect(prepareParams(true, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: '192.168.1.1,192.168.1.3', + source_ip: '192.168.1.2,192.168.1.4', + malware_hash: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + malware_url: 'https://example.com', + }, + }); + }); + + test('it prepares the params correctly when the connector is not legacy', async () => { + expect(prepareParams(false, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + + test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => { + const { + dest_ip: destIp, + source_ip: sourceIp, + malware_hash: malwareHash, + malware_url: malwareURL, + ...incidentWithoutObservables + } = sirParams.incident; + + expect( + prepareParams(true, { + ...sirParams, + // @ts-expect-error + incident: incidentWithoutObservables, + }) + ).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + }); + + describe('pushToService', () => { + test('it creates an incident correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + const res = await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('it adds observables correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).toHaveBeenCalledWith( + [ + { type: 'ipv4-addr', value: '192.168.1.1' }, + { type: 'ipv4-addr', value: '192.168.1.3' }, + { type: 'ipv4-addr', value: '192.168.1.2' }, + { type: 'ipv4-addr', value: '192.168.1.4' }, + { + type: 'SHA256', + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + }, + { type: 'URL', value: 'https://example.com' }, + ], + // createIncident mock returns this incident id + 'incident-1' + ); + }); + + test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: true }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + + test('it does not call bulkAddObservableToIncident if there are no observables', async () => { + const params = { + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + externalId: null, + }, + }; + + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts new file mode 100644 index 0000000000000..326bb79a0e708 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty, isString } from 'lodash'; + +import { + ExecutorSubActionPushParamsSIR, + ExternalServiceAPI, + ExternalServiceSIR, + ObservableTypes, + PushToServiceApiHandlerArgs, + PushToServiceApiParamsSIR, + PushToServiceResponse, +} from './types'; + +import { api } from './api'; + +const SPLIT_REGEX = /[ ,|\r\n\t]+/; + +export const formatObservables = (observables: string[], type: ObservableTypes) => { + /** + * ServiceNow accepted formats are: comma, new line, tab, or pipe separators. + * Before the application the observables were being sent to ServiceNow as a concatenated string with + * delimiter. With the application the format changed to an array of observables. + */ + const uniqueObservables = new Set(observables); + return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type })); +}; + +const obsAsArray = (obs: string | string[]): string[] => { + if (isEmpty(obs)) { + return []; + } + + if (isString(obs)) { + return obs.split(SPLIT_REGEX); + } + + return obs; +}; + +export const combineObservables = (a: string | string[], b: string | string[]): string[] => { + const first = obsAsArray(a); + const second = obsAsArray(b); + + return [...first, ...second]; +}; + +const observablesToString = (obs: string | string[] | null | undefined): string | null => { + if (Array.isArray(obs)) { + return obs.join(','); + } + + return obs ?? null; +}; + +export const prepareParams = ( + isLegacy: boolean, + params: PushToServiceApiParamsSIR +): PushToServiceApiParamsSIR => { + if (isLegacy) { + /** + * The schema has change to accept an array of observables + * or a string. In the case of a legacy connector we need to + * convert the observables to a string + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: observablesToString(params.incident.dest_ip), + malware_hash: observablesToString(params.incident.malware_hash), + malware_url: observablesToString(params.incident.malware_url), + source_ip: observablesToString(params.incident.source_ip), + }, + }; + } + + /** + * For non legacy connectors the observables + * will be added in a different call. + * They need to be set to null when sending the fields + * to ServiceNow + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: null, + malware_hash: null, + malware_url: null, + source_ip: null, + }, + }; +}; + +const pushToServiceHandler = async ({ + externalService, + params, + config, + secrets, + commentFieldKey, + logger, +}: PushToServiceApiHandlerArgs): Promise => { + const res = await api.pushToService({ + externalService, + params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR), + config, + secrets, + commentFieldKey, + logger, + }); + + const { + incident: { + dest_ip: destIP, + malware_hash: malwareHash, + malware_url: malwareUrl, + source_ip: sourceIP, + }, + } = params as ExecutorSubActionPushParamsSIR; + + /** + * Add bulk observables is only available for new connectors + * Old connectors gonna add their observables + * through the pushToService call. + */ + + if (!config.isLegacy) { + const sirExternalService = externalService as ExternalServiceSIR; + + const obsWithType: Array<[string[], ObservableTypes]> = [ + [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4], + [obsAsArray(malwareHash ?? []), ObservableTypes.sha256], + [obsAsArray(malwareUrl ?? []), ObservableTypes.url], + ]; + + const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); + if (observables.length > 0) { + await sirExternalService.bulkAddObservableToIncident(observables, res.id); + } + } + + return res; +}; + +export const apiSIR: ExternalServiceAPI = { + ...api, + pushToService: pushToServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts new file mode 100644 index 0000000000000..babd360cbcb82 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { snExternalServiceConfig } from './config'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('config', () => { + test('ITSM: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); + + test('SIR: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow-sir']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts new file mode 100644 index 0000000000000..37e4c6994b403 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, +} from '../../constants/connectors'; +import { SNProductsConfig } from './types'; + +export const serviceNowITSMTable = 'incident'; +export const serviceNowSIRTable = 'sn_si_incident'; + +export const ServiceNowITSMActionTypeId = '.servicenow'; +export const ServiceNowSIRActionTypeId = '.servicenow-sir'; + +export const snExternalServiceConfig: SNProductsConfig = { + '.servicenow': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR, + commentFieldKey: 'work_notes', + }, + '.servicenow-sir': { + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR, + commentFieldKey: 'work_notes', + }, +}; + +export const FIELD_PREFIX = 'u_'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index f2b500df6ccb3..29907381d45da 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -18,7 +18,7 @@ import { import { ActionsConfigurationUtilities } from '../../actions_config'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; import { createExternalService } from './service'; -import { api } from './api'; +import { api as commonAPI } from './api'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; import { @@ -30,7 +30,25 @@ import { ExecutorSubActionCommonFieldsParams, ServiceNowExecutorResultData, ExecutorSubActionGetChoicesParams, + ServiceFactory, + ExternalServiceAPI, } from './types'; +import { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, + snExternalServiceConfig, +} from './config'; +import { createExternalServiceSIR } from './service_sir'; +import { apiSIR } from './api_sir'; + +export { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, +}; export type ActionParamsType = | TypeOf @@ -41,12 +59,6 @@ interface GetActionTypeParams { configurationUtilities: ActionsConfigurationUtilities; } -const serviceNowITSMTable = 'incident'; -const serviceNowSIRTable = 'sn_si_incident'; - -export const ServiceNowITSMActionTypeId = '.servicenow'; -export const ServiceNowSIRActionTypeId = '.servicenow-sir'; - export type ServiceNowActionType = ActionType< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, @@ -79,8 +91,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowITSMTable, - commentFieldKey: 'work_notes', + actionTypeId: ServiceNowITSMActionTypeId, + createService: createExternalService, + api: commonAPI, }), }; } @@ -103,8 +116,9 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowSIRTable, - commentFieldKey: 'work_notes', + actionTypeId: ServiceNowSIRActionTypeId, + createService: createExternalServiceSIR, + api: apiSIR, }), }; } @@ -115,28 +129,31 @@ async function executor( { logger, configurationUtilities, - table, - commentFieldKey = 'comments', + actionTypeId, + createService, + api, }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; - table: string; - commentFieldKey?: string; + actionTypeId: string; + createService: ServiceFactory; + api: ExternalServiceAPI; }, execOptions: ServiceNowActionTypeExecutorOptions ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; let data: ServiceNowExecutorResultData | null = null; - const externalService = createExternalService( - table, + const externalService = createService( { config, secrets, }, logger, - configurationUtilities + configurationUtilities, + externalServiceConfig ); if (!api[subAction]) { @@ -156,9 +173,10 @@ async function executor( data = await api.pushToService({ externalService, params: pushToServiceParams, + config, secrets, logger, - commentFieldKey, + commentFieldKey: externalServiceConfig.commentFieldKey, }); logger.debug(`response push to service for incident id: ${data.id}`); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 909200472be33..3629fb33915ae 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -5,7 +5,14 @@ * 2.0. */ -import { ExternalService, ExecutorSubActionPushParams } from './types'; +import { + ExternalService, + ExecutorSubActionPushParams, + PushToServiceApiParamsSIR, + ExternalServiceSIR, + Observable, + ObservableTypes, +} from './types'; export const serviceNowCommonFields = [ { @@ -74,6 +81,10 @@ const createMock = (): jest.Mocked => { getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)), getIncident: jest.fn().mockImplementation(() => Promise.resolve({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', short_description: 'title from servicenow', description: 'description from servicenow', }) @@ -95,16 +106,60 @@ const createMock = (): jest.Mocked => { }) ), findIncidents: jest.fn(), + getApplicationInformation: jest.fn().mockImplementation(() => + Promise.resolve({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }) + ), + checkIfApplicationIsInstalled: jest.fn(), + getUrl: jest.fn().mockImplementation(() => 'https://instance.service-now.com'), + checkInstance: jest.fn(), }; return service; }; -const externalServiceMock = { +const createSIRMock = (): jest.Mocked => { + const service = { + ...createMock(), + addObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + value: 'https://example.com', + observable_sys_id: '3', + }) + ), + bulkAddObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve([ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, + ]) + ), + }; + + return service; +}; + +export const externalServiceMock = { create: createMock, }; -const executorParams: ExecutorSubActionPushParams = { +export const externalServiceSIRMock = { + create: createSIRMock, +}; + +export const executorParams: ExecutorSubActionPushParams = { incident: { externalId: 'incident-3', short_description: 'Incident title', @@ -114,6 +169,8 @@ const executorParams: ExecutorSubActionPushParams = { impact: '3', category: 'software', subcategory: 'os', + correlation_id: 'ruleId', + correlation_display: 'Alerting', }, comments: [ { @@ -127,6 +184,46 @@ const executorParams: ExecutorSubActionPushParams = { ], }; -const apiParams = executorParams; +export const sirParams: PushToServiceApiParamsSIR = { + incident: { + externalId: 'incident-3', + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'ruleId', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + ], +}; + +export const observables: Observable[] = [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + type: ObservableTypes.sha256, + }, + { + value: '127.0.0.1', + type: ObservableTypes.ip4, + }, + { + value: 'https://example.com', + type: ObservableTypes.url, + }, +]; -export { externalServiceMock, executorParams, apiParams }; +export const apiParams = executorParams; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 6fec30803d6d7..dab68bb9d3e9d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), + isLegacy: schema.boolean({ defaultValue: false }), }; export const ExternalIncidentServiceConfigurationSchema = schema.object( @@ -39,6 +40,8 @@ const CommonAttributes = { externalId: schema.nullable(schema.string()), category: schema.nullable(schema.string()), subcategory: schema.nullable(schema.string()), + correlation_id: schema.nullable(schema.string()), + correlation_display: schema.nullable(schema.string()), }; // Schema for ServiceNow Incident Management (ITSM) @@ -56,10 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ incident: schema.object({ ...CommonAttributes, - dest_ip: schema.nullable(schema.string()), - malware_hash: schema.nullable(schema.string()), - malware_url: schema.nullable(schema.string()), - source_ip: schema.nullable(schema.string()), + dest_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_hash: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_url: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + source_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), priority: schema.nullable(schema.string()), }), comments: CommentsSchema, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 37bfb662508a2..b8499b01e6a02 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -5,15 +5,16 @@ * 2.0. */ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { createExternalService } from './service'; import * as utils from '../lib/axios_utils'; -import { ExternalService } from './types'; +import { ExternalService, ServiceNowITSMIncident } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; import { serviceNowCommonFields, serviceNowChoices } from './mocks'; +import { snExternalServiceConfig } from './config'; const logger = loggingSystemMock.create().get() as jest.Mocked; jest.mock('axios'); @@ -28,24 +29,134 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; -const patchMock = utils.patch as jest.Mock; const configurationUtilities = actionsConfigMock.create(); -const table = 'incident'; + +const getImportSetAPIResponse = (update = false) => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: 'https://example.com/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', + sys_id: '1', + }, + ], +}); + +const getImportSetAPIError = () => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + status: 'error', + error_message: 'An error has occurred while importing the incident', + status_message: 'failure', + }, + ], +}); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, + }, + })); + +const mockImportIncident = (update: boolean) => + requestMock.mockImplementationOnce(() => ({ + data: getImportSetAPIResponse(update), + })); + +const mockIncidentResponse = (update: boolean) => + requestMock.mockImplementation(() => ({ + data: { + result: { + sys_id: '1', + number: 'INC01', + ...(update + ? { sys_updated_on: '2020-03-10 12:24:20' } + : { sys_created_on: '2020-03-10 12:24:20' }), + }, + }, + })); + +const createIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(false); + // Get incident response + mockIncidentResponse(false); + + return await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const updateIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(true); + // Get incident response + mockIncidentResponse(true); + + return await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const expectImportedIncident = (update: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', + method: 'post', + data: { + u_short_description: 'title', + u_description: 'desc', + ...(update ? { elastic_incident_id: '1' } : {}), + }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', + }); +}; describe('ServiceNow service', () => { let service: ExternalService; beforeEach(() => { service = createExternalService( - table, { // The trailing slash at the end of the url is intended. // All API calls need to have the trailing slash removed. - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ); }); @@ -57,13 +168,13 @@ describe('ServiceNow service', () => { test('throws without url', () => { expect(() => createExternalService( - table, { config: { apiUrl: null }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -71,13 +182,13 @@ describe('ServiceNow service', () => { test('throws without username', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -85,13 +196,13 @@ describe('ServiceNow service', () => { test('throws without password', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: undefined }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -116,19 +227,20 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', }); }); test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -140,7 +252,8 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', }); }); @@ -166,214 +279,346 @@ describe('ServiceNow service', () => { }); describe('createIncident', () => { - test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); - - const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + // new connectors + describe('import set table', () => { + test('it creates the incident correctly', async () => { + const res = await createIncident(service); + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + test('it should call request with correct arguments', async () => { + await createIncident(service); + expect(requestMock).toHaveBeenCalledTimes(3); + expectImportedIncident(false); }); - }); - test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, - }); + const res = await createIncident(service); - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident', - method: 'post', - data: { short_description: 'title', description: 'desc' }, - }); - }); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - 'sn_si_incident', - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities - ); + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); - const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); }); - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident', - method: 'post', - data: { short_description: 'title', description: 'desc' }, + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred' - ); - }); + test('it creates the incident correctly', async () => { + mockIncidentResponse(false); + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); - }); - }); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); - describe('updateIncident', () => { - test('it updates the incident correctly', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + mockIncidentResponse(false); - const res = await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, - }); + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); }); + }); - test('it should call request with correct arguments', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); - - await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + describe('updateIncident', () => { + // new connectors + describe('import set table', () => { + test('it updates the incident correctly', async () => { + const res = await updateIncident(service); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - expect(patchMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', - data: { short_description: 'title', description: 'desc' }, + test('it should call request with correct arguments', async () => { + await updateIncident(service); + expectImportedIncident(true); }); - }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - 'sn_si_incident', - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities - ); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + const res = await updateIncident(service); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); - const res = await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); - expect(patchMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', - data: { short_description: 'title', description: 'desc' }, + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - patchMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.updateIncident({ + test('it updates the incident correctly', async () => { + mockIncidentResponse(true); + const res = await service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred' - ); - }); + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it creates the comment correctly', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); - const res = await service.updateIncident({ - incidentId: '1', - comment: 'comment-1', - }); + mockIncidentResponse(false); - expect(res).toEqual({ - title: 'INC011', - id: '11', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11', - }); - }); + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); }); }); @@ -388,7 +633,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -402,13 +647,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -420,7 +665,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -456,7 +701,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -470,13 +715,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -489,7 +734,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -513,4 +758,79 @@ describe('ServiceNow service', () => { ); }); }); + + describe('getUrl', () => { + test('it returns the instance url', async () => { + expect(service.getUrl()).toBe('https://example.com'); + }); + }); + + describe('checkInstance', () => { + test('it throws an error if there is no result on data', () => { + const res = { status: 200, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow(); + }); + + test('it does NOT throws an error if the status > 400', () => { + const res = { status: 500, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).not.toThrow(); + }); + + test('it shows the servername', () => { + const res = { + status: 200, + data: {}, + request: { connection: { servername: 'https://example.com' } }, + } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow( + 'There is an issue with your Service Now Instance. Please check https://example.com.' + ); + }); + + describe('getApplicationInformation', () => { + test('it returns the application information', async () => { + mockApplicationVersion(); + const res = await service.getApplicationInformation(); + expect(res).toEqual({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getApplicationInformation()).rejects.toThrow( + '[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown' + ); + }); + }); + + describe('checkIfApplicationIsInstalled', () => { + test('it logs the application information', async () => { + mockApplicationVersion(); + await service.checkIfApplicationIsInstalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0' + ); + }); + + test('it does not log if useOldApi = true', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); + await service.checkIfApplicationIsInstalled(); + expect(requestMock).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 07ed9edc94d39..cb030c7bb6933 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -7,28 +7,35 @@ import axios, { AxiosResponse } from 'axios'; -import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types'; +import { + ExternalServiceCredentials, + ExternalService, + ExternalServiceParamsCreate, + ExternalServiceParamsUpdate, + ImportSetApiResponse, + ImportSetApiResponseError, + ServiceNowIncident, + GetApplicationInfoResponse, + SNProductsConfigValue, + ServiceFactory, +} from './types'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; -import { - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ResponseError, -} from './types'; -import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils'; +import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createServiceError, getPushedDate, prepareIncident } from './utils'; -const API_VERSION = 'v2'; -const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`; +export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; -export const createExternalService = ( - table: string, +export const createExternalService: ServiceFactory = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + { table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue ): ExternalService => { - const { apiUrl: url } = config as ServiceNowPublicConfigurationType; + const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; if (!url || !username || !password) { @@ -36,13 +43,26 @@ export const createExternalService = ( } const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; - const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`; - const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; - const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`; + const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${importSetTable}`; + const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/v2/table/${table}`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY_ENDPOINT}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; + const choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`; + /** + * Need to be set the same at: + * x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts + */ + const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`; + const axiosInstance = axios.create({ auth: { username, password }, }); + const useOldApi = !useImportAPI || isLegacy; + + const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl); + const getUpdateIncidentUrl = (incidentId: string) => + useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; + const getIncidentViewURL = (id: string) => { // Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`; @@ -57,7 +77,7 @@ export const createExternalService = ( }; const checkInstance = (res: AxiosResponse) => { - if (res.status === 200 && res.data.result == null) { + if (res.status >= 200 && res.status < 400 && res.data.result == null) { throw new Error( `There is an issue with your Service Now Instance. Please check ${ res.request?.connection?.servername ?? '' @@ -66,34 +86,70 @@ export const createExternalService = ( } }; - const createErrorMessage = (errorResponse: ResponseError): string => { - if (errorResponse == null) { - return ''; + const isImportSetApiResponseAnError = ( + data: ImportSetApiResponse['result'][0] + ): data is ImportSetApiResponseError['result'][0] => data.status === 'error'; + + const throwIfImportSetApiResponseIsAnError = (res: ImportSetApiResponse) => { + if (res.result.length === 0) { + throw new Error('Unexpected result'); } - const { error } = errorResponse; - return error != null ? `${error?.message}: ${error?.detail}` : ''; + const data = res.result[0]; + + // Create ResponseError message? + if (isImportSetApiResponseAnError(data)) { + throw new Error(data.error_message); + } }; - const getIncident = async (id: string) => { + /** + * Gets the Elastic SN Application information including the current version. + * It should not be used on legacy connectors. + */ + const getApplicationInformation = async (): Promise => { try { const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${id}`, + url: getVersionUrl(), logger, configurationUtilities, + method: 'get', }); + checkInstance(res); + return { ...res.data.result }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get incident with id ${id}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, 'Unable to get application version'); + } + }; + + const logApplicationInfo = (scope: string, version: string) => + logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`); + + const checkIfApplicationIsInstalled = async () => { + if (!useOldApi) { + const { version, scope } = await getApplicationInformation(); + logApplicationInfo(scope, version); + } + }; + + const getIncident = async (id: string): Promise => { + try { + const res = await request({ + axios: axiosInstance, + url: `${tableApiIncidentUrl}/${id}`, + logger, + configurationUtilities, + method: 'get', + }); + + checkInstance(res); + + return { ...res.data.result }; + } catch (error) { + throw createServiceError(error, `Unable to get incident with id ${id}`); } }; @@ -101,7 +157,7 @@ export const createExternalService = ( try { const res = await request({ axios: axiosInstance, - url: incidentUrl, + url: tableApiIncidentUrl, logger, params, configurationUtilities, @@ -109,71 +165,80 @@ export const createExternalService = ( checkInstance(res); return res.data.result.length > 0 ? { ...res.data.result } : undefined; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to find incidents by query. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to find incidents by query'); } }; - const createIncident = async ({ incident }: ExternalServiceParams) => { + const getUrl = () => urlWithoutTrailingSlash; + + const createIncident = async ({ incident }: ExternalServiceParamsCreate) => { try { + await checkIfApplicationIsInstalled(); + const res = await request({ axios: axiosInstance, - url: `${incidentUrl}`, + url: getCreateIncidentUrl(), logger, method: 'post', - data: { ...(incident as Record) }, + data: prepareIncident(useOldApi, incident), configurationUtilities, }); + checkInstance(res); + + if (!useOldApi) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const insertedIncident = await getIncident(incidentId); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: insertedIncident.number, + id: insertedIncident.sys_id, + pushedDate: getPushedDate(insertedIncident.sys_created_on), + url: getIncidentViewURL(insertedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to create incident. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to create incident'); } }; - const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => { try { - const res = await patch({ + await checkIfApplicationIsInstalled(); + + const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${incidentId}`, + url: getUpdateIncidentUrl(incidentId), + // Import Set API supports only POST. + method: useOldApi ? 'patch' : 'post', logger, - data: { ...(incident as Record) }, + data: { + ...prepareIncident(useOldApi, incident), + // elastic_incident_id is used to update the incident when using the Import Set API. + ...(useOldApi ? {} : { elastic_incident_id: incidentId }), + }, configurationUtilities, }); + checkInstance(res); + + if (!useOldApi) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const updatedIncident = await getIncident(id); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: updatedIncident.number, + id: updatedIncident.sys_id, + pushedDate: getPushedDate(updatedIncident.sys_updated_on), + url: getIncidentViewURL(updatedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to update incident with id ${incidentId}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, `Unable to update incident with id ${incidentId}`); } }; @@ -185,17 +250,12 @@ export const createExternalService = ( logger, configurationUtilities, }); + checkInstance(res); + return res.data.result.length > 0 ? res.data.result : []; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get fields. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get fields'); } }; @@ -210,14 +270,7 @@ export const createExternalService = ( checkInstance(res); return res.data.result; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get choices. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get choices'); } }; @@ -228,5 +281,9 @@ export const createExternalService = ( getIncident, updateIncident, getChoices, + getUrl, + checkInstance, + getApplicationInformation, + checkIfApplicationIsInstalled, }; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts new file mode 100644 index 0000000000000..0fc94b6287abd --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { createExternalServiceSIR } from './service_sir'; +import * as utils from '../lib/axios_utils'; +import { ExternalServiceSIR } from './types'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { observables } from './mocks'; +import { snExternalServiceConfig } from './config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_sir_int', version: '1.0.0' }, + }, + })); + +const getAddObservablesResponse = () => [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, +]; + +const mockAddObservablesResponse = (single: boolean) => { + const res = getAddObservablesResponse(); + requestMock.mockImplementation(() => ({ + data: { + result: single ? res[0] : res, + }, + })); +}; + +const expectAddObservables = (single: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + const url = single + ? 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables' + : 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk'; + + const data = single ? observables[0] : observables; + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url, + method: 'post', + data, + }); +}; + +describe('ServiceNow SIR service', () => { + let service: ExternalServiceSIR; + + beforeEach(() => { + service = createExternalServiceSIR( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ) as ExternalServiceSIR; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('bulkAddObservableToIncident', () => { + test('it adds multiple observables correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(false); + + const res = await service.bulkAddObservableToIncident(observables, 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()); + expectAddObservables(false); + }); + + test('it adds a single observable correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(true); + + const res = await service.addObservableToIncident(observables[0], 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()[0]); + expectAddObservables(true); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts new file mode 100644 index 0000000000000..fc8d8cc555bc8 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { + ExternalServiceCredentials, + SNProductsConfigValue, + Observable, + ExternalServiceSIR, + ObservableResponse, + ServiceFactory, +} from './types'; + +import { Logger } from '../../../../../../src/core/server'; +import { ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createExternalService } from './service'; +import { createServiceError } from './utils'; + +const getAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`; + +const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; + +export const createExternalServiceSIR: ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +): ExternalServiceSIR => { + const snService = createExternalService( + credentials, + logger, + configurationUtilities, + serviceConfig + ); + + const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const _addObservable = async (data: Observable | Observable[], url: string) => { + snService.checkIfApplicationIsInstalled(); + + const res = await request({ + axios: axiosInstance, + url, + logger, + method: 'post', + data, + configurationUtilities, + }); + + snService.checkInstance(res); + return res.data.result; + }; + + const addObservableToIncident = async ( + observable: Observable, + incidentID: string + ): Promise => { + try { + return await _addObservable( + observable, + getAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observable to security incident with id ${incidentID}` + ); + } + }; + + const bulkAddObservableToIncident = async ( + observables: Observable[], + incidentID: string + ): Promise => { + try { + return await _addObservable( + observables, + getBulkAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observables to security incident with id ${incidentID}` + ); + } + }; + return { + ...snService, + addObservableToIncident, + bulkAddObservableToIncident, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 50631cf289a73..ecca1e55e0fec 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -7,6 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { AxiosError, AxiosResponse } from 'axios'; import { TypeOf } from '@kbn/config-schema'; import { ExecutorParamsSchemaITSM, @@ -78,15 +79,29 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse { comments?: ExternalServiceCommentResponse[]; } -export type ExternalServiceParams = Record; +export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; +export type PartialIncident = Partial; + +export interface ExternalServiceParamsCreate { + incident: Incident & Record; +} + +export interface ExternalServiceParamsUpdate { + incidentId: string; + incident: PartialIncident & Record; +} export interface ExternalService { getChoices: (fields: string[]) => Promise; - getIncident: (id: string) => Promise; + getIncident: (id: string) => Promise; getFields: () => Promise; - createIncident: (params: ExternalServiceParams) => Promise; - updateIncident: (params: ExternalServiceParams) => Promise; - findIncidents: (params?: Record) => Promise; + createIncident: (params: ExternalServiceParamsCreate) => Promise; + updateIncident: (params: ExternalServiceParamsUpdate) => Promise; + findIncidents: (params?: Record) => Promise; + getUrl: () => string; + checkInstance: (res: AxiosResponse) => void; + getApplicationInformation: () => Promise; + checkIfApplicationIsInstalled: () => Promise; } export type PushToServiceApiParams = ExecutorSubActionPushParams; @@ -115,10 +130,9 @@ export type ServiceNowSIRIncident = Omit< 'externalId' >; -export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; - export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { params: PushToServiceApiParams; + config: Record; secrets: Record; logger: Logger; commentFieldKey: string; @@ -158,12 +172,20 @@ export interface GetChoicesHandlerArgs { params: ExecutorSubActionGetChoicesParams; } -export interface ExternalServiceApi { +export interface ServiceNowIncident { + sys_id: string; + number: string; + sys_created_on: string; + sys_updated_on: string; + [x: string]: unknown; +} + +export interface ExternalServiceAPI { getChoices: (args: GetChoicesHandlerArgs) => Promise; getFields: (args: GetCommonFieldsHandlerArgs) => Promise; handshake: (args: HandshakeApiHandlerArgs) => Promise; pushToService: (args: PushToServiceApiHandlerArgs) => Promise; - getIncident: (args: GetIncidentApiHandlerArgs) => Promise; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise; } export interface ExternalServiceCommentResponse { @@ -173,10 +195,90 @@ export interface ExternalServiceCommentResponse { } type TypeNullOrUndefined = T | null | undefined; -export interface ResponseError { + +export interface ServiceNowError { error: TypeNullOrUndefined<{ message: TypeNullOrUndefined; detail: TypeNullOrUndefined; }>; status: TypeNullOrUndefined; } + +export type ResponseError = AxiosError; + +export interface ImportSetApiResponseSuccess { + import_set: string; + staging_table: string; + result: Array<{ + display_name: string; + display_value: string; + record_link: string; + status: string; + sys_id: string; + table: string; + transform_map: string; + }>; +} + +export interface ImportSetApiResponseError { + import_set: string; + staging_table: string; + result: Array<{ + error_message: string; + status_message: string; + status: string; + transform_map: string; + }>; +} + +export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; +export interface GetApplicationInfoResponse { + id: string; + name: string; + scope: string; + version: string; +} + +export interface SNProductsConfigValue { + table: string; + appScope: string; + useImportAPI: boolean; + importSetTable: string; + commentFieldKey: string; +} + +export type SNProductsConfig = Record; + +export enum ObservableTypes { + ip4 = 'ipv4-addr', + url = 'URL', + sha256 = 'SHA256', +} + +export interface Observable { + value: string; + type: ObservableTypes; +} + +export interface ObservableResponse { + value: string; + observable_sys_id: ObservableTypes; +} + +export interface ExternalServiceSIR extends ExternalService { + addObservableToIncident: ( + observable: Observable, + incidentID: string + ) => Promise; + bulkAddObservableToIncident: ( + observables: Observable[], + incidentID: string + ) => Promise; +} + +export type ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +) => ExternalServiceSIR | ExternalService; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts new file mode 100644 index 0000000000000..87f27da6d213f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AxiosError } from 'axios'; +import { prepareIncident, createServiceError, getPushedDate } from './utils'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('utils', () => { + describe('prepareIncident', () => { + test('it prepares the incident correctly when useOldApi=false', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(false, incident); + expect(newIncident).toEqual({ u_short_description: 'title', u_description: 'desc' }); + }); + + test('it prepares the incident correctly when useOldApi=true', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(true, incident); + expect(newIncident).toEqual(incident); + }); + }); + + describe('createServiceError', () => { + test('it creates an error when the response is null', async () => { + const error = new Error('An error occurred'); + // @ts-expect-error + expect(createServiceError(error, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: errorResponse was null' + ); + }); + + test('it creates an error with response correctly', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: { message: 'Denied', detail: 'no access' } } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access' + ); + }); + + test('it creates an error correctly when the ServiceNow error is null', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: null } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response' + ); + }); + }); + + describe('getPushedDate', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-10-04 11:15:06 GMT')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('it formats the date correctly if timestamp is provided', async () => { + expect(getPushedDate('2021-10-04 11:15:06')).toBe('2021-10-04T11:15:06.000Z'); + }); + + test('it formats the date correctly if timestamp is not provided', async () => { + expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts new file mode 100644 index 0000000000000..5b7ca99ffc709 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types'; +import { FIELD_PREFIX } from './config'; +import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; +import * as i18n from './translations'; + +export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => + useOldApi + ? incident + : Object.entries(incident).reduce( + (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), + {} as Incident + ); + +const createErrorMessage = (errorResponse?: ServiceNowError): string => { + if (errorResponse == null) { + return 'unknown: errorResponse was null'; + } + + const { error } = errorResponse; + return error != null + ? `${error?.message}: ${error?.detail}` + : 'unknown: no error in error response'; +}; + +export const createServiceError = (error: ResponseError, message: string) => + new Error( + getErrorMessage( + i18n.SERVICENOW, + `${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + +export const getPushedDate = (timestamp?: string) => { + if (timestamp != null) { + return new Date(addTimeZoneToDate(timestamp)).toISOString(); + } + + return new Date().toISOString(); +}; diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts new file mode 100644 index 0000000000000..f20d499716cf0 --- /dev/null +++ b/x-pack/plugins/actions/server/constants/connectors.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: Remove when Elastic for ITSM is published. +export const ENABLE_NEW_SN_ITSM_CONNECTOR = true; + +// TODO: Remove when Elastic for Security Operations is published. +export const ENABLE_NEW_SN_SIR_CONNECTOR = true; diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts index c094109a43d97..9f8e62c77e3a7 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts @@ -165,6 +165,47 @@ describe('successful migrations', () => { }); expect(migratedAction).toEqual(action); }); + + test('set isLegacy config property for .servicenow', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow(); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('set isLegacy config property for .servicenow-sir', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' }); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('it does not set isLegacy config for other connectors', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockData(); + const migratedAction = migration716(action, context); + expect(migratedAction).toEqual(action); + }); }); describe('8.0.0', () => { @@ -306,3 +347,19 @@ function getMockData( type: 'action', }; } + +function getMockDataForServiceNow( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return { + attributes: { + name: 'abc', + actionTypeId: '.servicenow', + config: { apiUrl: 'https://example.com' }, + secrets: { user: 'test', password: '123' }, + ...overwrites, + }, + id: uuid.v4(), + type: 'action', + }; +} diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index e75f3eb41f2df..688839eb89858 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -59,13 +59,16 @@ export function getActionsMigrations( const migrationActionsFourteen = createEsoMigration( encryptedSavedObjects, (doc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(addisMissingSecretsField) + pipeMigrations(addIsMissingSecretsField) ); - const migrationEmailActionsSixteen = createEsoMigration( + const migrationActionsSixteen = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => doc.attributes.actionTypeId === '.email', - pipeMigrations(setServiceConfigIfNotSet) + (doc): doc is SavedObjectUnsanitizedDoc => + doc.attributes.actionTypeId === '.servicenow' || + doc.attributes.actionTypeId === '.servicenow-sir' || + doc.attributes.actionTypeId === '.email', + pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet) ); const migrationActions800 = createEsoMigration( @@ -79,7 +82,7 @@ export function getActionsMigrations( '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), - '7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'), + '7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'), }; } @@ -182,7 +185,7 @@ const setServiceConfigIfNotSet = ( }; }; -const addisMissingSecretsField = ( +const addIsMissingSecretsField = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { return { @@ -194,6 +197,28 @@ const addisMissingSecretsField = ( }; }; +const markOldServiceNowITSMConnectorAsLegacy = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + if ( + doc.attributes.actionTypeId !== '.servicenow' && + doc.attributes.actionTypeId !== '.servicenow-sir' + ) { + return doc; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + config: { + ...doc.attributes.config, + isLegacy: true, + }, + }, + }; +}; + function pipeMigrations(...migrations: ActionMigration[]): ActionMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index f894ca23dfbf0..f28926eb52052 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -1,9 +1,9 @@ -Case management in Kibana +# Case management in Kibana [![Issues][issues-shield]][issues-url] -[![Pull Requests][pr-shield]][pr-url] +[![Pull Requests][pr-shield]][pr-url] -# Cases Plugin Docs +# Docs ![Cases Logo][cases-logo] @@ -288,9 +288,9 @@ Connectors of type (`.none`) should have the `fields` attribute set to `null`. -[pr-shield]: https://img.shields.io/github/issues-pr/elangosundar/awesome-README-templates?style=for-the-badge -[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+label%3AFeature%3ACases+-is%3Adraft+is%3Aopen+ -[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge +[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Team:Threat%20Hunting:Cases?label=pull%20requests&style=for-the-badge +[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Team%3AThreat+Hunting%3ACases%22 +[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Team%3AThreat%20Hunting%3ACases%22&style=for-the-badge [issues-url]: https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3ACases [cases-logo]: images/logo.png [configure-img]: images/configure.png diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 948b203af14a8..b4ed4f7db177e 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -16,6 +16,7 @@ import { User, UserAction, UserActionField, + ActionConnector, } from '../api'; export interface CasesUiConfigType { @@ -259,3 +260,5 @@ export interface Ecs { _index?: string; signal?: SignalEcs; } + +export type CaseActionConnector = ActionConnector; diff --git a/x-pack/plugins/cases/public/common/mock/register_connectors.ts b/x-pack/plugins/cases/public/common/mock/register_connectors.ts new file mode 100644 index 0000000000000..42e7cd4a85e40 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/register_connectors.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { CaseActionConnector } from '../../../common'; + +const getUniqueActionTypeIds = (connectors: CaseActionConnector[]) => + new Set(connectors.map((connector) => connector.actionTypeId)); + +export const registerConnectorsToMockActionRegistry = ( + actionTypeRegistry: TriggersAndActionsUIPublicPluginStart['actionTypeRegistry'], + connectors: CaseActionConnector[] +) => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + const uniqueActionTypeIds = getUniqueActionTypeIds(connectors); + uniqueActionTypeIds.forEach((actionTypeId) => + actionTypeRegistry.register( + createMockActionTypeModel({ id: actionTypeId, iconClass: 'logoSecurity' }) + ) + ); +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx index 0e548fd53c89d..fed23564a3955 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx @@ -19,8 +19,8 @@ import { useKibana } from '../../common/lib/kibana'; import { StatusAll } from '../../containers/types'; import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common'; import { connectorsMock } from '../../containers/mock'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); @@ -59,14 +59,10 @@ jest.mock('../../common/lib/kibana', () => { }); describe('AllCasesGeneric ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 015ba877a2749..090ac0d31ed06 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -12,21 +12,17 @@ import '../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../containers/mock'; import { useKibana } from '../../common/lib/kibana'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { connectors } from '../configure_cases/__mock__'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('ExternalServiceColumn ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); }); it('Not pushed render', () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 3fff43108772d..a387c5eae3834 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -32,8 +32,8 @@ import { useKibana } from '../../common/lib/kibana'; import { AllCasesGeneric as AllCases } from './all_cases_generic'; import { AllCasesProps } from '.'; import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); @@ -148,14 +148,10 @@ describe('AllCasesGeneric', () => { userCanCrud: true, }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index 0bda6fe185093..38923784d862c 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { Connectors, Props } from './connectors'; import { TestProviders } from '../../common/mock'; @@ -14,6 +15,7 @@ import { ConnectorsDropdown } from './connectors_dropdown'; import { connectors, actionTypes } from './__mock__'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; @@ -35,11 +37,10 @@ describe('Connectors', () => { updateConnectorDisabled: false, }; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; + beforeAll(() => { - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: 'test', - iconClass: 'logoSecurity', - }); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -121,4 +122,33 @@ describe('Connectors', () => { .text() ).toBe('Update My Connector'); }); + + test('it shows the deprecated callout when the connector is legacy', async () => { + render( + , + { + // wrapper: TestProviders produces a TS error + wrapper: ({ children }) => {children}, + } + ); + + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect( + screen.getByText( + 'This connector type is deprecated. Create a new connector or update this connector' + ) + ).toBeInTheDocument(); + }); + + test('it does not shows the deprecated callout when the connector is none', async () => { + render(, { + // wrapper: TestProviders produces a TS error + wrapper: ({ children }) => {children}, + }); + + expect(screen.queryByText('Deprecated connector type')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index 40f314a653882..1b575e3ba9334 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -22,6 +22,8 @@ import * as i18n from './translations'; import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types'; import { Mapping } from './mapping'; import { ActionTypeConnector, ConnectorTypes } from '../../../common'; +import { DeprecatedCallout } from '../connectors/deprecated_callout'; +import { isLegacyConnector } from '../utils'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -53,11 +55,13 @@ const ConnectorsComponent: React.FC = ({ selectedConnector, updateConnectorDisabled, }) => { - const connectorsName = useMemo( - () => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none', + const connector = useMemo( + () => connectors.find((c) => c.id === selectedConnector.id), [connectors, selectedConnector.id] ); + const connectorsName = connector?.name ?? 'none'; + const actionTypeName = useMemo( () => actionTypes.find((c) => c.id === selectedConnector.type)?.name ?? 'Unknown', [actionTypes, selectedConnector.type] @@ -107,6 +111,11 @@ const ConnectorsComponent: React.FC = ({ appendAddConnectorButton={true} /> + {selectedConnector.type !== ConnectorTypes.none && isLegacyConnector(connector) && ( + + + + )} {selectedConnector.type !== ConnectorTypes.none ? ( ; @@ -28,14 +29,10 @@ describe('ConnectorsDropdown', () => { selectedConnector: 'none', }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -77,7 +74,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-servicenow-1", "inputDisplay": { type="logoSecurity" /> - + My Connector @@ -100,7 +99,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-resilient-2", "inputDisplay": { type="logoSecurity" /> - + My Connector 2 @@ -123,7 +124,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-jira-1", "inputDisplay": { type="logoSecurity" /> - + Jira @@ -146,7 +149,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-servicenow-sir", "inputDisplay": { type="logoSecurity" /> - + My Connector SIR @@ -165,6 +170,43 @@ describe('ConnectorsDropdown', () => { , "value": "servicenow-sir", }, + Object { + "data-test-subj": "dropdown-connector-servicenow-legacy", + "inputDisplay": + + + + + + My Connector + + + + + + , + "value": "servicenow-legacy", + }, ] `); }); @@ -245,4 +287,13 @@ describe('ConnectorsDropdown', () => { ) ).not.toThrowError(); }); + + test('it shows the deprecated tooltip when the connector is legacy', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + const tooltips = screen.getAllByLabelText('Deprecated connector'); + expect(tooltips[0]).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 3cab2afd41f41..f21b3ab3d544f 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -6,14 +6,14 @@ */ import React, { useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiIconTip, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; import { ConnectorTypes } from '../../../common'; import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; -import { getConnectorIcon } from '../utils'; +import { getConnectorIcon, isLegacyConnector } from '../utils'; export interface Props { connectors: ActionConnector[]; @@ -79,16 +79,28 @@ const ConnectorsDropdownComponent: React.FC = ({ { value: connector.id, inputDisplay: ( - + - + {connector.name} + {isLegacyConnector(connector) && ( + + + + )} ), 'data-test-subj': `dropdown-connector-${connector.id}`, diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 878d261369340..4a775c78d4ab8 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -162,3 +162,17 @@ export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => values: { connectorName }, defaultMessage: 'Update { connectorName }', }); + +export const DEPRECATED_TOOLTIP_TITLE = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipTitle', + { + defaultMessage: 'Deprecated connector', + } +); + +export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipContent', + { + defaultMessage: 'Please update your connector', + } +); diff --git a/x-pack/plugins/cases/public/components/connectors/card.test.tsx b/x-pack/plugins/cases/public/components/connectors/card.test.tsx index b5d70a6781916..384442814ffef 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.test.tsx @@ -10,22 +10,18 @@ import { mount } from 'enzyme'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { connectors } from '../configure_cases/__mock__'; import { ConnectorCard } from './card'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('ConnectorCard ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); }); it('it does not throw when accessing the icon if the connector type is not registered', () => { diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx new file mode 100644 index 0000000000000..6b1475e3c4bd0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DeprecatedCallout } from './deprecated_callout'; + +describe('DeprecatedCallout', () => { + test('it renders correctly', () => { + render(); + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect( + screen.getByText( + 'This connector type is deprecated. Create a new connector or update this connector' + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( + 'euiCallOut euiCallOut--warning' + ); + }); + + test('it renders a danger flyout correctly', () => { + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( + 'euiCallOut euiCallOut--danger' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx new file mode 100644 index 0000000000000..937f8406e218a --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCallOut, EuiCallOutProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', + { + defaultMessage: 'Deprecated connector type', + } +); + +const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc', + { + defaultMessage: + 'This connector type is deprecated. Create a new connector or update this connector', + } +); + +interface Props { + type?: EuiCallOutProps['color']; +} + +const DeprecatedCalloutComponent: React.FC = ({ type = 'warning' }) => ( + + {LEGACY_CONNECTOR_WARNING_DESC} + +); + +export const DeprecatedCallout = React.memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index b14842bbf1bbf..008340b6b7e97 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { waitFor, act } from '@testing-library/react'; +import { waitFor, act, render, screen } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; import { mount } from 'enzyme'; @@ -127,6 +127,17 @@ describe('ServiceNowITSM Fields', () => { ); }); + test('it shows the deprecated callout when the connector is legacy', async () => { + const legacyConnector = { ...connector, config: { isLegacy: true } }; + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument(); + }); + + test('it does not show the deprecated callout when the connector is not legacy', async () => { + render(); + expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument(); + }); + describe('onChange calls', () => { const wrapper = mount(); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 53c0d32dea1a5..096e450c736c1 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -16,6 +16,8 @@ import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; +import { connectorValidator } from './validator'; +import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -39,6 +41,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); + const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); @@ -149,90 +152,111 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } }, [category, impact, onChange, severity, subcategory, urgency]); - return isEdit ? ( -
- - onChangeCb('urgency', e.target.value)} - /> - - - - - - onChangeCb('severity', e.target.value)} + return ( + <> + {showConnectorWarning && ( + + + + + + )} + {isEdit ? ( +
+ + + + onChangeCb('urgency', e.target.value)} + /> + + + + + + + + onChangeCb('severity', e.target.value)} + /> + + + + + onChangeCb('impact', e.target.value)} + /> + + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null }) + } + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + + -
-
- - - onChangeCb('impact', e.target.value)} - /> - - -
- - - - onChange({ ...fields, category: e.target.value, subcategory: null })} - /> - - - - - onChangeCb('subcategory', e.target.value)} - /> - - - -
- ) : ( - +
+
+ )} + ); }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index 7d42c90a436f7..aac78b8266fb5 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { waitFor, act } from '@testing-library/react'; +import { waitFor, act, render, screen } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; import { useKibana } from '../../../common/lib/kibana'; @@ -68,16 +68,16 @@ describe('ServiceNowSIR Fields', () => { wrapper.update(); expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( - 'Destination IP: Yes' + 'Destination IPs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( - 'Source IP: Yes' + 'Source IPs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( - 'Malware URL: Yes' + 'Malware URLs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual( - 'Malware Hash: Yes' + 'Malware Hashes: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual( 'Priority: 1 - Critical' @@ -161,6 +161,17 @@ describe('ServiceNowSIR Fields', () => { ]); }); + test('it shows the deprecated callout when the connector is legacy', async () => { + const legacyConnector = { ...connector, config: { isLegacy: true } }; + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument(); + }); + + test('it does not show the deprecated callout when the connector is not legacy', async () => { + render(); + expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument(); + }); + describe('onChange calls', () => { const wrapper = mount(); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 1f9a7cf7acd64..a7b8aa7b27df5 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -17,6 +17,8 @@ import { Choice, Fields } from './types'; import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; +import { connectorValidator } from './validator'; +import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -40,8 +42,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; - const [choices, setChoices] = useState(defaultFields); + const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const onChangeCb = useCallback( ( @@ -166,115 +168,132 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< } }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); - return isEdit ? ( -
- - - - <> - - - onChangeCb('destIp', e.target.checked)} - /> - - - onChangeCb('sourceIp', e.target.checked)} - /> - - - - - onChangeCb('malwareUrl', e.target.checked)} - /> - - - onChangeCb('malwareHash', e.target.checked)} - /> - - - - - - - - - - onChangeCb('priority', e.target.value)} - /> - - - - - - - onChange({ ...fields, category: e.target.value, subcategory: null })} - /> - - - - - onChangeCb('subcategory', e.target.value)} + return ( + <> + {showConnectorWarning && ( + + + + + + )} + {isEdit ? ( +
+ + + + <> + + + onChangeCb('destIp', e.target.checked)} + /> + + + onChangeCb('sourceIp', e.target.checked)} + /> + + + + + onChangeCb('malwareUrl', e.target.checked)} + /> + + + onChangeCb('malwareHash', e.target.checked)} + /> + + + + + + + + + + onChangeCb('priority', e.target.value)} + /> + + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null }) + } + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + + -
-
-
-
- ) : ( - +
+
+ )} + ); }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts index fc48ecf17f2c6..d9ed86b594ecc 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -30,11 +30,11 @@ export const CHOICES_API_ERROR = i18n.translate( ); export const MALWARE_URL = i18n.translate('xpack.cases.connectors.serviceNow.malwareURLTitle', { - defaultMessage: 'Malware URL', + defaultMessage: 'Malware URLs', }); export const MALWARE_HASH = i18n.translate('xpack.cases.connectors.serviceNow.malwareHashTitle', { - defaultMessage: 'Malware Hash', + defaultMessage: 'Malware Hashes', }); export const CATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.categoryTitle', { @@ -46,11 +46,11 @@ export const SUBCATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.sub }); export const SOURCE_IP = i18n.translate('xpack.cases.connectors.serviceNow.sourceIPTitle', { - defaultMessage: 'Source IP', + defaultMessage: 'Source IPs', }); export const DEST_IP = i18n.translate('xpack.cases.connectors.serviceNow.destinationIPTitle', { - defaultMessage: 'Destination IP', + defaultMessage: 'Destination IPs', }); export const PRIORITY = i18n.translate( diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts new file mode 100644 index 0000000000000..c098d803276bc --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { connector } from '../mock'; +import { connectorValidator } from './validator'; + +describe('ServiceNow validator', () => { + describe('connectorValidator', () => { + test('it returns an error message if the connector is legacy', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + isLegacy: true, + }, + }; + + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' }); + }); + + test('it does not returns an error message if the connector is not legacy', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + isLegacy: false, + }, + }; + + expect(connectorValidator(invalidConnector)).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts new file mode 100644 index 0000000000000..3f67f25549343 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.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 { ValidationConfig } from '../../../common/shared_imports'; +import { CaseActionConnector } from '../../types'; + +/** + * The user can not use a legacy connector + */ + +export const connectorValidator = ( + connector: CaseActionConnector +): ReturnType => { + const { + config: { isLegacy }, + } = connector; + if (isLegacy) { + return { + message: 'Deprecated connector', + }; + } +}; diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index a2ffd42f2660b..ea7435c2cba45 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -22,8 +22,8 @@ import { TestProviders } from '../../common/mock'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { useKibana } from '../../common/lib/kibana'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -86,14 +86,10 @@ describe('Connector', () => { return
{children}
; }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/types.ts b/x-pack/plugins/cases/public/components/types.ts index 014afc371e761..07ab5814b082b 100644 --- a/x-pack/plugins/cases/public/components/types.ts +++ b/x-pack/plugins/cases/public/components/types.ts @@ -5,6 +5,4 @@ * 2.0. */ -import { ActionConnector } from '../../common'; - -export type CaseActionConnector = ActionConnector; +export { CaseActionConnector } from '../../common'; diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 5f7480cb84f7c..ac5f4dbdd298e 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -10,7 +10,13 @@ import { ConnectorTypes } from '../../common'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; import { StartPlugins } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; +import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../actions/server/constants/connectors'; export const getConnectorById = ( id: string, @@ -22,6 +28,8 @@ const validators: Record< (connector: CaseActionConnector) => ReturnType > = { [ConnectorTypes.swimlane]: swimlaneConnectorValidator, + [ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator, + [ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator, }; export const getConnectorsFormValidators = ({ @@ -68,3 +76,20 @@ export const getConnectorIcon = ( return emptyResponse; }; + +// TODO: Remove when the applications are certified +export const isLegacyConnector = (connector?: CaseActionConnector) => { + if (connector == null) { + return true; + } + + if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') { + return true; + } + + if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') { + return true; + } + + return connector.config.isLegacy; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index 833c2cfb3aa7c..d1ae7f310a719 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -71,6 +71,16 @@ export const connectorsMock: ActionConnector[] = [ }, isPreconfigured: false, }, + { + id: 'servicenow-legacy', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + isLegacy: true, + }, + isPreconfigured: false, + }, ]; export const actionTypesMock: ActionTypeConnector[] = [ diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts index 2cc1816e7fa67..ac9dc8839bfb8 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts @@ -10,6 +10,7 @@ import { format } from './itsm_format'; describe('ITSM formatter', () => { const theCase = { + id: 'case-id', connector: { fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' }, }, @@ -17,7 +18,11 @@ describe('ITSM formatter', () => { it('it formats correctly', async () => { const res = await format(theCase, []); - expect(res).toEqual(theCase.connector.fields); + expect(res).toEqual({ + ...theCase.connector.fields, + correlation_display: 'Elastic Case', + correlation_id: 'case-id', + }); }); it('it formats correctly when fields do not exist ', async () => { @@ -29,6 +34,8 @@ describe('ITSM formatter', () => { impact: null, category: null, subcategory: null, + correlation_display: 'Elastic Case', + correlation_id: null, }); }); }); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts index bc9d50026d1f8..1859ea1246f21 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts @@ -16,5 +16,13 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => { category = null, subcategory = null, } = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; - return { severity, urgency, impact, category, subcategory }; + return { + severity, + urgency, + impact, + category, + subcategory, + correlation_id: theCase.id ?? null, + correlation_display: 'Elastic Case', + }; }; diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts index fa103d4c1142d..b09272d0a5505 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts @@ -10,6 +10,7 @@ import { format } from './sir_format'; describe('ITSM formatter', () => { const theCase = { + id: 'case-id', connector: { fields: { destIp: true, @@ -26,13 +27,15 @@ describe('ITSM formatter', () => { it('it formats correctly without alerts', async () => { const res = await format(theCase, []); expect(res).toEqual({ - dest_ip: null, - source_ip: null, + dest_ip: [], + source_ip: [], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: null, - malware_url: null, + malware_hash: [], + malware_url: [], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -40,13 +43,15 @@ describe('ITSM formatter', () => { const invalidFields = { connector: { fields: null } } as CaseResponse; const res = await format(invalidFields, []); expect(res).toEqual({ - dest_ip: null, - source_ip: null, + dest_ip: [], + source_ip: [], category: null, subcategory: null, - malware_hash: null, - malware_url: null, + malware_hash: [], + malware_url: [], priority: null, + correlation_display: 'Elastic Case', + correlation_id: null, }); }); @@ -75,14 +80,18 @@ describe('ITSM formatter', () => { ]; const res = await format(theCase, alerts); expect(res).toEqual({ - dest_ip: '192.168.1.1,192.168.1.4', - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: ['192.168.1.1', '192.168.1.4'], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: - '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: [ + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', + ], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -111,13 +120,15 @@ describe('ITSM formatter', () => { ]; const res = await format(theCase, alerts); expect(res).toEqual({ - dest_ip: '192.168.1.1', - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: ['192.168.1.1'], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: ['9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -152,13 +163,15 @@ describe('ITSM formatter', () => { const res = await format(newCase, alerts); expect(res).toEqual({ - dest_ip: null, - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: [], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: null, - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: [], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); }); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts index b48a1b7f734c8..9108408c4d089 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts @@ -32,11 +32,11 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { malware_url: new Set(), }; - let sirFields: Record = { - dest_ip: null, - source_ip: null, - malware_hash: null, - malware_url: null, + let sirFields: Record = { + dest_ip: [], + source_ip: [], + malware_hash: [], + malware_url: [], }; const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter( @@ -44,18 +44,17 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { ); if (fieldsToAdd.length > 0) { - sirFields = alerts.reduce>((acc, alert) => { + sirFields = alerts.reduce>((acc, alert) => { fieldsToAdd.forEach((alertField) => { const field = get(alertFieldMapping[alertField].alertPath, alert); if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) { manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field); acc = { ...acc, - [alertFieldMapping[alertField].sirFieldKey]: `${ - acc[alertFieldMapping[alertField].sirFieldKey] != null - ? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}` - : field - }`, + [alertFieldMapping[alertField].sirFieldKey]: [ + ...acc[alertFieldMapping[alertField].sirFieldKey], + field, + ], }; } }); @@ -68,5 +67,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { category, subcategory, priority, + correlation_id: theCase.id ?? null, + correlation_display: 'Elastic Case', }; }; diff --git a/x-pack/plugins/cases/server/connectors/servicenow/types.ts b/x-pack/plugins/cases/server/connectors/servicenow/types.ts index 2caebc3dab316..b0e71cbe5e743 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/types.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/types.ts @@ -8,13 +8,18 @@ import { ServiceNowITSMFieldsType } from '../../../common'; import { ICasesConnector } from '../types'; -export interface ServiceNowSIRFieldsType { - dest_ip: string | null; - source_ip: string | null; +interface CorrelationValues { + correlation_id: string | null; + correlation_display: string | null; +} + +export interface ServiceNowSIRFieldsType extends CorrelationValues { + dest_ip: string[] | null; + source_ip: string[] | null; category: string | null; subcategory: string | null; - malware_hash: string | null; - malware_url: string | null; + malware_hash: string[] | null; + malware_url: string[] | null; priority: string | null; } @@ -26,7 +31,9 @@ export type AlertFieldMappingAndValues = Record< // ServiceNow ITSM export type ServiceNowITSMCasesConnector = ICasesConnector; -export type ServiceNowITSMFormat = ICasesConnector['format']; +export type ServiceNowITSMFormat = ICasesConnector< + ServiceNowITSMFieldsType & CorrelationValues +>['format']; export type ServiceNowITSMGetMapping = ICasesConnector['getMapping']; // ServiceNow SIR diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d2120faf09dfb..51511fad90b30 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -301,6 +301,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.swimlane', '.webhook', '.servicenow', + '.servicenow-sir', '.jira', '.resilient', '.teams', diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts index aa1bd7a5db5cc..a53e37f363d05 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getServiceNowConnector } from '../../objects/case'; +import { getServiceNowConnector, getServiceNowITSMHealthResponse } from '../../objects/case'; import { SERVICE_NOW_MAPPING, TOASTER } from '../../screens/configure_cases'; @@ -43,8 +43,16 @@ describe('Cases connectors', () => { id: '123', owner: 'securitySolution', }; + + const snConnector = getServiceNowConnector(); + beforeEach(() => { cleanKibana(); + cy.intercept('GET', `${snConnector.URL}/api/x_elas2_inc_int/elastic_api/health*`, { + statusCode: 200, + body: getServiceNowITSMHealthResponse(), + }); + cy.intercept('POST', '/api/actions/connector').as('createConnector'); cy.intercept('POST', '/api/cases/configure', (req) => { const connector = req.body.connector; @@ -52,6 +60,7 @@ describe('Cases connectors', () => { res.send(200, { ...configureResult, connector }); }); }).as('saveConnector'); + cy.intercept('GET', '/api/cases/configure', (req) => { req.reply((res) => { const resBody = @@ -77,7 +86,7 @@ describe('Cases connectors', () => { loginAndWaitForPageWithoutDateRange(CASES_URL); goToEditExternalConnection(); openAddNewConnectorOption(); - addServiceNowConnector(getServiceNowConnector()); + addServiceNowConnector(snConnector); cy.wait('@createConnector').then(({ response }) => { cy.wrap(response!.statusCode).should('eql', 200); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index af9b34f542046..b0bfdbf16c705 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -44,6 +44,14 @@ export interface IbmResilientConnectorOptions { incidentTypes: string[]; } +interface ServiceNowHealthResponse { + result: { + name: string; + scope: string; + version: string; + }; +} + export const getCase1 = (): TestCase => ({ name: 'This is the title of the case', tags: ['Tag1', 'Tag2'], @@ -60,6 +68,14 @@ export const getServiceNowConnector = (): Connector => ({ password: 'password', }); +export const getServiceNowITSMHealthResponse = (): ServiceNowHealthResponse => ({ + result: { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }, +}); + export const getJiraConnectorOptions = (): JiraConnectorOptions => ({ issueType: '10006', priority: 'High', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 642668bcecf34..54ffd60d0dc16 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25052,7 +25052,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "選択肢を取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "緊急", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "ユーザー名", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "Personal Developer Instance の構成", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "ServiceNow ITSMでインシデントを作成します。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4d86994c0fb84..55e4a913c0e09 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25480,7 +25480,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "无法获取选项", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "紧急性", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "用户名", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "配置个人开发者实例", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "在 ServiceNow ITSM 中创建事件。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx index 0b446b99c93dc..a96e1fc3dcb5d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -35,6 +35,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); @@ -66,6 +68,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); @@ -99,6 +103,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').first().prop('value')).toBe( @@ -132,6 +138,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy(); @@ -165,6 +173,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(true); @@ -199,6 +209,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(false); @@ -223,6 +235,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -245,6 +259,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -268,6 +284,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index e804ce2a9f54d..9ef498334ad3d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -71,6 +71,8 @@ describe('IndexActionConnectorFields renders', () => { editActionSecrets: () => {}, errors: { index: [] }, readOnly: false, + setCallbacks: () => {}, + isEdit: false, }; const wrapper = mountWithIntl(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index be5250ccf8b29..4859c25adcc06 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -34,6 +34,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -74,6 +76,8 @@ describe('JiraActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); @@ -104,6 +108,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -125,6 +131,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -152,6 +160,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx index 86347de528a01..8be15ddaa6bca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -33,6 +33,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -61,6 +63,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -86,6 +90,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -112,6 +118,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index bbd237a7cec89..35891f513be6b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -34,6 +34,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -74,6 +76,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -105,6 +109,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -126,6 +132,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -153,6 +161,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts index ba820efc8111f..4b67d256d99bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts @@ -6,7 +6,7 @@ */ import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; -import { getChoices } from './api'; +import { getChoices, getAppInfo } from './api'; const choicesResponse = { status: 'ok', @@ -44,10 +44,27 @@ const choicesResponse = { ], }; +const applicationInfoData = { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, +}; + +const applicationInfoResponse = { + ok: true, + status: 200, + json: async () => applicationInfoData, +}; + describe('ServiceNow API', () => { const http = httpServiceMock.createStartContract(); + let fetchMock: jest.SpyInstance>; - beforeEach(() => jest.resetAllMocks()); + beforeAll(() => { + fetchMock = jest.spyOn(window, 'fetch'); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); describe('getChoices', () => { test('should call get choices API', async () => { @@ -67,4 +84,96 @@ describe('ServiceNow API', () => { }); }); }); + + describe('getAppInfo', () => { + test('should call getAppInfo API for ITSM', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + const res = await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }); + + expect(res).toEqual(applicationInfoData.result); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/api/x_elas2_inc_int/elastic_api/health', + { + signal: abortCtrl.signal, + method: 'GET', + headers: { Authorization: 'Basic dGVzdDp0ZXN0' }, + } + ); + }); + + test('should call getAppInfo API correctly for SIR', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + const res = await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow-sir', + }); + + expect(res).toEqual(applicationInfoData.result); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + { + signal: abortCtrl.signal, + method: 'GET', + headers: { Authorization: 'Basic dGVzdDp0ZXN0' }, + } + ); + }); + + it('returns an error when the response fails', async () => { + expect.assertions(1); + + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => applicationInfoResponse.json, + }); + + await expect(() => + getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }) + ).rejects.toThrow('Received status:'); + }); + + it('returns an error when parsing the json fails', async () => { + expect.assertions(1); + + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('bad'); + }, + }); + + await expect(() => + getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }) + ).rejects.toThrow('bad'); + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index 62347580e75ca..32a2d0296d4c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -6,7 +6,11 @@ */ import { HttpSetup } from 'kibana/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config'; import { BASE_ACTION_API_PATH } from '../../../constants'; +import { API_INFO_ERROR } from './translations'; +import { AppInfo, RESTApiError } from './types'; export async function getChoices({ http, @@ -29,3 +33,43 @@ export async function getChoices({ } ); } + +/** + * The app info url should be the same as at: + * x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts + */ +const getAppInfoUrl = (url: string, scope: string) => `${url}/api/${scope}/elastic_api/health`; + +export async function getAppInfo({ + signal, + apiUrl, + username, + password, + actionTypeId, +}: { + signal: AbortSignal; + apiUrl: string; + username: string; + password: string; + actionTypeId: string; +}): Promise { + const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; + const config = snExternalServiceConfig[actionTypeId]; + const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash, config.appScope ?? ''), { + method: 'GET', + signal, + headers: { + Authorization: 'Basic ' + btoa(username + ':' + password), + }, + }); + + if (!response.ok) { + throw new Error(API_INFO_ERROR(response.status)); + } + + const data = await response.json(); + + return { + ...data.result, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx new file mode 100644 index 0000000000000..67c3238b04774 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ApplicationRequiredCallout } from './application_required_callout'; + +describe('ApplicationRequiredCallout', () => { + test('it renders the callout', () => { + render(); + expect(screen.getByText('Elastic ServiceNow App not installed')).toBeInTheDocument(); + expect( + screen.getByText('Please go to the ServiceNow app store and install the application') + ).toBeInTheDocument(); + }); + + test('it renders the ServiceNow store button', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + test('it renders an error message if provided', () => { + render(); + expect(screen.getByText('Error message: Denied')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx new file mode 100644 index 0000000000000..561dae95fe1b7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SNStoreButton } from './sn_store_button'; + +const content = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.content', + { + defaultMessage: 'Please go to the ServiceNow app store and install the application', + } +); + +const ERROR_MESSAGE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.errorMessage', + { + defaultMessage: 'Error message', + } +); + +interface Props { + message?: string | null; +} + +const ApplicationRequiredCalloutComponent: React.FC = ({ message }) => { + return ( + <> + + +

{content}

+ {message && ( +

+ {ERROR_MESSAGE}: {message} +

+ )} + +
+ + + ); +}; + +export const ApplicationRequiredCallout = memo(ApplicationRequiredCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts new file mode 100644 index 0000000000000..9d5fafbf5a0ea --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const UPDATE_INCIDENT_VARIABLE = '{{rule.id}}'; +export const NOT_UPDATE_INCIDENT_VARIABLE = '{{rule.id}}:{{alert.id}}'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx new file mode 100644 index 0000000000000..caee946524265 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiFieldText, + EuiSpacer, + EuiTitle, + EuiFieldPassword, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { useKibana } from '../../../../common/lib/kibana'; +import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; +import * as i18n from './translations'; +import { ServiceNowActionConnector } from './types'; +import { isFieldInvalid } from './helpers'; + +interface Props { + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; +} + +const CredentialsComponent: React.FC = ({ + action, + errors, + readOnly, + isLoading, + editActionSecrets, + editActionConfig, +}) => { + const { docLinks } = useKibana().services; + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl); + const isUsernameInvalid = isFieldInvalid(username, errors.username); + const isPasswordInvalid = isFieldInvalid(password, errors.password); + + const handleOnChangeActionConfig = useCallback( + (key: string, value: string) => editActionConfig(key, value), + [editActionConfig] + ); + + const handleOnChangeSecretConfig = useCallback( + (key: string, value: string) => editActionSecrets(key, value), + [editActionSecrets] + ); + + return ( + <> + + + +

{i18n.SN_INSTANCE_LABEL}

+
+

+ + {i18n.SETUP_DEV_INSTANCE} + + ), + }} + /> +

+
+ + + handleOnChangeActionConfig('apiUrl', evt.target.value)} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + disabled={isLoading} + /> + + +
+ + + + +

{i18n.AUTHENTICATION_LABEL}

+
+
+
+ + + + + {getEncryptedFieldNotifyLabel( + !action.id, + 2, + action.isMissingSecrets ?? false, + i18n.REENTER_VALUES_LABEL + )} + + + + + + + + handleOnChangeSecretConfig('username', evt.target.value)} + onBlur={() => { + if (!username) { + editActionSecrets('username', ''); + } + }} + disabled={isLoading} + /> + + + + + + + + handleOnChangeSecretConfig('password', evt.target.value)} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + disabled={isLoading} + /> + + + + + ); +}; + +export const Credentials = memo(CredentialsComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx new file mode 100644 index 0000000000000..767b38ebcf6ad --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n/react'; + +import { DeprecatedCallout } from './deprecated_callout'; + +describe('DeprecatedCallout', () => { + const onMigrate = jest.fn(); + + test('it renders correctly', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + }); + + test('it calls onMigrate when pressing the button', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(onMigrate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx new file mode 100644 index 0000000000000..101d1572a67ad --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut, EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + onMigrate: () => void; +} + +const DeprecatedCalloutComponent: React.FC = ({ onMigrate }) => { + return ( + <> + + + + {i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate', + { + defaultMessage: 'update this connector.', + } + )} + + ), + }} + /> + + + + ); +}; + +export const DeprecatedCallout = memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts new file mode 100644 index 0000000000000..e37d8dd3b4147 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isRESTApiError, isFieldInvalid } from './helpers'; + +describe('helpers', () => { + describe('isRESTApiError', () => { + const resError = { error: { message: 'error', detail: 'access denied' }, status: '401' }; + + test('should return true if the error is RESTApiError', async () => { + expect(isRESTApiError(resError)).toBeTruthy(); + }); + + test('should return true if there is failure status', async () => { + // @ts-expect-error + expect(isRESTApiError({ status: 'failure' })).toBeTruthy(); + }); + + test('should return false if there is no error', async () => { + // @ts-expect-error + expect(isRESTApiError({ whatever: 'test' })).toBeFalsy(); + }); + }); + + describe('isFieldInvalid', () => { + test('should return true if the field is invalid', async () => { + expect(isFieldInvalid('description', ['required'])).toBeTruthy(); + }); + + test('should return if false the field is not defined', async () => { + expect(isFieldInvalid(undefined, ['required'])).toBeFalsy(); + }); + + test('should return if false the error is not defined', async () => { + // @ts-expect-error + expect(isFieldInvalid('description', undefined)).toBeFalsy(); + }); + + test('should return if false the error is empty', async () => { + expect(isFieldInvalid('description', [])).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index 314d224491128..ca557b31c4f4f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -6,7 +6,38 @@ */ import { EuiSelectOption } from '@elastic/eui'; -import { Choice } from './types'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../actions/server/constants/connectors'; +import { IErrorObject } from '../../../../../public/types'; +import { AppInfo, Choice, RESTApiError, ServiceNowActionConnector } from './types'; export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => choices.map((choice) => ({ value: choice.value, text: choice.label })); + +export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError => + (res as RESTApiError).error != null || (res as RESTApiError).status === 'failure'; + +export const isFieldInvalid = ( + field: string | undefined, + error: string | IErrorObject | string[] +): boolean => error !== undefined && error.length > 0 && field !== undefined; + +// TODO: Remove when the applications are certified +export const isLegacyConnector = (connector: ServiceNowActionConnector) => { + if (connector == null) { + return true; + } + + if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') { + return true; + } + + if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') { + return true; + } + + return connector.config.isLegacy; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx new file mode 100644 index 0000000000000..8e1c1820920c5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { InstallationCallout } from './installation_callout'; + +describe('DeprecatedCallout', () => { + test('it renders correctly', () => { + render(); + expect( + screen.getByText( + 'To use this connector, you must first install the Elastic App from the ServiceNow App Store' + ) + ).toBeInTheDocument(); + }); + + test('it renders the button', () => { + render(); + expect(screen.getByRole('link')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx new file mode 100644 index 0000000000000..064207910568f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; + +import * as i18n from './translations'; +import { SNStoreButton } from './sn_store_button'; + +const InstallationCalloutComponent: React.FC = () => { + return ( + <> + + + + + + + ); +}; + +export const InstallationCallout = memo(InstallationCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index f1516f880dce4..b40db9c2dabda 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -43,6 +43,7 @@ describe('servicenow connector validation', () => { isPreconfigured: false, config: { apiUrl: 'https://dev94428.service-now.com/', + isLegacy: false, }, } as ServiceNowActionConnector; @@ -50,6 +51,7 @@ describe('servicenow connector validation', () => { config: { errors: { apiUrl: [], + isLegacy: [], }, }, secrets: { @@ -77,6 +79,7 @@ describe('servicenow connector validation', () => { config: { errors: { apiUrl: ['URL is required.'], + isLegacy: [], }, }, secrets: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index 24e2a87d42357..bb4a645f10bbc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -27,6 +27,7 @@ const validateConnector = async ( const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), + isLegacy: new Array(), }; const secretsErrors = { username: new Array(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 4993c51f350ad..02f3ae47728ab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -33,6 +33,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect( @@ -57,8 +59,7 @@ describe('ServiceNowActionConnectorFields renders', () => { name: 'servicenow', config: { apiUrl: 'https://test/', - incidentConfiguration: { mapping: [] }, - isCaseOwned: true, + isLegacy: false, }, } as ServiceNowActionConnector; const wrapper = mountWithIntl( @@ -69,6 +70,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); @@ -91,6 +94,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -112,6 +117,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -138,6 +145,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 29a6bca4b16ab..2cf738c5e0c13 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -5,162 +5,142 @@ * 2.0. */ -import React, { useCallback } from 'react'; - -import { - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiFieldPassword, - EuiSpacer, - EuiLink, - EuiTitle, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useCallback, useEffect, useState } from 'react'; + import { ActionConnectorFieldsProps } from '../../../../types'; import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; import { useKibana } from '../../../../common/lib/kibana'; -import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; +import { DeprecatedCallout } from './deprecated_callout'; +import { useGetAppInfo } from './use_get_app_info'; +import { ApplicationRequiredCallout } from './application_required_callout'; +import { isRESTApiError, isLegacyConnector } from './helpers'; +import { InstallationCallout } from './installation_callout'; +import { UpdateConnectorModal } from './update_connector_modal'; +import { updateActionConnector } from '../../../lib/action_connector_api'; +import { Credentials } from './credentials'; const ServiceNowConnectorFields: React.FC> = - ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { - const { docLinks } = useKibana().services; + ({ + action, + editActionSecrets, + editActionConfig, + errors, + consumer, + readOnly, + setCallbacks, + isEdit, + }) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; const { apiUrl } = action.config; + const { username, password } = action.secrets; + const isOldConnector = isLegacyConnector(action); - const isApiUrlInvalid: boolean = - errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; + const [showModal, setShowModal] = useState(false); - const { username, password } = action.secrets; + const { fetchAppInfo, isLoading } = useGetAppInfo({ + actionTypeId: action.actionTypeId, + }); - const isUsernameInvalid: boolean = - errors.username !== undefined && errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = - errors.password !== undefined && errors.password.length > 0 && password !== undefined; + const [applicationRequired, setApplicationRequired] = useState(false); + const [applicationInfoErrorMsg, setApplicationInfoErrorMsg] = useState(null); - const handleOnChangeActionConfig = useCallback( - (key: string, value: string) => editActionConfig(key, value), - [editActionConfig] - ); + const getApplicationInfo = useCallback(async () => { + setApplicationRequired(false); + setApplicationInfoErrorMsg(null); + + try { + const res = await fetchAppInfo(action); + if (isRESTApiError(res)) { + throw new Error(res.error?.message ?? i18n.UNKNOWN); + } + + return res; + } catch (e) { + setApplicationRequired(true); + setApplicationInfoErrorMsg(e.message); + // We need to throw here so the connector will be not be saved. + throw e; + } + }, [action, fetchAppInfo]); - const handleOnChangeSecretConfig = useCallback( - (key: string, value: string) => editActionSecrets(key, value), - [editActionSecrets] + const beforeActionConnectorSave = useCallback(async () => { + if (!isOldConnector) { + await getApplicationInfo(); + } + }, [getApplicationInfo, isOldConnector]); + + useEffect( + () => setCallbacks({ beforeActionConnectorSave }), + [beforeActionConnectorSave, setCallbacks] ); + + const onMigrateClick = useCallback(() => setShowModal(true), []); + const onModalCancel = useCallback(() => setShowModal(false), []); + + const onModalConfirm = useCallback(async () => { + await getApplicationInfo(); + await updateActionConnector({ + http, + connector: { + name: action.name, + config: { apiUrl, isLegacy: false }, + secrets: { username, password }, + }, + id: action.id, + }); + + editActionConfig('isLegacy', false); + setShowModal(false); + + toasts.addSuccess({ + title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), + text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, + }); + }, [ + getApplicationInfo, + http, + action.name, + action.id, + apiUrl, + username, + password, + editActionConfig, + toasts, + ]); + return ( <> - - - - - - } - > - handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - /> - - - - - - - -

{i18n.AUTHENTICATION_LABEL}

-
-
-
- - - - - {getEncryptedFieldNotifyLabel( - !action.id, - 2, - action.isMissingSecrets ?? false, - i18n.REENTER_VALUES_LABEL - )} - - - - - - - - handleOnChangeSecretConfig('username', evt.target.value)} - onBlur={() => { - if (!username) { - editActionSecrets('username', ''); - } - }} - /> - - - - - - - - handleOnChangeSecretConfig('password', evt.target.value)} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } - }} - /> - - - + {showModal && ( + + )} + {!isOldConnector && } + {isOldConnector && } + + {applicationRequired && !isOldConnector && ( + + )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index e864a8d3fd114..30e09356e95dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -31,6 +31,8 @@ const actionParams = { category: 'software', subcategory: 'os', externalId: null, + correlation_id: 'alertID', + correlation_display: 'Alerting', }, comments: [], }, @@ -144,7 +146,10 @@ describe('ServiceNowITSMParamsFields renders', () => { }; mount(); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); @@ -166,7 +171,10 @@ describe('ServiceNowITSMParamsFields renders', () => { wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index b243afb375e6d..81428cd7f0a73 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -13,16 +13,18 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiSwitch, } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; -import { ServiceNowITSMActionParams, Choice, Fields } from './types'; +import { ServiceNowITSMActionParams, Choice, Fields, ServiceNowActionConnector } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; import { useGetChoices } from './use_get_choices'; -import { choicesToEuiOptions } from './helpers'; +import { choicesToEuiOptions, isLegacyConnector } from './helpers'; import * as i18n from './translations'; +import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -42,6 +44,8 @@ const ServiceNowParamsFields: React.FunctionComponent< notifications: { toasts }, } = useKibana().services; + const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( () => @@ -53,8 +57,13 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); + const hasUpdateIncident = + incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; + const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); + const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; + const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -90,6 +99,14 @@ const ServiceNowParamsFields: React.FunctionComponent< ); }, []); + const onUpdateIncidentSwitchChange = useCallback(() => { + const newCorrelationID = !updateIncident + ? UPDATE_INCIDENT_VARIABLE + : NOT_UPDATE_INCIDENT_VARIABLE; + editSubActionProperty('correlation_id', newCorrelationID); + setUpdateIncident(!updateIncident); + }, [editSubActionProperty, updateIncident]); + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); @@ -119,7 +136,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -136,7 +153,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -236,25 +253,43 @@ const ServiceNowParamsFields: React.FunctionComponent<
- 0 && - incident.short_description !== undefined - } - label={i18n.SHORT_DESCRIPTION_LABEL} - > - - + + + 0 && + incident.short_description !== undefined + } + label={i18n.SHORT_DESCRIPTION_LABEL} + > + + + + {!isOldConnector && ( + + + + + + )} + + { }; mount(); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); @@ -196,7 +201,10 @@ describe('ServiceNowSIRParamsFields renders', () => { wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 0ba52014fa1f9..7b7cfc67d9971 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiSwitch, } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; @@ -21,8 +22,9 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import * as i18n from './translations'; import { useGetChoices } from './use_get_choices'; -import { ServiceNowSIRActionParams, Fields, Choice } from './types'; -import { choicesToEuiOptions } from './helpers'; +import { ServiceNowSIRActionParams, Fields, Choice, ServiceNowActionConnector } from './types'; +import { choicesToEuiOptions, isLegacyConnector } from './helpers'; +import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -31,6 +33,14 @@ const defaultFields: Fields = { priority: [], }; +const valuesToString = (value: string | string[] | null): string | undefined => { + if (Array.isArray(value)) { + return value.join(','); + } + + return value ?? undefined; +}; + const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { @@ -39,6 +49,8 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< notifications: { toasts }, } = useKibana().services; + const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( () => @@ -50,8 +62,13 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); + const hasUpdateIncident = + incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; + const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); + const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; + const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -87,6 +104,14 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< ); }, []); + const onUpdateIncidentSwitchChange = useCallback(() => { + const newCorrelationID = !updateIncident + ? UPDATE_INCIDENT_VARIABLE + : NOT_UPDATE_INCIDENT_VARIABLE; + editSubActionProperty('correlation_id', newCorrelationID); + setUpdateIncident(!updateIncident); + }, [editSubActionProperty, updateIncident]); + const { isLoading: isLoadingChoices } = useGetChoices({ http, toastNotifications: toasts, @@ -115,7 +140,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -132,7 +157,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -162,48 +187,48 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'short_description'} - inputTargetValue={incident?.short_description ?? undefined} + inputTargetValue={incident?.short_description} errors={errors['subActionParams.incident.short_description'] as string[]} /> - + - + - + - + @@ -277,6 +302,18 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined} label={i18n.COMMENTS_LABEL} /> + + {!isOldConnector && ( + + + + )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx new file mode 100644 index 0000000000000..fe73653234170 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SNStoreButton } from './sn_store_button'; + +describe('SNStoreButton', () => { + test('it renders the button', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + test('it renders a danger button', () => { + render(); + expect(screen.getByRole('link')).toHaveClass('euiButton--danger'); + }); + + test('it renders with correct href', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx new file mode 100644 index 0000000000000..5921f679d3f50 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiButtonProps, EuiButton } from '@elastic/eui'; + +import * as i18n from './translations'; + +const STORE_URL = 'https://store.servicenow.com/'; + +interface Props { + color: EuiButtonProps['color']; +} + +const SNStoreButtonComponent: React.FC = ({ color }) => { + return ( + + {i18n.VISIT_SN_STORE} + + ); +}; + +export const SNStoreButton = memo(SNStoreButtonComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index ea646b896f5e9..90292a35a88df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -10,7 +10,14 @@ import { i18n } from '@kbn/i18n'; export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel', { - defaultMessage: 'URL', + defaultMessage: 'ServiceNow instance URL', + } +); + +export const API_URL_HELPTEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlHelpText', + { + defaultMessage: 'Include the full URL', } ); @@ -53,7 +60,7 @@ export const REMEMBER_VALUES_LABEL = i18n.translate( export const REENTER_VALUES_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel', { - defaultMessage: 'Username and password are encrypted. Please reenter values for these fields.', + defaultMessage: 'You will need to re-authenticate each time you edit the connector', } ); @@ -95,14 +102,28 @@ export const TITLE_REQUIRED = i18n.translate( export const SOURCE_IP_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle', { - defaultMessage: 'Source IP', + defaultMessage: 'Source IPs', + } +); + +export const SOURCE_IP_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPHelpText', + { + defaultMessage: 'List of source IPs (comma, or pipe delimited)', } ); export const DEST_IP_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle', { - defaultMessage: 'Destination IP', + defaultMessage: 'Destination IPs', + } +); + +export const DEST_IP_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destIPHelpText', + { + defaultMessage: 'List of destination IPs (comma, or pipe delimited)', } ); @@ -137,14 +158,28 @@ export const COMMENTS_LABEL = i18n.translate( export const MALWARE_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle', { - defaultMessage: 'Malware URL', + defaultMessage: 'Malware URLs', + } +); + +export const MALWARE_URL_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLHelpText', + { + defaultMessage: 'List of malware URLs (comma, or pipe delimited)', } ); export const MALWARE_HASH_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', { - defaultMessage: 'Malware Hash', + defaultMessage: 'Malware Hashes', + } +); + +export const MALWARE_HASH_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashHelpText', + { + defaultMessage: 'List of malware hashes (comma, or pipe delimited)', } ); @@ -196,3 +231,91 @@ export const PRIORITY_LABEL = i18n.translate( defaultMessage: 'Priority', } ); + +export const API_INFO_ERROR = (status: number) => + i18n.translate('xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiInfoError', { + values: { status }, + defaultMessage: 'Received status: {status} when attempting to get application information', + }); + +export const INSTALL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.install', + { + defaultMessage: 'install', + } +); + +export const INSTALLATION_CALLOUT_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutTitle', + { + defaultMessage: + 'To use this connector, you must first install the Elastic App from the ServiceNow App Store', + } +); + +export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) => + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.migrationSuccessToastTitle', + { + defaultMessage: 'Migrated connector {connectorName}', + values: { + connectorName, + }, + } + ); + +export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutText', + { + defaultMessage: 'Connector has been successfully migrated.', + } +); + +export const VISIT_SN_STORE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.visitSNStore', + { + defaultMessage: 'Visit ServiceNow app store', + } +); + +export const SETUP_DEV_INSTANCE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.setupDevInstance', + { + defaultMessage: 'setup a developer instance', + } +); + +export const SN_INSTANCE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.snInstanceLabel', + { + defaultMessage: 'ServiceNow instance', + } +); + +export const UNKNOWN = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unknown', + { + defaultMessage: 'UNKNOWN', + } +); + +export const UPDATE_INCIDENT_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentCheckboxLabel', + { + defaultMessage: 'Update incident', + } +); + +export const ON = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOn', + { + defaultMessage: 'On', + } +); + +export const OFF = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOff', + { + defaultMessage: 'Off', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index f252f4648e670..b24883359dde5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -29,6 +29,7 @@ export interface ServiceNowSIRActionParams { export interface ServiceNowConfig { apiUrl: string; + isLegacy: boolean; } export interface ServiceNowSecrets { @@ -44,3 +45,17 @@ export interface Choice { } export type Fields = Record; +export interface AppInfo { + id: string; + name: string; + scope: string; + version: string; +} + +export interface RESTApiError { + error: { + message: string; + detail: string; + }; + status: string; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx new file mode 100644 index 0000000000000..b9d660f16dff7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiCallOut, + EuiTextColor, + EuiHorizontalRule, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { ServiceNowActionConnector } from './types'; +import { Credentials } from './credentials'; +import { isFieldInvalid } from './helpers'; +import { ApplicationRequiredCallout } from './application_required_callout'; + +const title = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', + { + defaultMessage: 'Update ServiceNow connector', + } +); + +const cancelButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', + { + defaultMessage: 'Cancel', + } +); + +const confirmButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', + { + defaultMessage: 'Update', + } +); + +const calloutTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalCalloutTitle', + { + defaultMessage: + 'The Elastic App from the ServiceNow App Store must be installed prior to running the update.', + } +); + +const warningMessage = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalWarningMessage', + { + defaultMessage: 'This will update all instances of this connector. This can not be reversed.', + } +); + +interface Props { + action: ActionConnectorFieldsProps['action']; + applicationInfoErrorMsg: string | null; + errors: ActionConnectorFieldsProps['errors']; + isLoading: boolean; + readOnly: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; + onCancel: () => void; + onConfirm: () => void; +} + +const UpdateConnectorModalComponent: React.FC = ({ + action, + applicationInfoErrorMsg, + errors, + isLoading, + readOnly, + editActionSecrets, + editActionConfig, + onCancel, + onConfirm, +}) => { + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const hasErrorsOrEmptyFields = + apiUrl === undefined || + username === undefined || + password === undefined || + isFieldInvalid(apiUrl, errors.apiUrl) || + isFieldInvalid(username, errors.username) || + isFieldInvalid(password, errors.password); + + return ( + + + +

{title}

+
+
+ + + + + + + + + + + {warningMessage} + + + + + {applicationInfoErrorMsg && ( + + )} + + + + + {cancelButtonText} + + {confirmButtonText} + + +
+ ); +}; + +export const UpdateConnectorModal = memo(UpdateConnectorModalComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx new file mode 100644 index 0000000000000..c6b70443ec8fb --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useGetAppInfo, UseGetAppInfo, UseGetAppInfoProps } from './use_get_app_info'; +import { getAppInfo } from './api'; +import { ServiceNowActionConnector } from './types'; + +jest.mock('./api'); +jest.mock('../../../../common/lib/kibana'); + +const getAppInfoMock = getAppInfo as jest.Mock; + +const actionTypeId = '.servicenow'; +const applicationInfoData = { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', +}; + +const actionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow ITSM', + isPreconfigured: false, + config: { + apiUrl: 'https://test.service-now.com/', + isLegacy: false, + }, +} as ServiceNowActionConnector; + +describe('useGetAppInfo', () => { + getAppInfoMock.mockResolvedValue(applicationInfoData); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + fetchAppInfo: result.current.fetchAppInfo, + }); + }); + + it('returns the application information', async () => { + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + let res; + + await act(async () => { + res = await result.current.fetchAppInfo(actionConnector); + }); + + expect(res).toEqual(applicationInfoData); + }); + + it('it throws an error when api fails', async () => { + expect.assertions(1); + getAppInfoMock.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + await expect(() => + act(async () => { + await result.current.fetchAppInfo(actionConnector); + }) + ).rejects.toThrow('An error occurred'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx new file mode 100644 index 0000000000000..a211c8dda66b7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { getAppInfo } from './api'; +import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types'; + +export interface UseGetAppInfoProps { + actionTypeId: string; +} + +export interface UseGetAppInfo { + fetchAppInfo: (connector: ServiceNowActionConnector) => Promise; + isLoading: boolean; +} + +export const useGetAppInfo = ({ actionTypeId }: UseGetAppInfoProps): UseGetAppInfo => { + const [isLoading, setIsLoading] = useState(false); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + const fetchAppInfo = useCallback( + async (connector) => { + try { + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getAppInfo({ + signal: abortCtrl.current.signal, + apiUrl: connector.config.apiUrl, + username: connector.secrets.username, + password: connector.secrets.password, + actionTypeId, + }); + + if (!didCancel.current) { + setIsLoading(false); + } + + return res; + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + } + throw error; + } + }, + [actionTypeId] + ); + + useEffect(() => { + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + setIsLoading(false); + }; + }, []); + + return { + fetchAppInfo, + isLoading, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx index 547346054011b..0a37165bd7f5f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -30,6 +30,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -56,6 +58,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -76,6 +80,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -98,6 +104,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts index 90bab65b83bfd..00262c3265d7a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts @@ -39,29 +39,28 @@ describe('Swimlane API', () => { }); it('returns an error when the response fails', async () => { + expect.assertions(1); const abortCtrl = new AbortController(); - fetchMock.mockResolvedValueOnce({ ok: false, status: 401, json: async () => getApplicationResponse, }); - try { - await getApplication({ + await expect(() => + getApplication({ signal: abortCtrl.signal, apiToken: '', appId: '', url: '', - }); - } catch (e) { - expect(e.message).toContain('Received status:'); - } + }) + ).rejects.toThrow('Received status:'); }); it('returns an error when parsing the json fails', async () => { - const abortCtrl = new AbortController(); + expect.assertions(1); + const abortCtrl = new AbortController(); fetchMock.mockResolvedValueOnce({ ok: true, status: 200, @@ -70,16 +69,14 @@ describe('Swimlane API', () => { }, }); - try { - await getApplication({ + await expect(() => + getApplication({ signal: abortCtrl.signal, apiToken: '', appId: '', url: '', - }); - } catch (e) { - expect(e.message).toContain('bad'); - } + }) + ).rejects.toThrow('bad'); }); it('it removes unsafe fields', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx index 6740179d786f2..4829156380e94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx @@ -50,6 +50,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -77,6 +79,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -106,6 +110,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -139,6 +145,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -184,6 +192,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -229,6 +239,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -285,6 +297,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx index 11c747125595d..5031b32281258 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx @@ -30,6 +30,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -56,6 +58,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -79,6 +83,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -103,6 +109,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx index c041b4e3e1e42..ea40c1ddfb139 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -35,6 +35,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); @@ -62,6 +64,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -92,6 +96,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -123,6 +129,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 091ea1e305e35..5a4d682ff573b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -49,6 +49,8 @@ describe('action_connector_form', () => { dispatch={() => {}} errors={{ name: [] }} actionTypeRegistry={actionTypeRegistry} + setCallbacks={() => {}} + isEdit={false} /> ); const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index f61a0f8f52904..5ee294b6dbd52 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -24,6 +24,7 @@ import { ActionTypeRegistryContract, UserConfiguredActionConnector, ActionTypeModel, + ActionConnectorFieldsSetCallbacks, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useKibana } from '../../../common/lib/kibana'; @@ -89,6 +90,8 @@ interface ActionConnectorProps< serverError?: { body: { message: string; error: string }; }; + setCallbacks: ActionConnectorFieldsSetCallbacks; + isEdit: boolean; } export const ActionConnectorForm = ({ @@ -99,6 +102,8 @@ export const ActionConnectorForm = ({ errors, actionTypeRegistry, consumer, + setCallbacks, + isEdit, }: ActionConnectorProps) => { const { docLinks, @@ -237,6 +242,8 @@ export const ActionConnectorForm = ({ editActionConfig={setActionConfigProperty} editActionSecrets={setActionSecretsProperty} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={isEdit} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 4dcf501fa0023..eda0b99e859a6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -34,11 +34,7 @@ import { ActionTypeForm } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; -import { - VIEW_LICENSE_OPTIONS_LINK, - DEFAULT_HIDDEN_ACTION_TYPES, - DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES, -} from '../../../common/constants'; +import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; import { ActionGroup, AlertActionParam } from '../../../../../alerting/common'; import { useKibana } from '../../../common/lib/kibana'; import { DefaultActionParamsGetter } from '../../lib/get_defaults_for_action_params'; @@ -237,15 +233,9 @@ export const ActionForm = ({ .list() /** * TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. - * TODO: Need to decide about ServiceNow SIR connector. * If actionTypes are set, hidden connectors are filtered out. Otherwise, they are not. */ - .filter( - ({ id }) => - actionTypes ?? - (!DEFAULT_HIDDEN_ACTION_TYPES.includes(id) && - !DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES.includes(id)) - ) + .filter(({ id }) => actionTypes ?? !DEFAULT_HIDDEN_ACTION_TYPES.includes(id)) .filter((item) => actionTypesIndex[item.id]) .filter((item) => !!item.actionParamsFields) .sort((a, b) => diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 1a3a186d891cc..16466fc9a210d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -33,6 +33,7 @@ import { IErrorObject, ConnectorAddFlyoutProps, ActionTypeModel, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -121,6 +122,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ }; const [isSaving, setIsSaving] = useState(false); + const [callbacks, setCallbacks] = useState(null); const closeFlyout = useCallback(() => { onClose(); @@ -155,6 +157,8 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ errors={errors.connectorErrors} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={false} /> ); @@ -199,10 +203,21 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ ); return; } + setIsSaving(true); + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); + setIsSaving(false); if (savedAction) { + await callbacks?.afterActionConnectorSave?.(savedAction); closeFlyout(); if (reloadConnectors) { await reloadConnectors(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 1e9669d1995dd..7fd6931c936f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -33,6 +33,7 @@ import { ActionTypeRegistryContract, UserConfiguredActionConnector, IErrorObject, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; @@ -97,6 +98,7 @@ const ConnectorAddModal = ({ secretsErrors: {}, }); + const [callbacks, setCallbacks] = useState(null); const actionTypeModel = actionTypeRegistry.get(actionType.id); useEffect(() => { @@ -189,6 +191,8 @@ const ConnectorAddModal = ({ errors={errors.connectorErrors} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={false} /> {isLoading ? ( <> @@ -230,9 +234,19 @@ const ConnectorAddModal = ({ return; } setIsSaving(true); + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); + setIsSaving(false); if (savedAction) { + await callbacks?.afterActionConnectorSave?.(savedAction); if (postSaveEventHandler) { postSaveEventHandler(savedAction); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 25c8103f0c8dc..206ae0bf5018b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -35,6 +35,7 @@ import { IErrorObject, EditConectorTabs, UserConfiguredActionConnector, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { ConnectorReducer, createConnectorReducer } from './connector_reducer'; import { updateActionConnector, executeAction } from '../../lib/action_connector_api'; @@ -138,6 +139,8 @@ const ConnectorEditFlyout = ({ [testExecutionResult] ); + const [callbacks, setCallbacks] = useState(null); + const closeFlyout = useCallback(() => { setConnector(getConnectorWithoutSecrets()); setHasChanges(false); @@ -236,23 +239,38 @@ const ConnectorEditFlyout = ({ }); }; + const setConnectorWithErrors = () => + setConnector( + getConnectorWithInvalidatedFields( + connector, + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors + ) + ); + const onSaveClicked = async (closeAfterSave: boolean = true) => { if (hasErrors) { - setConnector( - getConnectorWithInvalidatedFields( - connector, - errors.configErrors, - errors.secretsErrors, - errors.connectorBaseErrors - ) - ); + setConnectorWithErrors(); return; } + setIsSaving(true); + + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); setIsSaving(false); + if (savedAction) { setHasChanges(false); + await callbacks?.afterActionConnectorSave?.(savedAction); if (closeAfterSave) { closeFlyout(); } @@ -313,6 +331,8 @@ const ConnectorEditFlyout = ({ }} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={true} /> {isLoading ? ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index c237bbda48658..04f2334f8e8fa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -38,6 +38,7 @@ import { ActionConnectorTableItem, ActionTypeIndex, EditConectorTabs, + UserConfiguredActionConnector, } from '../../../../types'; import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt'; import { useKibana } from '../../../../common/lib/kibana'; @@ -45,6 +46,11 @@ import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import ConnectorEditFlyout from '../../action_connector_form/connector_edit_flyout'; import ConnectorAddFlyout from '../../action_connector_form/connector_add_flyout'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../actions/server/constants/connectors'; const ActionsConnectorsList: React.FunctionComponent = () => { const { @@ -167,6 +173,14 @@ const ActionsConnectorsList: React.FunctionComponent = () => { const checkEnabledResult = checkActionTypeEnabled( actionTypesIndex && actionTypesIndex[item.actionTypeId] ); + const itemConfig = ( + item as UserConfiguredActionConnector, Record> + ).config; + const showLegacyTooltip = + itemConfig?.isLegacy && + // TODO: Remove when applications are certified + ((ENABLE_NEW_SN_ITSM_CONNECTOR && item.actionTypeId === '.servicenow') || + (ENABLE_NEW_SN_SIR_CONNECTOR && item.actionTypeId === '.servicenow-sir')); const link = ( <> @@ -190,6 +204,23 @@ const ActionsConnectorsList: React.FunctionComponent = () => { position="right" /> ) : null} + {showLegacyTooltip && ( + + )} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index c2523dd59821d..9e490945e2261 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -12,5 +12,3 @@ export { builtInGroupByTypes } from './group_by_types'; export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions'; // TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. export const DEFAULT_HIDDEN_ACTION_TYPES = ['.case']; -// Action types included in this array will be hidden only from the alert's action type node list -export const DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES = ['.servicenow-sir']; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index a78d1d52de0bd..8085f9245f4e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -73,6 +73,14 @@ export type ActionTypeRegistryContract< > = PublicMethodsOf>>; export type RuleTypeRegistryContract = PublicMethodsOf>; +export type ActionConnectorFieldsCallbacks = { + beforeActionConnectorSave?: () => Promise; + afterActionConnectorSave?: (connector: ActionConnector) => Promise; +} | null; +export type ActionConnectorFieldsSetCallbacks = React.Dispatch< + React.SetStateAction +>; + export interface ActionConnectorFieldsProps { action: TActionConnector; editActionConfig: (property: string, value: unknown) => void; @@ -80,6 +88,8 @@ export interface ActionConnectorFieldsProps { errors: IErrorObject; readOnly: boolean; consumer?: string; + setCallbacks: ActionConnectorFieldsSetCallbacks; + isEdit: boolean; } export enum AlertFlyoutCloseReason { diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index b0f5f3ea490e5..40a7af18ac906 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -189,6 +189,8 @@ function getServiceNowActionParams(): ServiceNowActionParams { category: null, subcategory: null, externalId: null, + correlation_id: null, + correlation_display: null, }, comments: [], }, diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 87eb866b14fa5..0618d379dc77d 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -34,6 +34,7 @@ const enabledActionTypes = [ '.swimlane', '.server-log', '.servicenow', + '.servicenow-sir', '.jira', '.resilient', '.slack', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 997f36020af8c..ecfd8ef3b8e52 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -42,14 +42,6 @@ export function getAllExternalServiceSimulatorPaths(): string[] { const allPaths = Object.values(ExternalServiceSimulator).map((service) => getExternalServiceSimulatorPath(service) ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); - allPaths.push( - `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident/123` - ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_choice`); - allPaths.push( - `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary` - ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/issue`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/createmeta`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); @@ -76,6 +68,10 @@ export async function getSwimlaneServer(): Promise { return await initSwimlane(); } +export async function getServiceNowServer(): Promise { + return await initServiceNow(); +} + interface FixtureSetupDeps { actions: ActionsPluginSetupContract; features: FeaturesPluginSetup; @@ -127,7 +123,6 @@ export class FixturePlugin implements Plugin, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, - }); - } - ); +import http from 'http'; - router.patch( - { - path: `${path}/api/now/v2/table/incident/{id}`, - options: { - authRequired: false, - }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { sys_id: '123', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' }, - }); +export const initPlugin = async () => http.createServer(handler); + +const sendResponse = (response: http.ServerResponse, data: any) => { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(data, null, 4)); +}; + +const handler = async (request: http.IncomingMessage, response: http.ServerResponse) => { + const buffers = []; + let data: Record = {}; + + if (request.method === 'POST') { + for await (const chunk of request) { + buffers.push(chunk); } - ); - router.get( - { - path: `${path}/api/now/v2/table/incident/{id}`, - options: { - authRequired: false, + data = JSON.parse(Buffer.concat(buffers).toString()); + } + + const pathName = request.url!; + + if (pathName.includes('elastic_api/health')) { + return sendResponse(response, { + result: { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { + }); + } + + // Import Set API: Create or update incident + if ( + pathName.includes('x_elas2_inc_int_elastic_incident') || + pathName.includes('x_elas2_sir_int_elastic_si_incident') + ) { + const update = data?.elastic_incident_id != null; + return sendResponse(response, { + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: '/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', sys_id: '123', - number: 'INC01', - sys_created_on: '2020-03-10 12:24:20', - short_description: 'title', - description: 'description', }, - }); - } - ); + ], + }); + } - router.get( - { - path: `${path}/api/now/v2/table/sys_dictionary`, - options: { - authRequired: false, + // Create incident + if ( + pathName === '/api/now/v2/table/incident' || + pathName === '/api/now/v2/table/sn_si_incident' + ) { + return sendResponse(response, { + result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, + }); + } + + // URLs of type /api/now/v2/table/incident/{id} + // GET incident, PATCH incident + if ( + pathName.includes('/api/now/v2/table/incident') || + pathName.includes('/api/now/v2/table/sn_si_incident') + ) { + return sendResponse(response, { + result: { + sys_id: '123', + number: 'INC01', + sys_created_on: '2020-03-10 12:24:20', + sys_updated_on: '2020-03-10 12:24:20', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: [ - { - column_label: 'Close notes', - mandatory: 'false', - max_length: '4000', - element: 'close_notes', - }, - { - column_label: 'Description', - mandatory: 'false', - max_length: '4000', - element: 'description', - }, - { - column_label: 'Short description', - mandatory: 'false', - max_length: '160', - element: 'short_description', - }, - ], - }); - } - ); + }); + } - router.get( - { - path: `${path}/api/now/v2/table/sys_choice`, - options: { - authRequired: false, + // Add multiple observables + if (pathName.includes('/observables/bulk')) { + return sendResponse(response, { + result: [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, + ], + }); + } + + // Add single observables + if (pathName.includes('/observables')) { + return sendResponse(response, { + result: { + value: '127.0.0.1', + observable_sys_id: '2', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: [ - { - dependent_value: '', - label: '1 - Critical', - value: '1', - }, - { - dependent_value: '', - label: '2 - High', - value: '2', - }, - { - dependent_value: '', - label: '3 - Moderate', - value: '3', - }, - { - dependent_value: '', - label: '4 - Low', - value: '4', - }, - { - dependent_value: '', - label: '5 - Planning', - value: '5', - }, - ], - }); - } - ); -} - -function jsonResponse(res: KibanaResponseFactory, code: number, object?: Record) { - if (object == null) { - return res.custom({ - statusCode: code, - body: '', }); } - return res.custom>({ body: object, statusCode: code }); -} + if (pathName.includes('/api/now/table/sys_dictionary')) { + return sendResponse(response, { + result: [ + { + column_label: 'Close notes', + mandatory: 'false', + max_length: '4000', + element: 'close_notes', + }, + { + column_label: 'Description', + mandatory: 'false', + max_length: '4000', + element: 'description', + }, + { + column_label: 'Short description', + mandatory: 'false', + max_length: '160', + element: 'short_description', + }, + ], + }); + } + + if (pathName.includes('/api/now/table/sys_choice')) { + return sendResponse(response, { + result: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + } + + // Return an 400 error if endpoint is not supported + response.statusCode = 400; + response.setHeader('Content-Type', 'application/json'); + response.end('Not supported endpoint to request servicenow simulator'); +}; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts index afba550908ddc..97cbcbe7a60a6 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts @@ -35,5 +35,5 @@ const handler = (request: http.IncomingMessage, response: http.ServerResponse) = // Return an 400 error if http method is not supported response.statusCode = 400; response.setHeader('Content-Type', 'application/json'); - response.end('Not supported http method to request slack simulator'); + response.end('Not supported http method to request swimlane simulator'); }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts similarity index 76% rename from x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts index d6196ee6ce312..fe1ebdf8d28a9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts @@ -7,24 +7,22 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { - getExternalServiceSimulatorPath, - ExternalServiceSimulator, -} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export -export default function servicenowTest({ getService }: FtrProviderContext) { +export default function serviceNowITSMTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const configService = getService('config'); const mockServiceNow = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, }, secrets: { password: 'elastic', @@ -41,7 +39,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { short_description: 'a title', urgency: '1', category: 'software', - subcategory: 'software', + subcategory: 'os', }, comments: [ { @@ -53,16 +51,37 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }, }; - let servicenowSimulatorURL: string = ''; + describe('ServiceNow ITSM', () => { + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; - describe('ServiceNow', () => { - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } ); }); - describe('ServiceNow - Action Creation', () => { + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('ServiceNow ITSM - Action Creation', () => { it('should return 200 when creating a servicenow action successfully', async () => { const { body: createdAction } = await supertest .post('/api/actions/connector') @@ -71,7 +90,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow action', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, }, secrets: mockServiceNow.secrets, }) @@ -84,7 +103,8 @@ export default function servicenowTest({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', is_missing_secrets: false, config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, }); @@ -99,11 +119,33 @@ export default function servicenowTest({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', is_missing_secrets: false, config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, }); }); + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction.config.isLegacy).to.be(false); + }); + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { await supertest .post('/api/actions/connector') @@ -155,7 +197,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow action', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, }, }) .expect(400) @@ -170,10 +212,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); - describe('ServiceNow - Executor', () => { - let simulatedActionId: string; - let proxyServer: httpProxy | undefined; - let proxyHaveBeenCalled = false; + describe('ServiceNow ITSM - Executor', () => { before(async () => { const { body } = await supertest .post('/api/actions/connector') @@ -182,19 +221,12 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow simulator', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, secrets: mockServiceNow.secrets, }); simulatedActionId = body.id; - - proxyServer = await getHttpProxyServer( - kibanaServer.resolveUrl('/'), - configService.get('kbnTestServer.serverArgs'), - () => { - proxyHaveBeenCalled = true; - } - ); }); describe('Validation', () => { @@ -377,31 +409,81 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); describe('Execution', () => { - it('should handle creating an incident without comments', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...mockServiceNow.params, - subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, - comments: [], + // New connectors + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }, - }) - .expect(200); - - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: simulatedActionId, - data: { - id: '123', - title: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, - }, + }); + }); + }); + + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + }, + }); }); }); @@ -453,12 +535,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); }); - - after(() => { - if (proxyServer) { - proxyServer.close(); - } - }); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts new file mode 100644 index 0000000000000..eee3425b6a61f --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts @@ -0,0 +1,544 @@ +/* + * 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 httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function serviceNowSIRTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + externalId: null, + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'alertID', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + comment: 'first comment', + commentId: '456', + }, + ], + }, + }, + }; + + describe('ServiceNow SIR', () => { + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('ServiceNow SIR - Action Creation', () => { + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); + }); + + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction.config.isLegacy).to.be(false); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + secrets: mockServiceNow.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('ServiceNow SIR - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.status).to.eql('error'); + expect(resp.body.retry).to.eql(false); + // Node.js 12 oddity: + // + // The first time after the server is booted, the error message will be: + // + // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) + // + // After this, the error will be: + // + // Cannot destructure property 'value' of 'undefined' as it is undefined. + // + // The error seems to come from the exact same place in the code based on the + // exact same circumstances: + // + // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 + // + // What triggers the error is that the `handleError` function expects its 2nd + // argument to be an object containing a `valids` property of type array. + // + // In this test the object does not contain a `valids` property, so hence the + // error. + // + // Why the error message isn't the same in all scenarios is unknown to me and + // could be a bug in V8. + expect(resp.body.message).to.match( + /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ + ); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + savedObjectId: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ comment: 'boo' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + describe('getChoices', () => { + it('should fail when field is not provided', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: {}, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]', + }); + }); + }); + }); + }); + + describe('Execution', () => { + // New connectors + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, + }, + }); + }); + }); + + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, + }, + }); + }); + }); + + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index db57af0ba1a98..61bd1bcad34ad 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -25,7 +25,8 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/pagerduty')); loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); - loadTestFile(require.resolve('./builtin_action_types/servicenow')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_sir')); loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 7367641d71585..f34d7398db0c2 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -6,6 +6,9 @@ */ import { omit } from 'lodash'; +import getPort from 'get-port'; +import http from 'http'; + import expect from '@kbn/expect'; import type { ApiResponse, estypes } from '@elastic/elasticsearch'; import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; @@ -58,6 +61,7 @@ import { User } from './authentication/types'; import { superUser } from './authentication/users'; import { ESCasesConfigureAttributes } from '../../../../plugins/cases/server/services/configure/types'; import { ESCaseAttributes } from '../../../../plugins/cases/server/services/cases/types'; +import { getServiceNowServer } from '../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; function toArray(input: T | T[]): T[] { if (Array.isArray(input)) { @@ -652,13 +656,13 @@ export const getCaseSavedObjectsFromES = async ({ es }: { es: KibanaClient }) => export const createCaseWithConnector = async ({ supertest, configureReq = {}, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth = { user: superUser, space: null }, createCaseReq = getPostCaseRequest(), }: { supertest: SuperTest.SuperTest; - servicenowSimulatorURL: string; + serviceNowSimulatorURL: string; actionsRemover: ActionsRemover; configureReq?: Record; auth?: { user: User; space: string | null }; @@ -671,7 +675,7 @@ export const createCaseWithConnector = async ({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth, }); @@ -1220,3 +1224,17 @@ export const getAlertsAttachedToCase = async ({ return theCase; }; + +export const getServiceNowSimulationServer = async (): Promise<{ + server: http.Server; + url: string; +}> => { + const server = await getServiceNowServer(); + const port = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!server.listening) { + server.listen(port); + } + const url = `http://localhost:${port}`; + + return { server, url }; +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 0ea66d35b63b8..73e8f2ba851fc 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -7,6 +7,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import http from 'http'; + import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -32,11 +34,8 @@ import { getServiceNowConnector, getConnectorMappingsFromES, getCase, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { CaseConnector, CaseStatuses, @@ -55,17 +54,17 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -73,10 +72,14 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should push a case', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -95,18 +98,13 @@ export default ({ getService }: FtrProviderContext): void => { external_title: 'INC01', }); - // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins - expect( - external_url.includes( - 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' - ) - ).to.equal(true); + expect(external_url.includes('nav_to.do?uri=incident.do?sys_id=123')).to.equal(true); }); it('preserves the connector.id after pushing a case', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -121,7 +119,7 @@ export default ({ getService }: FtrProviderContext): void => { it('preserves the external_service.connector_id after updating the connector', async () => { const { postedCase, connector: pushConnector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -135,7 +133,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -175,7 +173,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -222,7 +220,7 @@ export default ({ getService }: FtrProviderContext): void => { it('pushes a comment appropriately', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); await createComment({ supertest, caseId: postedCase.id, params: postCommentUserReq }); @@ -241,7 +239,7 @@ export default ({ getService }: FtrProviderContext): void => { closure_type: 'close-by-pushing', }, supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -256,7 +254,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should create the correct user action', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const pushedCase = await pushCase({ @@ -289,7 +287,7 @@ export default ({ getService }: FtrProviderContext): void => { connector_name: connector.name, external_id: '123', external_title: 'INC01', - external_url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + external_url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }); }); @@ -297,7 +295,7 @@ export default ({ getService }: FtrProviderContext): void => { it.skip('should push a collection case but not close it when closure_type: close-by-pushing', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, configureReq: { closure_type: 'close-by-pushing', @@ -337,7 +335,7 @@ export default ({ getService }: FtrProviderContext): void => { it('unhappy path = 409s when case is closed', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); await updateCase({ @@ -367,7 +365,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should push a case that the user has permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, }); @@ -383,7 +381,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), @@ -404,7 +402,7 @@ export default ({ getService }: FtrProviderContext): void => { } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, }); @@ -422,7 +420,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case in a space that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: { user: superUser, space: 'space2' }, }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts index 255a2a4ce28b5..fda2c8d361042 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; @@ -17,13 +18,10 @@ import { deleteConfiguration, getConfigurationRequest, getServiceNowConnector, + getServiceNowSimulationServer, } from '../../../../../common/lib/utils'; import { ObjectRemover as ActionsRemover } from '../../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getCreateConnectorUrl } from '../../../../../../../plugins/cases/common/utils/connectors_api'; // eslint-disable-next-line import/no-default-export @@ -31,15 +29,17 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); describe('get_all_user_actions', () => { - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); + afterEach(async () => { await deleteCasesByESQuery(es); await deleteComments(es); @@ -48,13 +48,17 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { const { body: connector } = await supertest .post(getCreateConnectorUrl()) .set('kbn-xsrf', 'true') .send({ ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }) .expect(200); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts index ff8f1cff884af..404b63376daa4 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts @@ -5,14 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; - import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, @@ -22,6 +18,7 @@ import { getConfigurationRequest, removeServerGeneratedPropertiesFromSavedObject, getConfigurationOutput, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; @@ -29,27 +26,31 @@ import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); describe('get_configure', () => { - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should return a configuration with mapping', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); actionsRemover.add('default', connector.id, 'action', 'actions'); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts index fb922f8d10243..c3e737464f19b 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts @@ -109,6 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'ServiceNow Connector', config: { apiUrl: 'http://some.non.existent.com', + isLegacy: false, }, isPreconfigured: false, isMissingSecrets: false, @@ -118,7 +119,10 @@ export default ({ getService }: FtrProviderContext): void => { id: sir.id, actionTypeId: '.servicenow-sir', name: 'ServiceNow Connector', - config: { apiUrl: 'http://some.non.existent.com' }, + config: { + apiUrl: 'http://some.non.existent.com', + isLegacy: false, + }, isPreconfigured: false, isMissingSecrets: false, referencedByCount: 0, diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts index 789b68b19beb6..26eba77dd2576 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts @@ -5,13 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -22,6 +19,7 @@ import { updateConfiguration, getServiceNowConnector, createConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; @@ -29,16 +27,16 @@ import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); describe('patch_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -46,12 +44,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should patch a configuration connector and create mappings', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -107,7 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts index 96ffcf4bc3f5c..077bfc5861322 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts @@ -5,14 +5,11 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -22,22 +19,23 @@ import { createConfiguration, createConnector, getServiceNowConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); describe('post_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -45,12 +43,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should create a configuration with mapping', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); diff --git a/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts index 6294400281b92..69d403ea15301 100644 --- a/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts @@ -5,6 +5,8 @@ * 2.0. */ +import http from 'http'; + import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -13,11 +15,8 @@ import { pushCase, deleteAllCaseItems, createCaseWithConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { globalRead, noKibanaPrivileges, @@ -31,17 +30,17 @@ import { secOnlyDefaultSpaceAuth } from '../../../utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -49,12 +48,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + const supertestWithoutAuth = getService('supertestWithoutAuth'); it('should push a case that the user has permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -69,7 +72,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), }); @@ -95,7 +98,7 @@ export default ({ getService }: FtrProviderContext): void => { } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -112,7 +115,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return a 404 when attempting to access a space', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts index 28b7fe6095507..bfb266e6f6c3a 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts @@ -6,7 +6,7 @@ */ /* eslint-disable @typescript-eslint/naming-convention */ - +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -17,27 +17,24 @@ import { deleteAllCaseItems, createCaseWithConnector, getAuthWithSuperUser, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); const authSpace1 = getAuthWithSuperUser(); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -45,10 +42,14 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should push a case in space1', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: authSpace1, }); @@ -69,18 +70,13 @@ export default ({ getService }: FtrProviderContext): void => { external_title: 'INC01', }); - // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins - expect( - external_url.includes( - 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' - ) - ).to.equal(true); + expect(external_url.includes('nav_to.do?uri=incident.do?sys_id=123')).to.equal(true); }); it('should not push a case in a different space', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts index a142e6470ae93..4da44f08c6236 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts @@ -5,14 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; - import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, @@ -23,6 +19,7 @@ import { removeServerGeneratedPropertiesFromSavedObject, getConfigurationOutput, getAuthWithSuperUser, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { nullUser } from '../../../../common/lib/mock'; @@ -31,28 +28,32 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); describe('get_configure', () => { - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should return a configuration with a mapping from space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); @@ -107,7 +108,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts index 0301fa3a930cb..7b6848d1f301e 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts @@ -109,6 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'ServiceNow Connector', config: { apiUrl: 'http://some.non.existent.com', + isLegacy: false, }, isPreconfigured: false, isMissingSecrets: false, @@ -118,7 +119,10 @@ export default ({ getService }: FtrProviderContext): void => { id: sir.id, actionTypeId: '.servicenow-sir', name: 'ServiceNow Connector', - config: { apiUrl: 'http://some.non.existent.com' }, + config: { + apiUrl: 'http://some.non.existent.com', + isLegacy: false, + }, isPreconfigured: false, isMissingSecrets: false, referencedByCount: 0, diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts index 14d0debe2ac17..ca362d13ae459 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts @@ -5,13 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -24,6 +21,7 @@ import { createConnector, getAuthWithSuperUser, getActionsSpace, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { nullUser } from '../../../../common/lib/mock'; @@ -32,18 +30,18 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); const space = getActionsSpace(authSpace1.space); describe('patch_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -51,12 +49,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should patch a configuration connector and create mappings in space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); @@ -126,7 +128,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts index 7c5035193d465..b815278db5bd8 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts @@ -5,14 +5,11 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -24,6 +21,7 @@ import { getServiceNowConnector, getAuthWithSuperUser, getActionsSpace, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { nullUser } from '../../../../common/lib/mock'; @@ -31,18 +29,18 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); const space = getActionsSpace(authSpace1.space); describe('post_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -50,12 +48,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should create a configuration with a mapping in space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); From 6a55f87da0df83b299de55b79034648a2ec9fe0d Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Tue, 12 Oct 2021 20:25:08 +0200 Subject: [PATCH 46/68] Connect locator persistable state to Short URLs (#114397) --- .../common/url_service/__tests__/setup.ts | 4 +- .../common/url_service/locators/locator.ts | 6 +- .../url_service/locators/locator_client.ts | 92 ++++++++++- .../common/url_service/locators/types.ts | 28 +++- src/plugins/share/common/url_service/mocks.ts | 4 +- .../common/url_service/short_urls/types.ts | 21 +-- .../share/common/url_service/url_service.ts | 8 +- src/plugins/share/public/mocks.ts | 4 +- src/plugins/share/public/plugin.ts | 4 +- src/plugins/share/server/plugin.ts | 24 +-- .../share/server/saved_objects/index.ts | 9 -- src/plugins/share/server/saved_objects/url.ts | 67 -------- src/plugins/share/server/url_service/index.ts | 2 + ...ster_url_service_saved_object_type.test.ts | 144 ++++++++++++++++++ .../register_url_service_saved_object_type.ts | 97 ++++++++++++ .../short_urls/short_url_client.test.ts | 119 ++++++++++++++- .../short_urls/short_url_client.ts | 86 ++++++++--- .../short_urls/short_url_client_factory.ts | 11 +- .../storage/memory_short_url_storage.test.ts | 44 +++++- .../storage/memory_short_url_storage.ts | 41 +++-- .../storage/saved_object_short_url_storage.ts | 46 ++++-- .../server/url_service/short_urls/types.ts | 24 ++- .../apm_plugin/mock_apm_plugin_context.tsx | 2 +- 23 files changed, 719 insertions(+), 168 deletions(-) delete mode 100644 src/plugins/share/server/saved_objects/index.ts delete mode 100644 src/plugins/share/server/saved_objects/url.ts create mode 100644 src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts create mode 100644 src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts diff --git a/src/plugins/share/common/url_service/__tests__/setup.ts b/src/plugins/share/common/url_service/__tests__/setup.ts index 239b2554e663a..8f339c2060faf 100644 --- a/src/plugins/share/common/url_service/__tests__/setup.ts +++ b/src/plugins/share/common/url_service/__tests__/setup.ts @@ -37,7 +37,7 @@ export const urlServiceTestSetup = (partialDeps: Partial getUrl: async () => { throw new Error('not implemented'); }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented.'); @@ -52,7 +52,7 @@ export const urlServiceTestSetup = (partialDeps: Partial throw new Error('Not implemented.'); }, }), - }, + }), ...partialDeps, }; const service = new UrlService(deps); diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index fc970e2c7a490..2d33f701df595 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -67,13 +67,15 @@ export class Locator

implements LocatorPublic

{ state: P, references: SavedObjectReference[] ): P => { - return this.definition.inject ? this.definition.inject(state, references) : state; + if (!this.definition.inject) return state; + return this.definition.inject(state, references); }; public readonly extract: PersistableState

['extract'] = ( state: P ): { state: P; references: SavedObjectReference[] } => { - return this.definition.extract ? this.definition.extract(state) : { state, references: [] }; + if (!this.definition.extract) return { state, references: [] }; + return this.definition.extract(state); }; // LocatorPublic

---------------------------------------------------------- diff --git a/src/plugins/share/common/url_service/locators/locator_client.ts b/src/plugins/share/common/url_service/locators/locator_client.ts index 587083551aa6d..7dd69165be5dd 100644 --- a/src/plugins/share/common/url_service/locators/locator_client.ts +++ b/src/plugins/share/common/url_service/locators/locator_client.ts @@ -7,9 +7,12 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; +import { MigrateFunctionsObject } from 'src/plugins/kibana_utils/common'; +import { SavedObjectReference } from 'kibana/server'; import type { LocatorDependencies } from './locator'; -import type { LocatorDefinition, LocatorPublic, ILocatorClient } from './types'; +import type { LocatorDefinition, LocatorPublic, ILocatorClient, LocatorData } from './types'; import { Locator } from './locator'; +import { LocatorMigrationFunction, LocatorsMigrationMap } from '.'; export type LocatorClientDependencies = LocatorDependencies; @@ -44,4 +47,91 @@ export class LocatorClient implements ILocatorClient { public get

(id: string): undefined | LocatorPublic

{ return this.locators.get(id); } + + protected getOrThrow

(id: string): LocatorPublic

{ + const locator = this.locators.get(id); + if (!locator) throw new Error(`Locator [ID = "${id}"] is not registered.`); + return locator; + } + + public migrations(): { [locatorId: string]: MigrateFunctionsObject } { + const migrations: { [locatorId: string]: MigrateFunctionsObject } = {}; + + for (const locator of this.locators.values()) { + migrations[locator.id] = locator.migrations; + } + + return migrations; + } + + // PersistableStateService ---------------------------------------------------------- + + public telemetry( + state: LocatorData, + collector: Record + ): Record { + for (const locator of this.locators.values()) { + collector = locator.telemetry(state.state, collector); + } + + return collector; + } + + public inject(state: LocatorData, references: SavedObjectReference[]): LocatorData { + const locator = this.getOrThrow(state.id); + const filteredReferences = references + .filter((ref) => ref.name.startsWith('params:')) + .map((ref) => ({ + ...ref, + name: ref.name.substr('params:'.length), + })); + return { + ...state, + state: locator.inject(state.state, filteredReferences), + }; + } + + public extract(state: LocatorData): { state: LocatorData; references: SavedObjectReference[] } { + const locator = this.getOrThrow(state.id); + const extracted = locator.extract(state.state); + return { + state: { + ...state, + state: extracted.state, + }, + references: extracted.references.map((ref) => ({ + ...ref, + name: 'params:' + ref.name, + })), + }; + } + + public readonly getAllMigrations = (): LocatorsMigrationMap => { + const locatorParamsMigrations = this.migrations(); + const locatorMigrations: LocatorsMigrationMap = {}; + const versions = new Set(); + + for (const migrationMap of Object.values(locatorParamsMigrations)) + for (const version of Object.keys(migrationMap)) versions.add(version); + + for (const version of versions.values()) { + const migration: LocatorMigrationFunction = (locator) => { + const locatorMigrationsMap = locatorParamsMigrations[locator.id]; + if (!locatorMigrationsMap) return locator; + + const migrationFunction = locatorMigrationsMap[version]; + if (!migrationFunction) return locator; + + return { + ...locator, + version, + state: migrationFunction(locator.state), + }; + }; + + locatorMigrations[version] = migration; + } + + return locatorMigrations; + }; } diff --git a/src/plugins/share/common/url_service/locators/types.ts b/src/plugins/share/common/url_service/locators/types.ts index ab0efa9b2375a..c64dc588aaf22 100644 --- a/src/plugins/share/common/url_service/locators/types.ts +++ b/src/plugins/share/common/url_service/locators/types.ts @@ -8,13 +8,18 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { DependencyList } from 'react'; -import { PersistableState } from 'src/plugins/kibana_utils/common'; +import { + MigrateFunction, + PersistableState, + PersistableStateService, + VersionedState, +} from 'src/plugins/kibana_utils/common'; import type { FormatSearchParamsOptions } from './redirect'; /** * URL locator registry. */ -export interface ILocatorClient { +export interface ILocatorClient extends PersistableStateService { /** * Create and register a new locator. * @@ -141,3 +146,22 @@ export interface KibanaLocation { */ state: S; } + +/** + * Represents a serializable state of a locator. Includes locator ID, version + * and its params. + */ +export interface LocatorData + extends VersionedState, + SerializableRecord { + /** + * Locator ID. + */ + id: string; +} + +export interface LocatorsMigrationMap { + [semver: string]: LocatorMigrationFunction; +} + +export type LocatorMigrationFunction = MigrateFunction; diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts index dd86e2398589e..24ba226818427 100644 --- a/src/plugins/share/common/url_service/mocks.ts +++ b/src/plugins/share/common/url_service/mocks.ts @@ -18,7 +18,7 @@ export class MockUrlService extends UrlService { getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented.'); @@ -33,7 +33,7 @@ export class MockUrlService extends UrlService { throw new Error('Not implemented.'); }, }), - }, + }), }); } } diff --git a/src/plugins/share/common/url_service/short_urls/types.ts b/src/plugins/share/common/url_service/short_urls/types.ts index db744a25f9f79..698ffe7b8421b 100644 --- a/src/plugins/share/common/url_service/short_urls/types.ts +++ b/src/plugins/share/common/url_service/short_urls/types.ts @@ -6,9 +6,8 @@ * Side Public License, v 1. */ -import { SerializableRecord } from '@kbn/utility-types'; -import { VersionedState } from 'src/plugins/kibana_utils/common'; -import { LocatorPublic } from '../locators'; +import type { SerializableRecord } from '@kbn/utility-types'; +import type { LocatorPublic, ILocatorClient, LocatorData } from '../locators'; /** * A factory for Short URL Service. We need this factory as the dependency @@ -21,6 +20,10 @@ export interface IShortUrlClientFactory { get(dependencies: D): IShortUrlClient; } +export type IShortUrlClientFactoryProvider = (params: { + locators: ILocatorClient; +}) => IShortUrlClientFactory; + /** * CRUD-like API for short URLs. */ @@ -128,14 +131,4 @@ export interface ShortUrlData; } -/** - * Represents a serializable state of a locator. Includes locator ID, version - * and its params. - */ -export interface LocatorData - extends VersionedState { - /** - * Locator ID. - */ - id: string; -} +export type { LocatorData }; diff --git a/src/plugins/share/common/url_service/url_service.ts b/src/plugins/share/common/url_service/url_service.ts index dedb81720865d..24e2ea0b62379 100644 --- a/src/plugins/share/common/url_service/url_service.ts +++ b/src/plugins/share/common/url_service/url_service.ts @@ -7,10 +7,10 @@ */ import { LocatorClient, LocatorClientDependencies } from './locators'; -import { IShortUrlClientFactory } from './short_urls'; +import { IShortUrlClientFactoryProvider, IShortUrlClientFactory } from './short_urls'; export interface UrlServiceDependencies extends LocatorClientDependencies { - shortUrls: IShortUrlClientFactory; + shortUrls: IShortUrlClientFactoryProvider; } /** @@ -26,6 +26,8 @@ export class UrlService { constructor(protected readonly deps: UrlServiceDependencies) { this.locators = new LocatorClient(deps); - this.shortUrls = deps.shortUrls; + this.shortUrls = deps.shortUrls({ + locators: this.locators, + }); } } diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts index 73df7257290f0..33cdf141de9f3 100644 --- a/src/plugins/share/public/mocks.ts +++ b/src/plugins/share/public/mocks.ts @@ -18,7 +18,7 @@ const url = new UrlService({ getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented'); @@ -33,7 +33,7 @@ const url = new UrlService({ throw new Error('Not implemented.'); }, }), - }, + }), }); const createSetupContract = (): Setup => { diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 103fbb50bb95f..fd8a5fd7541a6 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -104,7 +104,7 @@ export class SharePlugin implements Plugin { }); return url; }, - shortUrls: { + shortUrls: () => ({ get: () => ({ create: async () => { throw new Error('Not implemented'); @@ -119,7 +119,7 @@ export class SharePlugin implements Plugin { throw new Error('Not implemented.'); }, }), - }, + }), }); this.url.locators.create(new LegacyShortUrlLocatorDefinition()); diff --git a/src/plugins/share/server/plugin.ts b/src/plugins/share/server/plugin.ts index f0e4abf9eb589..d79588420fe87 100644 --- a/src/plugins/share/server/plugin.ts +++ b/src/plugins/share/server/plugin.ts @@ -9,11 +9,14 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server'; -import { url } from './saved_objects'; import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../common/constants'; import { UrlService } from '../common/url_service'; -import { ServerUrlService, ServerShortUrlClientFactory } from './url_service'; -import { registerUrlServiceRoutes } from './url_service/http/register_url_service_routes'; +import { + ServerUrlService, + ServerShortUrlClientFactory, + registerUrlServiceRoutes, + registerUrlServiceSavedObjectType, +} from './url_service'; import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator'; /** @public */ @@ -44,18 +47,17 @@ export class SharePlugin implements Plugin { getUrl: async () => { throw new Error('Locator .getUrl() currently is not supported on the server.'); }, - shortUrls: new ServerShortUrlClientFactory({ - currentVersion: this.version, - }), + shortUrls: ({ locators }) => + new ServerShortUrlClientFactory({ + currentVersion: this.version, + locators, + }), }); - this.url.locators.create(new LegacyShortUrlLocatorDefinition()); - const router = core.http.createRouter(); - - registerUrlServiceRoutes(core, router, this.url); + registerUrlServiceSavedObjectType(core.savedObjects, this.url); + registerUrlServiceRoutes(core, core.http.createRouter(), this.url); - core.savedObjects.registerType(url); core.uiSettings.register({ [CSV_SEPARATOR_SETTING]: { name: i18n.translate('share.advancedSettings.csv.separatorTitle', { diff --git a/src/plugins/share/server/saved_objects/index.ts b/src/plugins/share/server/saved_objects/index.ts deleted file mode 100644 index ff37efb9fec17..0000000000000 --- a/src/plugins/share/server/saved_objects/index.ts +++ /dev/null @@ -1,9 +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. - */ - -export { url } from './url'; diff --git a/src/plugins/share/server/saved_objects/url.ts b/src/plugins/share/server/saved_objects/url.ts deleted file mode 100644 index 6288e87f629f5..0000000000000 --- a/src/plugins/share/server/saved_objects/url.ts +++ /dev/null @@ -1,67 +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 { SavedObjectsType } from 'kibana/server'; - -export const url: SavedObjectsType = { - name: 'url', - namespaceType: 'single', - hidden: false, - management: { - icon: 'link', - defaultSearchField: 'url', - importableAndExportable: true, - getTitle(obj) { - return `/goto/${encodeURIComponent(obj.id)}`; - }, - getInAppUrl(obj) { - return { - path: '/goto/' + encodeURIComponent(obj.id), - uiCapabilitiesPath: '', - }; - }, - }, - mappings: { - properties: { - slug: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - }, - }, - }, - accessCount: { - type: 'long', - }, - accessDate: { - type: 'date', - }, - createDate: { - type: 'date', - }, - // Legacy field - contains already pre-formatted final URL. - // This is here to support old saved objects that have this field. - // TODO: Remove this field and execute a migration to the new format. - url: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - ignore_above: 2048, - }, - }, - }, - // Information needed to load and execute a locator. - locatorJSON: { - type: 'text', - index: false, - }, - }, - }, -}; diff --git a/src/plugins/share/server/url_service/index.ts b/src/plugins/share/server/url_service/index.ts index 068a5289d42ed..62d1329371736 100644 --- a/src/plugins/share/server/url_service/index.ts +++ b/src/plugins/share/server/url_service/index.ts @@ -8,3 +8,5 @@ export * from './types'; export * from './short_urls'; +export { registerUrlServiceRoutes } from './http/register_url_service_routes'; +export { registerUrlServiceSavedObjectType } from './saved_objects/register_url_service_saved_object_type'; diff --git a/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts new file mode 100644 index 0000000000000..651169f6101a9 --- /dev/null +++ b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts @@ -0,0 +1,144 @@ +/* + * 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 { SerializableRecord } from '@kbn/utility-types'; +import type { + SavedObjectMigrationMap, + SavedObjectsType, + SavedObjectUnsanitizedDoc, +} from 'kibana/server'; +import { ServerShortUrlClientFactory } from '..'; +import { UrlService, LocatorDefinition } from '../../../common/url_service'; +import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator'; +import { MemoryShortUrlStorage } from '../short_urls/storage/memory_short_url_storage'; +import { ShortUrlSavedObjectAttributes } from '../short_urls/storage/saved_object_short_url_storage'; +import { registerUrlServiceSavedObjectType } from './register_url_service_saved_object_type'; + +const setup = () => { + const currentVersion = '7.7.7'; + const service = new UrlService({ + getUrl: () => { + throw new Error('Not implemented.'); + }, + navigate: () => { + throw new Error('Not implemented.'); + }, + shortUrls: ({ locators }) => + new ServerShortUrlClientFactory({ + currentVersion, + locators, + }), + }); + const definition = new LegacyShortUrlLocatorDefinition(); + const locator = service.locators.create(definition); + const storage = new MemoryShortUrlStorage(); + const client = service.shortUrls.get({ storage }); + + let type: SavedObjectsType; + registerUrlServiceSavedObjectType( + { + registerType: (urlSavedObjectType) => { + type = urlSavedObjectType; + }, + }, + service + ); + + return { + type: type!, + client, + service, + storage, + locator, + definition, + currentVersion, + }; +}; + +describe('migrations', () => { + test('returns empty migrations object if there are no migrations', () => { + const { type } = setup(); + + expect((type.migrations as () => SavedObjectMigrationMap)()).toEqual({}); + }); + + test('migrates locator to the latest version', () => { + interface FooLocatorParamsOld extends SerializableRecord { + color: string; + indexPattern: string; + } + + interface FooLocatorParams extends SerializableRecord { + color: string; + indexPatterns: string[]; + } + + class FooLocatorDefinition implements LocatorDefinition { + public readonly id = 'FOO_LOCATOR'; + + public async getLocation() { + return { + app: 'foo', + path: '', + state: {}, + }; + } + + migrations = { + '8.0.0': ({ indexPattern, ...rest }: FooLocatorParamsOld): FooLocatorParams => ({ + ...rest, + indexPatterns: [indexPattern], + }), + }; + } + + const { type, service } = setup(); + + service.locators.create(new FooLocatorDefinition()); + + const migrationFunction = (type.migrations as () => SavedObjectMigrationMap)()['8.0.0']; + + expect(typeof migrationFunction).toBe('function'); + + const doc1: SavedObjectUnsanitizedDoc = { + id: 'foo', + attributes: { + accessCount: 0, + accessDate: 0, + createDate: 0, + locatorJSON: JSON.stringify({ + id: 'FOO_LOCATOR', + version: '7.7.7', + state: { + color: 'red', + indexPattern: 'myIndex', + }, + }), + url: '', + }, + type: 'url', + }; + + const doc2 = migrationFunction(doc1, {} as any); + + expect(doc2.id).toBe('foo'); + expect(doc2.type).toBe('url'); + expect(doc2.attributes.accessCount).toBe(0); + expect(doc2.attributes.accessDate).toBe(0); + expect(doc2.attributes.createDate).toBe(0); + expect(doc2.attributes.url).toBe(''); + expect(JSON.parse(doc2.attributes.locatorJSON)).toEqual({ + id: 'FOO_LOCATOR', + version: '8.0.0', + state: { + color: 'red', + indexPatterns: ['myIndex'], + }, + }); + }); +}); diff --git a/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts new file mode 100644 index 0000000000000..b2fcefcc767cf --- /dev/null +++ b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.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 type { + SavedObjectMigrationMap, + SavedObjectsServiceSetup, + SavedObjectsType, +} from 'kibana/server'; +import type { LocatorData } from 'src/plugins/share/common/url_service'; +import type { ServerUrlService } from '..'; + +export const registerUrlServiceSavedObjectType = ( + so: Pick, + service: ServerUrlService +) => { + const urlSavedObjectType: SavedObjectsType = { + name: 'url', + namespaceType: 'single', + hidden: false, + management: { + icon: 'link', + defaultSearchField: 'url', + importableAndExportable: true, + getTitle(obj) { + return `/goto/${encodeURIComponent(obj.id)}`; + }, + getInAppUrl(obj) { + return { + path: '/goto/' + encodeURIComponent(obj.id), + uiCapabilitiesPath: '', + }; + }, + }, + mappings: { + properties: { + slug: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + accessCount: { + type: 'long', + }, + accessDate: { + type: 'date', + }, + createDate: { + type: 'date', + }, + // Legacy field - contains already pre-formatted final URL. + // This is here to support old saved objects that have this field. + // TODO: Remove this field and execute a migration to the new format. + url: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 2048, + }, + }, + }, + // Information needed to load and execute a locator. + locatorJSON: { + type: 'text', + index: false, + }, + }, + }, + migrations: () => { + const locatorMigrations = service.locators.getAllMigrations(); + const savedObjectLocatorMigrations: SavedObjectMigrationMap = {}; + + for (const [version, locatorMigration] of Object.entries(locatorMigrations)) { + savedObjectLocatorMigrations[version] = (doc) => { + const locator = JSON.parse(doc.attributes.locatorJSON) as LocatorData; + doc.attributes = { + ...doc.attributes, + locatorJSON: JSON.stringify(locatorMigration(locator)), + }; + return doc; + }; + } + + return savedObjectLocatorMigrations; + }, + }; + + so.registerType(urlSavedObjectType); +}; diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts index ac684eb03a9d5..503748a2b1cad 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts @@ -7,9 +7,11 @@ */ import { ServerShortUrlClientFactory } from './short_url_client_factory'; -import { UrlService } from '../../../common/url_service'; +import { UrlService, LocatorDefinition } from '../../../common/url_service'; import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator'; import { MemoryShortUrlStorage } from './storage/memory_short_url_storage'; +import { SerializableRecord } from '@kbn/utility-types'; +import { SavedObjectReference } from 'kibana/server'; const setup = () => { const currentVersion = '1.2.3'; @@ -20,9 +22,11 @@ const setup = () => { navigate: () => { throw new Error('Not implemented.'); }, - shortUrls: new ServerShortUrlClientFactory({ - currentVersion, - }), + shortUrls: ({ locators }) => + new ServerShortUrlClientFactory({ + currentVersion, + locators, + }), }); const definition = new LegacyShortUrlLocatorDefinition(); const locator = service.locators.create(definition); @@ -177,4 +181,111 @@ describe('ServerShortUrlClient', () => { ); }); }); + + describe('Persistable State', () => { + interface FooLocatorParams extends SerializableRecord { + dashboardId: string; + indexPatternId: string; + } + + class FooLocatorDefinition implements LocatorDefinition { + public readonly id = 'FOO_LOCATOR'; + + public readonly getLocation = async () => ({ + app: 'foo_app', + path: '/foo/path', + state: {}, + }); + + public readonly extract = ( + state: FooLocatorParams + ): { state: FooLocatorParams; references: SavedObjectReference[] } => ({ + state, + references: [ + { + id: state.dashboardId, + type: 'dashboard', + name: 'dashboardId', + }, + { + id: state.indexPatternId, + type: 'index_pattern', + name: 'indexPatternId', + }, + ], + }); + + public readonly inject = ( + state: FooLocatorParams, + references: SavedObjectReference[] + ): FooLocatorParams => { + const dashboard = references.find( + (ref) => ref.type === 'dashboard' && ref.name === 'dashboardId' + ); + const indexPattern = references.find( + (ref) => ref.type === 'index_pattern' && ref.name === 'indexPatternId' + ); + + return { + ...state, + dashboardId: dashboard ? dashboard.id : '', + indexPatternId: indexPattern ? indexPattern.id : '', + }; + }; + } + + test('extracts and persists references', async () => { + const { service, client, storage } = setup(); + const locator = service.locators.create(new FooLocatorDefinition()); + const shortUrl = await client.create({ + locator, + params: { + dashboardId: '123', + indexPatternId: '456', + }, + }); + const record = await storage.getById(shortUrl.data.id); + + expect(record.references).toEqual([ + { + id: '123', + type: 'dashboard', + name: 'locator:params:dashboardId', + }, + { + id: '456', + type: 'index_pattern', + name: 'locator:params:indexPatternId', + }, + ]); + }); + + test('injects references', async () => { + const { service, client, storage } = setup(); + const locator = service.locators.create(new FooLocatorDefinition()); + const shortUrl1 = await client.create({ + locator, + params: { + dashboardId: '3', + indexPatternId: '5', + }, + }); + const record1 = await storage.getById(shortUrl1.data.id); + + record1.data.locator.state = {}; + + await storage.update(record1.data.id, record1.data); + + const record2 = await storage.getById(shortUrl1.data.id); + + expect(record2.data.locator.state).toEqual({}); + + const shortUrl2 = await client.get(shortUrl1.data.id); + + expect(shortUrl2.data.locator.state).toEqual({ + dashboardId: '3', + indexPatternId: '5', + }); + }); + }); }); diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.ts index caaa76bef172d..1efece073d955 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.ts @@ -7,8 +7,17 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; +import { SavedObjectReference } from 'kibana/server'; import { generateSlug } from 'random-word-slugs'; -import type { IShortUrlClient, ShortUrl, ShortUrlCreateParams } from '../../../common/url_service'; +import { ShortUrlRecord } from '.'; +import type { + IShortUrlClient, + ShortUrl, + ShortUrlCreateParams, + ILocatorClient, + ShortUrlData, + LocatorData, +} from '../../../common/url_service'; import type { ShortUrlStorage } from './types'; import { validateSlug } from './util'; @@ -36,6 +45,11 @@ export interface ServerShortUrlClientDependencies { * Storage provider for short URLs. */ storage: ShortUrlStorage; + + /** + * The locators service. + */ + locators: ILocatorClient; } export class ServerShortUrlClient implements IShortUrlClient { @@ -64,44 +78,80 @@ export class ServerShortUrlClient implements IShortUrlClient { } } + const extracted = this.extractReferences({ + id: locator.id, + version: currentVersion, + state: params, + }); const now = Date.now(); - const data = await storage.create({ - accessCount: 0, - accessDate: now, - createDate: now, - slug, - locator: { - id: locator.id, - version: currentVersion, - state: params, + + const data = await storage.create

( + { + accessCount: 0, + accessDate: now, + createDate: now, + slug, + locator: extracted.state as LocatorData

, }, - }); + { references: extracted.references } + ); return { data, }; } - public async get(id: string): Promise { - const { storage } = this.dependencies; - const data = await storage.getById(id); + private extractReferences(locatorData: LocatorData): { + state: LocatorData; + references: SavedObjectReference[]; + } { + const { locators } = this.dependencies; + const { state, references } = locators.extract(locatorData); + return { + state, + references: references.map((ref) => ({ + ...ref, + name: 'locator:' + ref.name, + })), + }; + } + private injectReferences({ data, references }: ShortUrlRecord): ShortUrlData { + const { locators } = this.dependencies; + const locatorReferences = references + .filter((ref) => ref.name.startsWith('locator:')) + .map((ref) => ({ + ...ref, + name: ref.name.substr('locator:'.length), + })); return { - data, + ...data, + locator: locators.inject(data.locator, locatorReferences), }; } - public async delete(id: string): Promise { + public async get(id: string): Promise { const { storage } = this.dependencies; - await storage.delete(id); + const record = await storage.getById(id); + const data = this.injectReferences(record); + + return { + data, + }; } public async resolve(slug: string): Promise { const { storage } = this.dependencies; - const data = await storage.getBySlug(slug); + const record = await storage.getBySlug(slug); + const data = this.injectReferences(record); return { data, }; } + + public async delete(id: string): Promise { + const { storage } = this.dependencies; + await storage.delete(id); + } } diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts index 696233b7a1ca5..63456c36daa68 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { ShortUrlStorage } from './types'; -import type { IShortUrlClientFactory } from '../../../common/url_service'; +import type { IShortUrlClientFactory, ILocatorClient } from '../../../common/url_service'; import { ServerShortUrlClient } from './short_url_client'; import { SavedObjectShortUrlStorage } from './storage/saved_object_short_url_storage'; @@ -20,6 +20,11 @@ export interface ServerShortUrlClientFactoryDependencies { * Current version of Kibana, e.g. 7.15.0. */ currentVersion: string; + + /** + * Locators service. + */ + locators: ILocatorClient; } export interface ServerShortUrlClientFactoryCreateParams { @@ -39,9 +44,11 @@ export class ServerShortUrlClientFactory savedObjects: params.savedObjects!, savedObjectType: 'url', }); + const { currentVersion, locators } = this.dependencies; const client = new ServerShortUrlClient({ storage, - currentVersion: this.dependencies.currentVersion, + currentVersion, + locators, }); return client; diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts index d178e0b81786c..5d1b0bfa0bf55 100644 --- a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts +++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts @@ -41,6 +41,46 @@ describe('.create()', () => { }); }); +describe('.update()', () => { + test('can update an existing short URL', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + const url1 = await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + + await storage.update(url1.id, { + accessCount: 1, + }); + + const url2 = await storage.getById(url1.id); + + expect(url1.accessCount).toBe(0); + expect(url2.data.accessCount).toBe(1); + }); + + test('throws when URL does not exist', async () => { + const storage = new MemoryShortUrlStorage(); + const [, error] = await of( + storage.update('DOES_NOT_EXIST', { + accessCount: 1, + }) + ); + + expect(error).toBeInstanceOf(Error); + }); +}); + describe('.getById()', () => { test('can fetch by ID a newly created short URL', async () => { const storage = new MemoryShortUrlStorage(); @@ -58,7 +98,7 @@ describe('.getById()', () => { }, slug: 'test-slug', }); - const url2 = await storage.getById(url1.id); + const url2 = (await storage.getById(url1.id)).data; expect(url2.accessCount).toBe(0); expect(url1.createDate).toBe(now); @@ -112,7 +152,7 @@ describe('.getBySlug()', () => { }, slug: 'test-slug', }); - const url2 = await storage.getBySlug('test-slug'); + const url2 = (await storage.getBySlug('test-slug')).data; expect(url2.accessCount).toBe(0); expect(url1.createDate).toBe(now); diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts index 40d76a91154ba..fafd00344eecd 100644 --- a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts +++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts @@ -9,35 +9,54 @@ import { v4 as uuidv4 } from 'uuid'; import type { SerializableRecord } from '@kbn/utility-types'; import { ShortUrlData } from 'src/plugins/share/common/url_service/short_urls/types'; -import { ShortUrlStorage } from '../types'; +import { SavedObjectReference } from 'kibana/server'; +import { ShortUrlStorage, ShortUrlRecord } from '../types'; + +const clone =

(obj: P): P => JSON.parse(JSON.stringify(obj)) as P; export class MemoryShortUrlStorage implements ShortUrlStorage { - private urls = new Map(); + private urls = new Map(); public async create

( - data: Omit, 'id'> + data: Omit, 'id'>, + { references = [] }: { references?: SavedObjectReference[] } = {} ): Promise> { const id = uuidv4(); - const url: ShortUrlData

= { ...data, id }; + const url: ShortUrlRecord

= { + data: { ...data, id }, + references, + }; this.urls.set(id, url); - return url; + + return clone(url.data); + } + + public async update

( + id: string, + data: Partial, 'id'>>, + { references }: { references?: SavedObjectReference[] } = {} + ): Promise { + const so = await this.getById(id); + Object.assign(so.data, data); + if (references) so.references = references; + this.urls.set(id, so); } public async getById

( id: string - ): Promise> { + ): Promise> { if (!this.urls.has(id)) { throw new Error(`No short url with id "${id}"`); } - return this.urls.get(id)! as ShortUrlData

; + return clone(this.urls.get(id)! as ShortUrlRecord

); } public async getBySlug

( slug: string - ): Promise> { + ): Promise> { for (const url of this.urls.values()) { - if (url.slug === slug) { - return url as ShortUrlData

; + if (url.data.slug === slug) { + return clone(url as ShortUrlRecord

); } } throw new Error(`No short url with slug "${slug}".`); @@ -45,7 +64,7 @@ export class MemoryShortUrlStorage implements ShortUrlStorage { public async exists(slug: string): Promise { for (const url of this.urls.values()) { - if (url.slug === slug) { + if (url.data.slug === slug) { return true; } } diff --git a/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts index c66db6d82cdbd..792dfabde3cab 100644 --- a/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts +++ b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts @@ -7,7 +7,8 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; -import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; +import { SavedObject, SavedObjectReference, SavedObjectsClientContract } from 'kibana/server'; +import { ShortUrlRecord } from '..'; import { LEGACY_SHORT_URL_LOCATOR_ID } from '../../../../common/url_service/locators/legacy_short_url_locator'; import { ShortUrlData } from '../../../../common/url_service/short_urls/types'; import { ShortUrlStorage } from '../types'; @@ -85,12 +86,15 @@ const createShortUrlData =

( }; const createAttributes =

( - data: Omit, 'id'> + data: Partial, 'id'>> ): ShortUrlSavedObjectAttributes => { - const { locator, ...rest } = data; + const { accessCount = 0, accessDate = 0, createDate = 0, slug = '', locator } = data; const attributes: ShortUrlSavedObjectAttributes = { - ...rest, - locatorJSON: JSON.stringify(locator), + accessCount, + accessDate, + createDate, + slug, + locatorJSON: locator ? JSON.stringify(locator) : '', url: '', }; @@ -106,30 +110,49 @@ export class SavedObjectShortUrlStorage implements ShortUrlStorage { constructor(private readonly dependencies: SavedObjectShortUrlStorageDependencies) {} public async create

( - data: Omit, 'id'> + data: Omit, 'id'>, + { references }: { references?: SavedObjectReference[] } = {} ): Promise> { const { savedObjects, savedObjectType } = this.dependencies; const attributes = createAttributes(data); const savedObject = await savedObjects.create(savedObjectType, attributes, { refresh: true, + references, }); return createShortUrlData

(savedObject); } + public async update

( + id: string, + data: Partial, 'id'>>, + { references }: { references?: SavedObjectReference[] } = {} + ): Promise { + const { savedObjects, savedObjectType } = this.dependencies; + const attributes = createAttributes(data); + + await savedObjects.update(savedObjectType, id, attributes, { + refresh: true, + references, + }); + } + public async getById

( id: string - ): Promise> { + ): Promise> { const { savedObjects, savedObjectType } = this.dependencies; const savedObject = await savedObjects.get(savedObjectType, id); - return createShortUrlData

(savedObject); + return { + data: createShortUrlData

(savedObject), + references: savedObject.references, + }; } public async getBySlug

( slug: string - ): Promise> { + ): Promise> { const { savedObjects } = this.dependencies; const search = `(attributes.slug:"${escapeSearchReservedChars(slug)}")`; const result = await savedObjects.find({ @@ -143,7 +166,10 @@ export class SavedObjectShortUrlStorage implements ShortUrlStorage { const savedObject = result.saved_objects[0] as ShortUrlSavedObject; - return createShortUrlData

(savedObject); + return { + data: createShortUrlData

(savedObject), + references: savedObject.references, + }; } public async exists(slug: string): Promise { diff --git a/src/plugins/share/server/url_service/short_urls/types.ts b/src/plugins/share/server/url_service/short_urls/types.ts index 7aab70ca49519..9a9d9006eb371 100644 --- a/src/plugins/share/server/url_service/short_urls/types.ts +++ b/src/plugins/share/server/url_service/short_urls/types.ts @@ -7,6 +7,7 @@ */ import type { SerializableRecord } from '@kbn/utility-types'; +import { SavedObjectReference } from 'kibana/server'; import { ShortUrlData } from '../../../common/url_service/short_urls/types'; /** @@ -17,20 +18,32 @@ export interface ShortUrlStorage { * Create and store a new short URL entry. */ create

( - data: Omit, 'id'> + data: Omit, 'id'>, + options?: { references?: SavedObjectReference[] } ): Promise>; + /** + * Update an existing short URL entry. + */ + update

( + id: string, + data: Partial, 'id'>>, + options?: { references?: SavedObjectReference[] } + ): Promise; + /** * Fetch a short URL entry by ID. */ - getById

(id: string): Promise>; + getById

( + id: string + ): Promise>; /** * Fetch a short URL entry by slug. */ getBySlug

( slug: string - ): Promise>; + ): Promise>; /** * Checks if a short URL exists by slug. @@ -42,3 +55,8 @@ export interface ShortUrlStorage { */ delete(id: string): Promise; } + +export interface ShortUrlRecord { + data: ShortUrlData; + references: SavedObjectReference[]; +} diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 617af6dae484d..abdab939f4a0a 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -100,7 +100,7 @@ const urlService = new UrlService({ getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, - shortUrls: {} as any, + shortUrls: () => ({ get: () => {} } as any), }); const locator = urlService.locators.create(new MlLocatorDefinition()); From 9e908f6caa3b1030e931ccf38b03c40f0f9deaef Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Tue, 12 Oct 2021 14:43:50 -0400 Subject: [PATCH 47/68] [RAC][Security Solution] Refactor persistence and security rule generic types (#114022) * Refactor persistence and security rule generic types * Remove unused import, fix unit tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/rule_registry/server/index.ts | 2 +- ...> create_persistence_rule_type_wrapper.ts} | 4 +- .../server/utils/persistence_types.ts | 54 +++++++++----- ...s => create_security_rule_type_wrapper.ts} | 33 +++++++-- .../eql/create_eql_alert_type.test.ts | 12 ++- .../rule_types/eql/create_eql_alert_type.ts | 24 ++---- .../factories/bulk_create_factory.ts | 18 ++++- .../create_indicator_match_alert_type.test.ts | 60 +++++++-------- .../create_indicator_match_alert_type.ts | 24 ++---- .../lib/detection_engine/rule_types/ml.ts | 71 ------------------ .../ml/create_ml_alert_type.test.ts | 14 +++- .../rule_types/ml/create_ml_alert_type.ts | 23 ++---- .../query/create_query_alert_type.test.ts | 43 ++++++----- .../query/create_query_alert_type.ts | 24 ++---- .../create_threshold_alert_type.test.ts | 14 +++- .../threshold/create_threshold_alert_type.ts | 25 ++----- .../lib/detection_engine/rule_types/types.ts | 74 ++++++++----------- .../security_solution/server/plugin.ts | 31 ++++++-- 18 files changed, 254 insertions(+), 296 deletions(-) rename x-pack/plugins/rule_registry/server/utils/{create_persistence_rule_type_factory.ts => create_persistence_rule_type_wrapper.ts} (91%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/{create_security_rule_type_factory.ts => create_security_rule_type_wrapper.ts} (92%) delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml.ts diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index b287e6a3e4688..5331ab86be982 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -32,7 +32,7 @@ export { LifecycleAlertServices, createLifecycleExecutor, } from './utils/create_lifecycle_executor'; -export { createPersistenceRuleTypeFactory } from './utils/create_persistence_rule_type_factory'; +export { createPersistenceRuleTypeWrapper } from './utils/create_persistence_rule_type_wrapper'; export * from './utils/persistence_types'; export type { AlertsClient } from './alert_data_client/alerts_client'; diff --git a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts similarity index 91% rename from x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_factory.ts rename to x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts index 837d0378703f7..86b6cf72ed1f1 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts @@ -7,9 +7,9 @@ import { ALERT_INSTANCE_ID, VERSION } from '@kbn/rule-data-utils'; import { getCommonAlertFields } from './get_common_alert_fields'; -import { CreatePersistenceRuleTypeFactory } from './persistence_types'; +import { CreatePersistenceRuleTypeWrapper } from './persistence_types'; -export const createPersistenceRuleTypeFactory: CreatePersistenceRuleTypeFactory = +export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper = ({ logger, ruleDataClient }) => (type) => { return { diff --git a/x-pack/plugins/rule_registry/server/utils/persistence_types.ts b/x-pack/plugins/rule_registry/server/utils/persistence_types.ts index 11607909a2e0f..5da05d9956d7f 100644 --- a/x-pack/plugins/rule_registry/server/utils/persistence_types.ts +++ b/x-pack/plugins/rule_registry/server/utils/persistence_types.ts @@ -8,43 +8,59 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { BulkResponse } from '@elastic/elasticsearch/api/types'; import { Logger } from '@kbn/logging'; -import { ESSearchRequest } from 'src/core/types/elasticsearch'; import { + AlertExecutorOptions, AlertInstanceContext, AlertInstanceState, + AlertType, AlertTypeParams, AlertTypeState, } from '../../../alerting/server'; +import { WithoutReservedActionGroups } from '../../../alerting/common'; import { IRuleDataClient } from '../rule_data_client'; -import { AlertTypeWithExecutor } from '../types'; -export type PersistenceAlertService< - TState extends AlertInstanceState = never, - TContext extends AlertInstanceContext = never, - TActionGroupIds extends string = never -> = ( +export type PersistenceAlertService = ( alerts: Array<{ id: string; fields: Record; }>, refresh: boolean | 'wait_for' -) => Promise>; +) => Promise | undefined>; -export type PersistenceAlertQueryService = ( - query: ESSearchRequest -) => Promise>>; -export interface PersistenceServices { - alertWithPersistence: PersistenceAlertService; +export interface PersistenceServices { + alertWithPersistence: PersistenceAlertService; } -export type CreatePersistenceRuleTypeFactory = (options: { +export type PersistenceAlertType< + TParams extends AlertTypeParams, + TState extends AlertTypeState, + TInstanceContext extends AlertInstanceContext = {}, + TActionGroupIds extends string = never +> = Omit< + AlertType, + 'executor' +> & { + executor: ( + options: AlertExecutorOptions< + TParams, + TState, + AlertInstanceState, + TInstanceContext, + WithoutReservedActionGroups + > & { + services: PersistenceServices; + } + ) => Promise; +}; + +export type CreatePersistenceRuleTypeWrapper = (options: { ruleDataClient: IRuleDataClient; logger: Logger; }) => < - TState extends AlertTypeState, TParams extends AlertTypeParams, - TServices extends PersistenceServices, - TAlertInstanceContext extends AlertInstanceContext = {} + TState extends AlertTypeState, + TInstanceContext extends AlertInstanceContext = {}, + TActionGroupIds extends string = never >( - type: AlertTypeWithExecutor -) => AlertTypeWithExecutor; + type: PersistenceAlertType +) => AlertType; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts similarity index 92% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index 9ea36abe997c3..b037e572f21b7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -14,7 +14,7 @@ import { parseScheduleDates } from '@kbn/securitysolution-io-ts-utils'; import { ListArray } from '@kbn/securitysolution-io-ts-list-types'; import { toError } from '@kbn/securitysolution-list-api'; -import { createPersistenceRuleTypeFactory } from '../../../../../rule_registry/server'; +import { createPersistenceRuleTypeWrapper } from '../../../../../rule_registry/server'; import { buildRuleMessageFactory } from './factories/build_rule_message_factory'; import { checkPrivilegesFromEsClient, @@ -22,10 +22,14 @@ import { getRuleRangeTuples, hasReadIndexPrivileges, hasTimestampFields, - isMachineLearningParams, + isEqlParams, + isQueryParams, + isSavedQueryParams, + isThreatParams, + isThresholdParams, } from '../signals/utils'; import { DEFAULT_MAX_SIGNALS, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; -import { CreateSecurityRuleTypeFactory } from './types'; +import { CreateSecurityRuleTypeWrapper } from './types'; import { getListClient } from './utils/get_list_client'; import { NotificationRuleTypeParams, @@ -37,13 +41,14 @@ import { bulkCreateFactory, wrapHitsFactory, wrapSequencesFactory } from './fact import { RuleExecutionLogClient } from '../rule_execution_log/rule_execution_log_client'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; import { scheduleThrottledNotificationActions } from '../notifications/schedule_throttle_notification_actions'; +import { AlertAttributes } from '../signals/types'; /* eslint-disable complexity */ -export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = +export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = ({ lists, logger, config, ruleDataClient, eventLogService }) => (type) => { const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config; - const persistenceRuleType = createPersistenceRuleTypeFactory({ ruleDataClient, logger }); + const persistenceRuleType = createPersistenceRuleTypeWrapper({ ruleDataClient, logger }); return persistenceRuleType({ ...type, async executor(options) { @@ -69,7 +74,10 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = eventLogService, underlyingClient: config.ruleExecutionLog.underlyingClient, }); - const ruleSO = await savedObjectsClient.get('alert', alertId); + const ruleSO = await savedObjectsClient.get>( + 'alert', + alertId + ); const { actions, @@ -107,7 +115,16 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = // move this collection of lines into a function in utils // so that we can use it in create rules route, bulk, etc. try { - if (!isMachineLearningParams(params)) { + // Typescript 4.1.3 can't figure out that `!isMachineLearningParams(params)` also excludes the only rule type + // of rule params that doesn't include `params.index`, but Typescript 4.3.5 does compute the stricter type correctly. + // When we update Typescript to >= 4.3.5, we can replace this logic with `!isMachineLearningParams(params)` again. + if ( + isEqlParams(params) || + isThresholdParams(params) || + isQueryParams(params) || + isSavedQueryParams(params) || + isThreatParams(params) + ) { const index = params.index; const hasTimestampOverride = !!timestampOverride; @@ -254,7 +271,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = createdSignals, createdSignalsCount: createdSignals.length, errors: result.errors.concat(runResult.errors), - lastLookbackDate: runResult.lastLookbackDate, + lastLookbackDate: runResult.lastLookBackDate, searchAfterTimes: result.searchAfterTimes.concat(runResult.searchAfterTimes), state: runState, success: result.success && runResult.success, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts index 43860d396ac5d..486a692ba29f4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts @@ -12,6 +12,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { createEqlAlertType } from './create_eql_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { getEqlRuleParams } from '../../schemas/rule_schemas.mock'; +import { createSecurityRuleTypeWrapper } from '../create_security_rule_type_wrapper'; import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../rule_execution_log/rule_execution_log_client'); @@ -23,15 +24,20 @@ describe('Event correlation alerts', () => { query: 'any where false', }; const { services, dependencies, executor } = createRuleTypeMocks('eql', params); - const eqlAlertType = createEqlAlertType({ - experimentalFeatures: allowedExperimentalValues, + const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({ lists: dependencies.lists, logger: dependencies.logger, config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, eventLogService: dependencies.eventLogService, - version: '1.0.0', }); + const eqlAlertType = securityRuleTypeWrapper( + createEqlAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + version: '1.0.0', + }) + ); dependencies.alerting.registerType(eqlAlertType); services.scopedClusterClient.asCurrentUser.search.mockReturnValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts index 9324b469bf644..f09f013301dea 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts @@ -6,24 +6,16 @@ */ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; -import { PersistenceServices } from '../../../../../../rule_registry/server'; import { EQL_RULE_TYPE_ID } from '../../../../../common/constants'; -import { eqlRuleParams, EqlRuleParams } from '../../schemas/rule_schemas'; +import { EqlRuleParams, eqlRuleParams } from '../../schemas/rule_schemas'; import { eqlExecutor } from '../../signals/executors/eql'; -import { createSecurityRuleTypeFactory } from '../create_security_rule_type_factory'; -import { CreateRuleOptions } from '../types'; +import { CreateRuleOptions, SecurityAlertType } from '../types'; -export const createEqlAlertType = (createOptions: CreateRuleOptions) => { - const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = - createOptions; - const createSecurityRuleType = createSecurityRuleTypeFactory({ - lists, - logger, - config, - ruleDataClient, - eventLogService, - }); - return createSecurityRuleType({ +export const createEqlAlertType = ( + createOptions: CreateRuleOptions +): SecurityAlertType => { + const { experimentalFeatures, logger, version } = createOptions; + return { id: EQL_RULE_TYPE_ID, name: 'Event Correlation Rule', validate: { @@ -83,5 +75,5 @@ export const createEqlAlertType = (createOptions: CreateRuleOptions) => { }); return { ...result, state }; }, - }); + }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts index af0a8a27f2b25..3c12adbca3e44 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts @@ -16,7 +16,6 @@ import { BuildRuleMessage } from '../../signals/rule_messages'; import { errorAggregator, makeFloatString } from '../../signals/utils'; import { RefreshTypes } from '../../types'; import { PersistenceAlertService } from '../../../../../../rule_registry/server'; -import { AlertInstanceContext } from '../../../../../../alerting/common'; export interface GenericBulkCreateResponse { success: boolean; @@ -27,9 +26,9 @@ export interface GenericBulkCreateResponse { } export const bulkCreateFactory = - ( + ( logger: Logger, - alertWithPersistence: PersistenceAlertService, + alertWithPersistence: PersistenceAlertService, buildRuleMessage: BuildRuleMessage, refreshForBulkCreate: RefreshTypes ) => @@ -61,6 +60,19 @@ export const bulkCreateFactory = `individual bulk process time took: ${makeFloatString(end - start)} milliseconds` ) ); + + if (response == null) { + return { + errors: [ + 'alertWithPersistence returned undefined response. Alerts as Data write flag may be disabled.', + ], + success: false, + bulkCreateDuration: makeFloatString(end - start), + createdItemsCount: 0, + createdItems: [], + }; + } + logger.debug( buildRuleMessage(`took property says bulk took: ${response.body.took} milliseconds`) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts index 3db4f5686abdc..576e409378213 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts @@ -16,6 +16,7 @@ import { createIndicatorMatchAlertType } from './create_indicator_match_alert_ty import { sampleDocNoSortId } from '../../signals/__mocks__/es_results'; import { CountResponse } from 'kibana/server'; import { RuleParams } from '../../schemas/rule_schemas'; +import { createSecurityRuleTypeWrapper } from '../create_security_rule_type_wrapper'; import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../utils/get_list_client', () => ({ @@ -50,18 +51,23 @@ describe('Indicator Match Alerts', () => { to: 'now', type: 'threat_match', }; + const { services, dependencies, executor } = createRuleTypeMocks('threat_match', params); + const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({ + lists: dependencies.lists, + logger: dependencies.logger, + config: createMockConfig(), + ruleDataClient: dependencies.ruleDataClient, + eventLogService: dependencies.eventLogService, + }); it('does not send an alert when no events found', async () => { - const { services, dependencies, executor } = createRuleTypeMocks('threat_match', params); - const indicatorMatchAlertType = createIndicatorMatchAlertType({ - experimentalFeatures: allowedExperimentalValues, - lists: dependencies.lists, - logger: dependencies.logger, - config: createMockConfig(), - ruleDataClient: dependencies.ruleDataClient, - eventLogService: dependencies.eventLogService, - version: '1.0.0', - }); + const indicatorMatchAlertType = securityRuleTypeWrapper( + createIndicatorMatchAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + version: '1.0.0', + }) + ); dependencies.alerting.registerType(indicatorMatchAlertType); @@ -92,16 +98,13 @@ describe('Indicator Match Alerts', () => { }); it('does not send an alert when no enrichments are found', async () => { - const { services, dependencies, executor } = createRuleTypeMocks('threat_match', params); - const indicatorMatchAlertType = createIndicatorMatchAlertType({ - experimentalFeatures: allowedExperimentalValues, - lists: dependencies.lists, - logger: dependencies.logger, - config: createMockConfig(), - ruleDataClient: dependencies.ruleDataClient, - eventLogService: dependencies.eventLogService, - version: '1.0.0', - }); + const indicatorMatchAlertType = securityRuleTypeWrapper( + createIndicatorMatchAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + version: '1.0.0', + }) + ); dependencies.alerting.registerType(indicatorMatchAlertType); @@ -130,16 +133,13 @@ describe('Indicator Match Alerts', () => { }); it('sends an alert when enrichments are found', async () => { - const { services, dependencies, executor } = createRuleTypeMocks('threat_match', params); - const indicatorMatchAlertType = createIndicatorMatchAlertType({ - experimentalFeatures: allowedExperimentalValues, - lists: dependencies.lists, - logger: dependencies.logger, - config: createMockConfig(), - ruleDataClient: dependencies.ruleDataClient, - eventLogService: dependencies.eventLogService, - version: '1.0.0', - }); + const indicatorMatchAlertType = securityRuleTypeWrapper( + createIndicatorMatchAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + version: '1.0.0', + }) + ); dependencies.alerting.registerType(indicatorMatchAlertType); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts index c30fdd7d99c2a..ee0688840811a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts @@ -6,24 +6,16 @@ */ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; -import { PersistenceServices } from '../../../../../../rule_registry/server'; import { INDICATOR_RULE_TYPE_ID } from '../../../../../common/constants'; -import { threatRuleParams, ThreatRuleParams } from '../../schemas/rule_schemas'; +import { ThreatRuleParams, threatRuleParams } from '../../schemas/rule_schemas'; import { threatMatchExecutor } from '../../signals/executors/threat_match'; -import { createSecurityRuleTypeFactory } from '../create_security_rule_type_factory'; -import { CreateRuleOptions } from '../types'; +import { CreateRuleOptions, SecurityAlertType } from '../types'; -export const createIndicatorMatchAlertType = (createOptions: CreateRuleOptions) => { - const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = - createOptions; - const createSecurityRuleType = createSecurityRuleTypeFactory({ - lists, - logger, - config, - ruleDataClient, - eventLogService, - }); - return createSecurityRuleType({ +export const createIndicatorMatchAlertType = ( + createOptions: CreateRuleOptions +): SecurityAlertType => { + const { experimentalFeatures, logger, version } = createOptions; + return { id: INDICATOR_RULE_TYPE_ID, name: 'Indicator Match Rule', validate: { @@ -86,5 +78,5 @@ export const createIndicatorMatchAlertType = (createOptions: CreateRuleOptions) }); return { ...result, state }; }, - }); + }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml.ts deleted file mode 100644 index e0ad333b76a24..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - -import { schema } from '@kbn/config-schema'; -import { KibanaRequest, Logger } from 'src/core/server'; -import { SavedObject } from 'src/core/types'; - -import { buildEsQuery, IIndexPattern } from '../../../../../../../src/plugins/data/common'; - -import { createPersistenceRuleTypeFactory } from '../../../../../rule_registry/server'; -import { ML_RULE_TYPE_ID } from '../../../../common/constants'; -import { SecurityRuleRegistry } from '../../../plugin'; - -const createSecurityMlRuleType = createPersistenceRuleTypeFactory(); - -import { - AlertInstanceContext, - AlertInstanceState, - AlertServices, -} from '../../../../../alerting/server'; -import { ListClient } from '../../../../../lists/server'; -import { isJobStarted } from '../../../../common/machine_learning/helpers'; -import { ExceptionListItemSchema } from '../../../../common/shared_imports'; -import { SetupPlugins } from '../../../plugin'; -import { RefreshTypes } from '../types'; -import { bulkCreateMlSignals } from '../signals/bulk_create_ml_signals'; -import { filterEventsAgainstList } from '../signals/filters/filter_events_against_list'; -import { findMlSignals } from '../signals/find_ml_signals'; -import { BuildRuleMessage } from '../signals/rule_messages'; -import { RuleStatusService } from '../signals/rule_status_service'; -import { MachineLearningRuleAttributes } from '../signals/types'; -import { createErrorsFromShard, createSearchAfterReturnType, mergeReturns } from '../signals/utils'; - -export const mlAlertType = createSecurityMlRuleType({ - id: ML_RULE_TYPE_ID, - name: 'Machine Learning Rule', - validate: { - params: schema.object({ - indexPatterns: schema.arrayOf(schema.string()), - customQuery: schema.string(), - }), - }, - actionGroups: [ - { - id: 'default', - name: 'Default', - }, - ], - defaultActionGroupId: 'default', - actionVariables: { - context: [{ name: 'server', description: 'the server' }], - }, - minimumLicenseRequired: 'basic', - isExportable: false, - producer: 'security-solution', - async executor({ - services: { alertWithPersistence, findAlerts }, - params: { indexPatterns, customQuery }, - }) { - return { - lastChecked: new Date(), - }; - }, -}); -*/ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts index bffc20c3df1e3..b7a099b10e275 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts @@ -14,6 +14,7 @@ import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { createMlAlertType } from './create_ml_alert_type'; import { RuleParams } from '../../schemas/rule_schemas'; +import { createSecurityRuleTypeWrapper } from '../create_security_rule_type_wrapper'; import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../signals/bulk_create_ml_signals'); @@ -94,16 +95,21 @@ describe('Machine Learning Alerts', () => { }, ]); const { dependencies, executor } = createRuleTypeMocks('machine_learning', params); - const mlAlertType = createMlAlertType({ - experimentalFeatures: allowedExperimentalValues, + const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({ lists: dependencies.lists, logger: dependencies.logger, config: createMockConfig(), - ml: mlMock, ruleDataClient: dependencies.ruleDataClient, eventLogService: dependencies.eventLogService, - version: '1.0.0', }); + const mlAlertType = securityRuleTypeWrapper( + createMlAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + ml: mlMock, + version: '1.0.0', + }) + ); dependencies.alerting.registerType(mlAlertType); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts index ac2d3f14831a4..756757c7c9956 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts @@ -6,23 +6,16 @@ */ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; -import { PersistenceServices } from '../../../../../../rule_registry/server'; import { ML_RULE_TYPE_ID } from '../../../../../common/constants'; -import { machineLearningRuleParams, MachineLearningRuleParams } from '../../schemas/rule_schemas'; +import { MachineLearningRuleParams, machineLearningRuleParams } from '../../schemas/rule_schemas'; import { mlExecutor } from '../../signals/executors/ml'; -import { createSecurityRuleTypeFactory } from '../create_security_rule_type_factory'; -import { CreateRuleOptions } from '../types'; +import { CreateRuleOptions, SecurityAlertType } from '../types'; -export const createMlAlertType = (createOptions: CreateRuleOptions) => { - const { lists, logger, config, ml, ruleDataClient, eventLogService } = createOptions; - const createSecurityRuleType = createSecurityRuleTypeFactory({ - lists, - logger, - config, - ruleDataClient, - eventLogService, - }); - return createSecurityRuleType({ +export const createMlAlertType = ( + createOptions: CreateRuleOptions +): SecurityAlertType => { + const { logger, ml } = createOptions; + return { id: ML_RULE_TYPE_ID, name: 'Machine Learning Rule', validate: { @@ -81,5 +74,5 @@ export const createMlAlertType = (createOptions: CreateRuleOptions) => { }); return { ...result, state }; }, - }); + }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index 4fdeac8047b1d..638c40c13cfe2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -14,6 +14,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { sampleDocNoSortId } from '../../signals/__mocks__/es_results'; import { createQueryAlertType } from './create_query_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; +import { createSecurityRuleTypeWrapper } from '../create_security_rule_type_wrapper'; import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../utils/get_list_client', () => ({ @@ -26,17 +27,22 @@ jest.mock('../utils/get_list_client', () => ({ jest.mock('../../rule_execution_log/rule_execution_log_client'); describe('Custom Query Alerts', () => { + const { services, dependencies, executor } = createRuleTypeMocks(); + const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({ + lists: dependencies.lists, + logger: dependencies.logger, + config: createMockConfig(), + ruleDataClient: dependencies.ruleDataClient, + eventLogService: dependencies.eventLogService, + }); it('does not send an alert when no events found', async () => { - const { services, dependencies, executor } = createRuleTypeMocks(); - const queryAlertType = createQueryAlertType({ - experimentalFeatures: allowedExperimentalValues, - lists: dependencies.lists, - logger: dependencies.logger, - config: createMockConfig(), - ruleDataClient: dependencies.ruleDataClient, - eventLogService: dependencies.eventLogService, - version: '1.0.0', - }); + const queryAlertType = securityRuleTypeWrapper( + createQueryAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + version: '1.0.0', + }) + ); dependencies.alerting.registerType(queryAlertType); @@ -74,16 +80,13 @@ describe('Custom Query Alerts', () => { }); it('sends a properly formatted alert when events are found', async () => { - const { services, dependencies, executor } = createRuleTypeMocks(); - const queryAlertType = createQueryAlertType({ - experimentalFeatures: allowedExperimentalValues, - lists: dependencies.lists, - logger: dependencies.logger, - config: createMockConfig(), - ruleDataClient: dependencies.ruleDataClient, - eventLogService: dependencies.eventLogService, - version: '1.0.0', - }); + const queryAlertType = securityRuleTypeWrapper( + createQueryAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + version: '1.0.0', + }) + ); dependencies.alerting.registerType(queryAlertType); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts index 469c237112dcb..aa2b25c422221 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts @@ -6,24 +6,16 @@ */ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; -import { PersistenceServices } from '../../../../../../rule_registry/server'; import { QUERY_RULE_TYPE_ID } from '../../../../../common/constants'; -import { queryRuleParams, QueryRuleParams } from '../../schemas/rule_schemas'; +import { QueryRuleParams, queryRuleParams } from '../../schemas/rule_schemas'; import { queryExecutor } from '../../signals/executors/query'; -import { createSecurityRuleTypeFactory } from '../create_security_rule_type_factory'; -import { CreateRuleOptions } from '../types'; +import { CreateRuleOptions, SecurityAlertType } from '../types'; -export const createQueryAlertType = (createOptions: CreateRuleOptions) => { - const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = - createOptions; - const createSecurityRuleType = createSecurityRuleTypeFactory({ - lists, - logger, - config, - ruleDataClient, - eventLogService, - }); - return createSecurityRuleType({ +export const createQueryAlertType = ( + createOptions: CreateRuleOptions +): SecurityAlertType => { + const { experimentalFeatures, logger, version } = createOptions; + return { id: QUERY_RULE_TYPE_ID, name: 'Custom Query Rule', validate: { @@ -86,5 +78,5 @@ export const createQueryAlertType = (createOptions: CreateRuleOptions) => { }); return { ...result, state }; }, - }); + }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts index aff57dbdf3cd4..093ec0af78f59 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts @@ -9,6 +9,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { createThresholdAlertType } from './create_threshold_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { getThresholdRuleParams } from '../../schemas/rule_schemas.mock'; +import { createSecurityRuleTypeWrapper } from '../create_security_rule_type_wrapper'; import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../rule_execution_log/rule_execution_log_client'); @@ -17,16 +18,21 @@ describe('Threshold Alerts', () => { it('does not send an alert when no events found', async () => { const params = getThresholdRuleParams(); const { dependencies, executor } = createRuleTypeMocks('threshold', params); - const thresholdAlertTpe = createThresholdAlertType({ - experimentalFeatures: allowedExperimentalValues, + const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({ lists: dependencies.lists, logger: dependencies.logger, config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, eventLogService: dependencies.eventLogService, - version: '1.0.0', }); - dependencies.alerting.registerType(thresholdAlertTpe); + const thresholdAlertType = securityRuleTypeWrapper( + createThresholdAlertType({ + experimentalFeatures: allowedExperimentalValues, + logger: dependencies.logger, + version: '1.0.0', + }) + ); + dependencies.alerting.registerType(thresholdAlertType); await executor({ params }); expect(dependencies.ruleDataClient.getWriter).not.toBeCalled(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts index 789e4525c58ab..2b3c1c0a8965b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts @@ -6,26 +6,17 @@ */ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; - -import { PersistenceServices } from '../../../../../../rule_registry/server'; import { THRESHOLD_RULE_TYPE_ID } from '../../../../../common/constants'; import { thresholdRuleParams, ThresholdRuleParams } from '../../schemas/rule_schemas'; import { thresholdExecutor } from '../../signals/executors/threshold'; import { ThresholdAlertState } from '../../signals/types'; -import { createSecurityRuleTypeFactory } from '../create_security_rule_type_factory'; -import { CreateRuleOptions } from '../types'; +import { CreateRuleOptions, SecurityAlertType } from '../types'; -export const createThresholdAlertType = (createOptions: CreateRuleOptions) => { - const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = - createOptions; - const createSecurityRuleType = createSecurityRuleTypeFactory({ - lists, - logger, - config, - ruleDataClient, - eventLogService, - }); - return createSecurityRuleType({ +export const createThresholdAlertType = ( + createOptions: CreateRuleOptions +): SecurityAlertType => { + const { experimentalFeatures, logger, version } = createOptions; + return { id: THRESHOLD_RULE_TYPE_ID, name: 'Threshold Rule', validate: { @@ -63,8 +54,6 @@ export const createThresholdAlertType = (createOptions: CreateRuleOptions) => { state, } = execOptions; - // console.log(JSON.stringify(state)); - const result = await thresholdExecutor({ buildRuleMessage, bulkCreate, @@ -82,5 +71,5 @@ export const createThresholdAlertType = (createOptions: CreateRuleOptions) => { return result; }, - }); + }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index c94339da03b93..393cb00939b24 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -11,28 +11,30 @@ import { SearchHit } from '@elastic/elasticsearch/api/types'; import { Logger } from '@kbn/logging'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { AlertExecutorOptions, AlertType } from '../../../../../alerting/server'; import { SavedObject } from '../../../../../../../src/core/server'; import { AlertInstanceContext, AlertInstanceState, - AlertTypeParams, AlertTypeState, + WithoutReservedActionGroups, } from '../../../../../alerting/common'; -import { AlertType } from '../../../../../alerting/server'; import { ListClient } from '../../../../../lists/server'; import { TechnicalRuleFieldMap } from '../../../../../rule_registry/common/assets/field_maps/technical_rule_field_map'; import { TypeOfFieldMap } from '../../../../../rule_registry/common/field_map'; -import { - AlertTypeWithExecutor, - PersistenceServices, - IRuleDataClient, -} from '../../../../../rule_registry/server'; +import { PersistenceServices, IRuleDataClient } from '../../../../../rule_registry/server'; import { BaseHit } from '../../../../common/detection_engine/types'; import { ConfigType } from '../../../config'; import { SetupPlugins } from '../../../plugin'; import { RuleParams } from '../schemas/rule_schemas'; import { BuildRuleMessage } from '../signals/rule_messages'; -import { AlertAttributes, BulkCreate, WrapHits, WrapSequences } from '../signals/types'; +import { + AlertAttributes, + BulkCreate, + SearchAfterAndBulkCreateReturnType, + WrapHits, + WrapSequences, +} from '../signals/types'; import { AlertsFieldMap, RulesFieldMap } from './field_maps'; import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { IEventLogService } from '../../../../../event_log/server'; @@ -50,12 +52,6 @@ export interface SecurityAlertTypeReturnValue { warningMessages: string[]; } -type SimpleAlertType< - TState extends AlertTypeState, - TParams extends AlertTypeParams = {}, - TAlertInstanceContext extends AlertInstanceContext = {} -> = AlertType; - export interface RunOpts { buildRuleMessage: BuildRuleMessage; bulkCreate: BulkCreate; @@ -72,44 +68,42 @@ export interface RunOpts { wrapSequences: WrapSequences; } -export type SecurityAlertTypeExecutor< - TState extends AlertTypeState, - TServices extends PersistenceServices, +export type SecurityAlertType< TParams extends RuleParams, - TAlertInstanceContext extends AlertInstanceContext = {} -> = ( - options: Parameters['executor']>[0] & { - runOpts: RunOpts; - } & { services: TServices } -) => Promise>; - -type SecurityAlertTypeWithExecutor< TState extends AlertTypeState, - TServices extends PersistenceServices, - TParams extends RuleParams, - TAlertInstanceContext extends AlertInstanceContext = {} + TInstanceContext extends AlertInstanceContext = {}, + TActionGroupIds extends string = never > = Omit< - AlertType, + AlertType, 'executor' > & { - executor: SecurityAlertTypeExecutor; + executor: ( + options: AlertExecutorOptions< + TParams, + TState, + AlertInstanceState, + TInstanceContext, + WithoutReservedActionGroups + > & { + services: PersistenceServices; + runOpts: RunOpts; + } + ) => Promise; }; -export type CreateSecurityRuleTypeFactory = (options: { +export type CreateSecurityRuleTypeWrapper = (options: { lists: SetupPlugins['lists']; logger: Logger; config: ConfigType; ruleDataClient: IRuleDataClient; eventLogService: IEventLogService; }) => < - TParams extends RuleParams & { index?: string[] | undefined }, - TAlertInstanceContext extends AlertInstanceContext, - TServices extends PersistenceServices, - TState extends AlertTypeState + TParams extends RuleParams, + TState extends AlertTypeState, + TInstanceContext extends AlertInstanceContext = {} >( - type: SecurityAlertTypeWithExecutor - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) => AlertTypeWithExecutor; + type: SecurityAlertType +) => AlertType; export type RACAlertSignal = TypeOfFieldMap & TypeOfFieldMap; export type RACAlert = Exclude< @@ -124,11 +118,7 @@ export type WrappedRACAlert = BaseHit; export interface CreateRuleOptions { experimentalFeatures: ExperimentalFeatures; - lists: SetupPlugins['lists']; logger: Logger; - config: ConfigType; ml?: SetupPlugins['ml']; - ruleDataClient: IRuleDataClient; version: string; - eventLogService: IEventLogService; } diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index f0a91f8b06c00..d54ed18af01e3 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -109,6 +109,7 @@ import { ctiFieldMap } from './lib/detection_engine/rule_types/field_maps/cti'; import { legacyRulesNotificationAlertType } from './lib/detection_engine/notifications/legacy_rules_notification_alert_type'; // eslint-disable-next-line no-restricted-imports import { legacyIsNotificationAlertExecutor } from './lib/detection_engine/notifications/legacy_types'; +import { createSecurityRuleTypeWrapper } from './lib/detection_engine/rule_types/create_security_rule_type_wrapper'; import { IEventLogClientService, IEventLogService } from '../../event_log/server'; import { registerEventLogProvider } from './lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider'; @@ -268,20 +269,34 @@ export class Plugin implements IPlugin Date: Tue, 12 Oct 2021 14:47:00 -0400 Subject: [PATCH 48/68] [Security Solution][Endpoint][TA] Trusted Apps license downgrade experience (#113048) --- ...-plugin-core-public.doclinksstart.links.md | 3 + ...kibana-plugin-core-public.doclinksstart.md | 3 +- .../public/doc_links/doc_links_service.ts | 6 + src/core/public/public.api.md | 3 + .../__mocks__/use_endpoint_privileges.ts | 1 + .../components/user_privileges/index.tsx | 7 +- .../use_endpoint_privileges.test.ts | 26 ++- .../use_endpoint_privileges.ts | 6 +- .../alerts/use_alerts_privileges.test.tsx | 7 +- .../search_exceptions/search_exceptions.tsx | 4 +- .../view/event_filters_list_page.test.tsx | 1 + .../host_isolation_exceptions_list.test.tsx | 1 + .../policy_trusted_apps_flyout.test.tsx | 1 + .../components/create_trusted_app_flyout.tsx | 61 ++++++- .../create_trusted_app_form.test.tsx | 68 ++++++++ .../components/create_trusted_app_form.tsx | 32 +++- .../effected_policy_select.test.tsx | 1 + .../effected_policy_select.tsx | 11 +- .../view/trusted_app_deletion_dialog.tsx | 16 +- .../view/trusted_apps_page.test.tsx | 152 ++++++++++++++---- 20 files changed, 345 insertions(+), 65 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index c8ccdfeedb83f..5871d7df6a7c5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -145,6 +145,9 @@ readonly links: { readonly networkMap: string; readonly troubleshootGaps: string; }; + readonly securitySolution: { + readonly trustedApps: string; + }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 04c2495cf3f1d..a4e842c317256 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,4 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly spaces: Readonly<{
kibanaLegacyUrlAliases: string;
kibanaDisableLegacyUrlAliasesApi: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | - +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
readonly suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
};
readonly securitySolution: {
readonly trustedApps: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: string;
}>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 01108298adc99..a4ca5722c6aca 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -233,6 +233,9 @@ export class DocLinksService { networkMap: `${SECURITY_SOLUTION_DOCS}conf-map-ui.html`, troubleshootGaps: `${SECURITY_SOLUTION_DOCS}alerts-ui-monitor.html#troubleshoot-gaps`, }, + securitySolution: { + trustedApps: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/trusted-apps-ov.html`, + }, query: { eql: `${ELASTICSEARCH_DOCS}eql.html`, kueryQuerySyntax: `${KIBANA_DOCS}kuery-query.html`, @@ -641,6 +644,9 @@ export interface DocLinksStart { readonly networkMap: string; readonly troubleshootGaps: string; }; + readonly securitySolution: { + readonly trustedApps: string; + }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 45b7e3bdc02b5..324066764768d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -614,6 +614,9 @@ export interface DocLinksStart { readonly networkMap: string; readonly troubleshootGaps: string; }; + readonly securitySolution: { + readonly trustedApps: string; + }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts index 80cf11fecd847..80ca534534187 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts @@ -12,6 +12,7 @@ export const useEndpointPrivileges = jest.fn(() => { loading: false, canAccessFleet: true, canAccessEndpointManagement: true, + isPlatinumPlus: true, }; return endpointPrivilegesMock; }); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx index 028473f5c2001..437d27278102b 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx @@ -24,7 +24,12 @@ export interface UserPrivilegesState { export const initialUserPrivilegesState = (): UserPrivilegesState => ({ listPrivileges: { loading: false, error: undefined, result: undefined }, detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, - endpointPrivileges: { loading: true, canAccessEndpointManagement: false, canAccessFleet: false }, + endpointPrivileges: { + loading: true, + canAccessEndpointManagement: false, + canAccessFleet: false, + isPlatinumPlus: false, + }, kibanaSecuritySolutionsPrivileges: { crud: false, read: false }, }); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts index a05d1ac8d3588..82443e913499b 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts @@ -5,15 +5,27 @@ * 2.0. */ -import { renderHook, RenderHookResult, RenderResult } from '@testing-library/react-hooks'; +import { act, renderHook, RenderHookResult, RenderResult } from '@testing-library/react-hooks'; import { useHttp, useCurrentUser } from '../../lib/kibana'; import { EndpointPrivileges, useEndpointPrivileges } from './use_endpoint_privileges'; import { securityMock } from '../../../../../security/public/mocks'; import { appRoutesService } from '../../../../../fleet/common'; import { AuthenticatedUser } from '../../../../../security/common'; +import { licenseService } from '../../hooks/use_license'; import { fleetGetCheckPermissionsHttpMock } from '../../../management/pages/mocks'; jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_license', () => { + const licenseServiceInstance = { + isPlatinumPlus: jest.fn(), + }; + return { + licenseService: licenseServiceInstance, + useLicense: () => { + return licenseServiceInstance; + }, + }; +}); describe('When using useEndpointPrivileges hook', () => { let authenticatedUser: AuthenticatedUser; @@ -33,6 +45,7 @@ describe('When using useEndpointPrivileges hook', () => { fleetApiMock = fleetGetCheckPermissionsHttpMock( useHttp() as Parameters[0] ); + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); render = () => { const hookRenderResponse = renderHook(() => useEndpointPrivileges()); @@ -60,6 +73,7 @@ describe('When using useEndpointPrivileges hook', () => { canAccessEndpointManagement: false, canAccessFleet: false, loading: true, + isPlatinumPlus: true, }); // Make user service available @@ -69,15 +83,19 @@ describe('When using useEndpointPrivileges hook', () => { canAccessEndpointManagement: false, canAccessFleet: false, loading: true, + isPlatinumPlus: true, }); // Release the API response - releaseApiResponse!(); - await fleetApiMock.waitForApi(); + await act(async () => { + fleetApiMock.waitForApi(); + releaseApiResponse!(); + }); expect(result.current).toEqual({ canAccessEndpointManagement: true, canAccessFleet: true, loading: false, + isPlatinumPlus: true, }); }); @@ -99,6 +117,7 @@ describe('When using useEndpointPrivileges hook', () => { canAccessEndpointManagement: false, canAccessFleet: true, // this is only true here because I did not adjust the API mock loading: false, + isPlatinumPlus: true, }); }); @@ -115,6 +134,7 @@ describe('When using useEndpointPrivileges hook', () => { canAccessEndpointManagement: false, canAccessFleet: false, loading: false, + isPlatinumPlus: true, }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts index b8db0c5c0fbc9..315935104d107 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts @@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useCurrentUser, useHttp } from '../../lib/kibana'; import { appRoutesService, CheckPermissionsResponse } from '../../../../../fleet/common'; +import { useLicense } from '../../hooks/use_license'; export interface EndpointPrivileges { loading: boolean; @@ -15,6 +16,7 @@ export interface EndpointPrivileges { canAccessFleet: boolean; /** If user has permissions to access Endpoint management (includes check to ensure they also have access to fleet) */ canAccessEndpointManagement: boolean; + isPlatinumPlus: boolean; } /** @@ -27,6 +29,7 @@ export const useEndpointPrivileges = (): EndpointPrivileges => { const http = useHttp(); const user = useCurrentUser(); const isMounted = useRef(true); + const license = useLicense(); const [canAccessFleet, setCanAccessFleet] = useState(false); const [fleetCheckDone, setFleetCheckDone] = useState(false); @@ -62,8 +65,9 @@ export const useEndpointPrivileges = (): EndpointPrivileges => { loading: !fleetCheckDone || !user, canAccessFleet, canAccessEndpointManagement: canAccessFleet && isSuperUser, + isPlatinumPlus: license.isPlatinumPlus(), }; - }, [canAccessFleet, fleetCheckDone, isSuperUser, user]); + }, [canAccessFleet, fleetCheckDone, isSuperUser, user, license]); // Capture if component is unmounted useEffect( diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx index cbab24835c1ac..40894c1d01929 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx @@ -86,7 +86,12 @@ const userPrivilegesInitial: ReturnType = { result: undefined, error: undefined, }, - endpointPrivileges: { loading: true, canAccessEndpointManagement: false, canAccessFleet: false }, + endpointPrivileges: { + loading: true, + canAccessEndpointManagement: false, + canAccessFleet: false, + isPlatinumPlus: true, + }, kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, }; diff --git a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx index a77a2a41038d7..2b7b2e6b66884 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx @@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton } from '@elastic/e import { i18n } from '@kbn/i18n'; import { PolicySelectionItem, PoliciesSelector } from '../policies_selector'; import { ImmutableArray, PolicyData } from '../../../../common/endpoint/types'; +import { useEndpointPrivileges } from '../../../common/components/user_privileges/use_endpoint_privileges'; export interface SearchExceptionsProps { defaultValue?: string; @@ -31,6 +32,7 @@ export const SearchExceptions = memo( defaultIncludedPolicies, defaultExcludedPolicies, }) => { + const { isPlatinumPlus } = useEndpointPrivileges(); const [query, setQuery] = useState(defaultValue); const [includedPolicies, setIncludedPolicies] = useState(defaultIncludedPolicies || ''); const [excludedPolicies, setExcludedPolicies] = useState(defaultExcludedPolicies || ''); @@ -88,7 +90,7 @@ export const SearchExceptions = memo( data-test-subj="searchField" /> - {hasPolicyFilter && policyList ? ( + {isPlatinumPlus && hasPolicyFilter && policyList ? ( { let render: () => ReturnType; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index ac472fdae4d7b..9de3d83ed8bab 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -15,6 +15,7 @@ import { isFailedResourceState, isLoadedResourceState } from '../../../state'; import { getHostIsolationExceptionItems } from '../service'; import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; +jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); jest.mock('../service'); const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx index a586c3c9d1b29..b07005908ed1e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx @@ -21,6 +21,7 @@ import { createLoadedResourceState, isLoadedResourceState } from '../../../../.. import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; jest.mock('../../../../trusted_apps/service'); +jest.mock('../../../../../../common/components/user_privileges/use_endpoint_privileges'); let mockedContext: AppContextTestRender; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx index 7abf5d77dd5e9..f72d54aa9e3c9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx @@ -8,22 +8,25 @@ import { EuiButton, EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, + EuiLink, EuiSpacer, EuiText, EuiTitle, } from '@elastic/eui'; -import React, { memo, useCallback, useEffect, useMemo } from 'react'; +import React, { memo, useCallback, useEffect, useState, useMemo } from 'react'; import { EuiFlyoutProps } from '@elastic/eui/src/components/flyout/flyout'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; import { CreateTrustedAppForm, CreateTrustedAppFormProps } from './create_trusted_app_form'; import { editTrustedAppFetchError, @@ -43,10 +46,14 @@ import { useTrustedAppsSelector } from '../hooks'; import { ABOUT_TRUSTED_APPS, CREATE_TRUSTED_APP_ERROR } from '../translations'; import { defaultNewTrustedApp } from '../../store/builders'; import { getTrustedAppsListPath } from '../../../../common/routing'; -import { useToasts } from '../../../../../common/lib/kibana'; +import { useKibana, useToasts } from '../../../../../common/lib/kibana'; import { useTestIdGenerator } from '../../../../components/hooks/use_test_id_generator'; +import { useLicense } from '../../../../../common/hooks/use_license'; +import { isGlobalEffectScope } from '../../state/type_guards'; +import { NewTrustedApp } from '../../../../../../common/endpoint/types'; +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; -type CreateTrustedAppFlyoutProps = Omit; +export type CreateTrustedAppFlyoutProps = Omit; export const CreateTrustedAppFlyout = memo( ({ onClose, ...flyoutProps }) => { const dispatch = useDispatch<(action: AppAction) => void>(); @@ -63,6 +70,9 @@ export const CreateTrustedAppFlyout = memo( const trustedAppFetchError = useTrustedAppsSelector(editTrustedAppFetchError); const formValues = useTrustedAppsSelector(getCreationDialogFormEntry) || defaultNewTrustedApp(); const location = useTrustedAppsSelector(getCurrentLocation); + const isPlatinumPlus = useLicense().isPlatinumPlus(); + const docLinks = useKibana().services.docLinks; + const [isFormDirty, setIsFormDirty] = useState(false); const dataTestSubj = flyoutProps['data-test-subj']; @@ -124,10 +134,28 @@ export const CreateTrustedAppFlyout = memo( type: 'trustedAppCreationDialogFormStateUpdated', payload: { entry: newFormState.item, isValid: newFormState.isValid }, }); + if (_.isEqual(formValues, newFormState.item) === false) { + setIsFormDirty(true); + } }, - [dispatch] + + [dispatch, formValues] + ); + + const isTrustedAppsByPolicyEnabled = useIsExperimentalFeatureEnabled( + 'trustedAppsByPolicyEnabled' ); + const isGlobal = useMemo(() => { + return isGlobalEffectScope((formValues as NewTrustedApp).effectScope); + }, [formValues]); + + const showExpiredLicenseBanner = useMemo(() => { + return ( + isTrustedAppsByPolicyEnabled && !isPlatinumPlus && isEditMode && (!isGlobal || isFormDirty) + ); + }, [isTrustedAppsByPolicyEnabled, isPlatinumPlus, isEditMode, isGlobal, isFormDirty]); + // If there was a failure trying to retrieve the Trusted App for edit item, // then redirect back to the list ++ show toast message. useEffect(() => { @@ -181,7 +209,28 @@ export const CreateTrustedAppFlyout = memo( - + {showExpiredLicenseBanner && ( + + + + + + + )}

@@ -203,6 +252,8 @@ export const CreateTrustedAppFlyout = memo( fullWidth onChange={handleFormOnChange} isInvalid={!!creationErrors} + isEditMode={isEditMode} + isDirty={isFormDirty} error={creationErrorsMessage} policies={policies} trustedApp={formValues} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index 45eaa2c890a78..d3b4a541bd18d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -24,10 +24,23 @@ import { defaultNewTrustedApp } from '../../store/builders'; import { forceHTMLElementOffsetWidth } from './effected_policy_select/test_utils'; import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import { licenseService } from '../../../../../common/hooks/use_license'; jest.mock('../../../../../common/hooks/use_experimental_features'); const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../../../common/hooks/use_license', () => { + const licenseServiceInstance = { + isPlatinumPlus: jest.fn(), + }; + return { + licenseService: licenseServiceInstance, + useLicense: () => { + return licenseServiceInstance; + }, + }; +}); + describe('When using the Trusted App Form', () => { const dataTestSubjForForm = 'createForm'; const generator = new EndpointDocGenerator('effected-policy-select'); @@ -112,6 +125,7 @@ describe('When using the Trusted App Form', () => { beforeEach(() => { resetHTMLElementOffsetWidth = forceHTMLElementOffsetWidth(); useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); mockedContext = createAppRootMockRenderer(); @@ -120,6 +134,8 @@ describe('When using the Trusted App Form', () => { formProps = { 'data-test-subj': dataTestSubjForForm, trustedApp: latestUpdatedTrustedApp, + isEditMode: false, + isDirty: false, onChange: jest.fn((updates) => { latestUpdatedTrustedApp = updates.item; }), @@ -303,6 +319,58 @@ describe('When using the Trusted App Form', () => { }); }); + describe('the Policy Selection area when the license downgrades to gold or below', () => { + beforeEach(() => { + // select per policy for trusted app + const policy = generator.generatePolicyPackagePolicy(); + policy.name = 'test policy A'; + policy.id = '123'; + + formProps.policies.options = [policy]; + + (formProps.trustedApp as NewTrustedApp).effectScope = { + type: 'policy', + policies: ['123'], + }; + + formProps.isEditMode = true; + + // downgrade license + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + }); + + it('maintains policy configuration but does not allow the user to edit add/remove individual policies in edit mode', () => { + render(); + const perPolicyButton = renderResult.getByTestId( + `${dataTestSubjForForm}-effectedPolicies-perPolicy` + ) as HTMLButtonElement; + + expect(perPolicyButton.classList.contains('euiButtonGroupButton-isSelected')).toEqual(true); + expect(renderResult.getByTestId('policy-123').getAttribute('aria-disabled')).toEqual('true'); + expect(renderResult.getByTestId('policy-123-checkbox')).toBeChecked(); + }); + it("allows the user to set the trusted app entry to 'Global' in the edit option", () => { + render(); + const globalButtonInput = renderResult.getByTestId('globalPolicy') as HTMLButtonElement; + + reactTestingLibrary.act(() => { + fireEvent.click(globalButtonInput); + }); + + expect(formProps.onChange.mock.calls[0][0].item.effectScope.type).toBe('global'); + }); + it('hides the policy assignment section if the TA is set to global', () => { + (formProps.trustedApp as NewTrustedApp).effectScope = { + type: 'global', + }; + expect(renderResult.queryByTestId(`${dataTestSubjForForm}-effectedPolicies`)).toBeNull(); + }); + it('hides the policy assignment section if the user is adding a new TA', () => { + formProps.isEditMode = false; + expect(renderResult.queryByTestId(`${dataTestSubjForForm}-effectedPolicies`)).toBeNull(); + }); + }); + describe('and the user visits required fields but does not fill them out', () => { beforeEach(() => { render(); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 5db9a8557fa10..50485ccde00ad 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -48,6 +48,7 @@ import { EffectedPolicySelectProps, } from './effected_policy_select'; import { useTestIdGenerator } from '../../../../components/hooks/use_test_id_generator'; +import { useLicense } from '../../../../../common/hooks/use_license'; const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ OperatingSystem.MAC, @@ -189,6 +190,8 @@ export type CreateTrustedAppFormProps = Pick< > & { /** The trusted app values that will be passed to the form */ trustedApp: MaybeImmutable; + isEditMode: boolean; + isDirty: boolean; onChange: (state: TrustedAppFormState) => void; /** Setting passed on to the EffectedPolicySelect component */ policies: Pick; @@ -196,7 +199,15 @@ export type CreateTrustedAppFormProps = Pick< fullWidth?: boolean; }; export const CreateTrustedAppForm = memo( - ({ fullWidth, onChange, trustedApp: _trustedApp, policies = { options: [] }, ...formProps }) => { + ({ + fullWidth, + isEditMode, + isDirty, + onChange, + trustedApp: _trustedApp, + policies = { options: [] }, + ...formProps + }) => { const trustedApp = _trustedApp as NewTrustedApp; const dataTestSubj = formProps['data-test-subj']; @@ -205,6 +216,16 @@ export const CreateTrustedAppForm = memo( 'trustedAppsByPolicyEnabled' ); + const isPlatinumPlus = useLicense().isPlatinumPlus(); + + const isGlobal = useMemo(() => { + return isGlobalEffectScope(trustedApp.effectScope); + }, [trustedApp]); + + const hideAssignmentSection = useMemo(() => { + return !isPlatinumPlus && (!isEditMode || (isGlobal && !isDirty)); + }, [isEditMode, isGlobal, isDirty, isPlatinumPlus]); + const osOptions: Array> = useMemo( () => OPERATING_SYSTEMS.map((os) => ({ value: os, inputDisplay: OS_TITLES[os] })), [] @@ -213,7 +234,7 @@ export const CreateTrustedAppForm = memo( // We create local state for the list of policies because we want the selected policies to // persist while the user is on the form and possibly toggling between global/non-global const [selectedPolicies, setSelectedPolicies] = useState({ - isGlobal: isGlobalEffectScope(trustedApp.effectScope), + isGlobal, selected: [], }); @@ -406,7 +427,7 @@ export const CreateTrustedAppForm = memo( }, [notifyOfChange, trustedApp]); // Anytime the TrustedApp has an effective scope of `policies`, then ensure that - // those polices are selected in the UI while at teh same time preserving prior + // those polices are selected in the UI while at the same time preserving prior // selections (UX requirement) useEffect(() => { setSelectedPolicies((currentSelection) => { @@ -530,12 +551,13 @@ export const CreateTrustedAppForm = memo( data-test-subj={getTestId('conditionsBuilder')} /> - {isTrustedAppsByPolicyEnabled ? ( + {isTrustedAppsByPolicyEnabled && !hideAssignmentSection ? ( <> { componentProps = { options: [], isGlobal: true, + isPlatinumPlus: true, onChange: handleOnChange, 'data-test-subj': 'test', }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx index bb620ee5e7c01..e247602060384 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/effected_policy_select/effected_policy_select.tsx @@ -56,12 +56,14 @@ export type EffectedPolicySelectProps = Omit< > & { options: PolicyData[]; isGlobal: boolean; + isPlatinumPlus: boolean; onChange: (selection: EffectedPolicySelection) => void; selected?: PolicyData[]; }; export const EffectedPolicySelect = memo( ({ isGlobal, + isPlatinumPlus, onChange, listProps, options, @@ -107,7 +109,8 @@ export const EffectedPolicySelect = memo( id={htmlIdGenerator()()} onChange={NOOP} checked={isPolicySelected.has(policy.id)} - disabled={isGlobal} + disabled={isGlobal || !isPlatinumPlus} + data-test-subj={`policy-${policy.id}-checkbox`} /> ), append: ( @@ -124,11 +127,11 @@ export const EffectedPolicySelect = memo( ), policy, checked: isPolicySelected.has(policy.id) ? 'on' : undefined, - disabled: isGlobal, + disabled: isGlobal || !isPlatinumPlus, 'data-test-subj': `policy-${policy.id}`, })) .sort(({ label: labelA }, { label: labelB }) => labelA.localeCompare(labelB)); - }, [getAppUrl, isGlobal, options, selected]); + }, [getAppUrl, isGlobal, isPlatinumPlus, options, selected]); const handleOnPolicySelectChange = useCallback< Required>['onChange'] @@ -178,7 +181,7 @@ export const EffectedPolicySelect = memo(

{i18n.translate('xpack.securitySolution.trustedApps.assignmentSectionDescription', { defaultMessage: - 'You can assign this trusted application globally across all policies or assign it to specific policies.', + 'Assign this trusted application globally across all policies, or assign it to specific policies.', })}

diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx index 9e76cfd001c97..87c7439c236cc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx @@ -24,13 +24,7 @@ import { EuiText, } from '@elastic/eui'; -import { - Immutable, - ImmutableObject, - PolicyEffectScope, - GlobalEffectScope, - TrustedApp, -} from '../../../../../common/endpoint/types'; +import { Immutable, TrustedApp } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { useTrustedAppsSelector } from './hooks'; import { @@ -38,14 +32,10 @@ import { isDeletionDialogOpen, isDeletionInProgress, } from '../store/selectors'; +import { isPolicyEffectScope } from '../state/type_guards'; const CANCEL_SUBJ = 'trustedAppDeletionCancel'; const CONFIRM_SUBJ = 'trustedAppDeletionConfirm'; -const isTrustedAppByPolicy = ( - trustedApp: ImmutableObject -): trustedApp is ImmutableObject => { - return (trustedApp as ImmutableObject).policies !== undefined; -}; const getTranslations = (entry: Immutable | undefined) => ({ title: ( @@ -67,7 +57,7 @@ const getTranslations = (entry: Immutable | undefined) => ({ defaultMessage="Deleting this entry will remove it from {count} associated {count, plural, one {policy} other {policies}}." values={{ count: - entry && isTrustedAppByPolicy(entry.effectScope) + entry && isPolicyEffectScope(entry.effectScope) ? entry.effectScope.policies.length : 'all', }} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index faeef26032cd5..7ced93abc7fe1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -34,6 +34,7 @@ import { forceHTMLElementOffsetWidth } from './components/effected_policy_select import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; +import { licenseService } from '../../../../common/hooks/use_license'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -43,6 +44,20 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ jest.mock('../../../../common/hooks/use_experimental_features'); const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../../common/hooks/use_license', () => { + const licenseServiceInstance = { + isPlatinumPlus: jest.fn(), + }; + return { + licenseService: licenseServiceInstance, + useLicense: () => { + return licenseServiceInstance; + }, + }; +}); + +jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); + describe('When on the Trusted Apps Page', () => { const expectedAboutInfo = 'Add a trusted application to improve performance or alleviate conflicts with other ' + @@ -58,26 +73,7 @@ describe('When on the Trusted Apps Page', () => { const originalScrollTo = window.scrollTo; const act = reactTestingLibrary.act; - const getFakeTrustedApp = (): TrustedApp => ({ - id: '1111-2222-3333-4444', - version: 'abc123', - name: 'one app', - os: OperatingSystem.WINDOWS, - created_at: '2021-01-04T13:55:00.561Z', - created_by: 'me', - updated_at: '2021-01-04T13:55:00.561Z', - updated_by: 'me', - description: 'a good one', - effectScope: { type: 'global' }, - entries: [ - { - field: ConditionEntryField.PATH, - value: 'one/two', - operator: 'included', - type: 'match', - }, - ], - }); + const getFakeTrustedApp = jest.fn(); const createListApiResponse = ( page: number = 1, @@ -137,9 +133,32 @@ describe('When on the Trusted Apps Page', () => { beforeEach(() => { mockedContext = createAppRootMockRenderer(); + getFakeTrustedApp.mockImplementation( + (): TrustedApp => ({ + id: '1111-2222-3333-4444', + version: 'abc123', + name: 'one app', + os: OperatingSystem.WINDOWS, + created_at: '2021-01-04T13:55:00.561Z', + created_by: 'me', + updated_at: '2021-01-04T13:55:00.561Z', + updated_by: 'me', + description: 'a good one', + effectScope: { type: 'global' }, + entries: [ + { + field: ConditionEntryField.PATH, + value: 'one/two', + operator: 'included', + type: 'match', + }, + ], + }) + ); history = mockedContext.history; coreStart = mockedContext.coreStart; + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); waitForAction = mockedContext.middlewareSpy.waitForAction; render = () => mockedContext.render(); reactTestingLibrary.act(() => { @@ -178,19 +197,94 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the Grid view is being displayed', () => { - describe('and the edit trusted app button is clicked', () => { - let renderResult: ReturnType; + let renderResult: ReturnType; + + const renderWithListDataAndClickOnEditCard = async () => { + renderResult = await renderWithListData(); + + await act(async () => { + (await renderResult.findAllByTestId('trustedAppCard-header-actions-button'))[0].click(); + }); + + act(() => { + fireEvent.click(renderResult.getByTestId('editTrustedAppAction')); + }); + }; + + const renderWithListDataAndClickAddButton = async (): Promise< + ReturnType + > => { + renderResult = await renderWithListData(); + + act(() => { + const addButton = renderResult.getByTestId('trustedAppsListAddButton'); + fireEvent.click(addButton, { button: 1 }); + }); + + // Wait for the policies to be loaded + await act(async () => { + await waitForAction('trustedAppsPoliciesStateChanged', { + validate: (action) => { + return isLoadedResourceState(action.payload); + }, + }); + }); + + return renderResult; + }; + describe('the license is downgraded to gold or below and the user is editing a per policy TA', () => { beforeEach(async () => { - renderResult = await renderWithListData(); + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); - await act(async () => { - (await renderResult.findAllByTestId('trustedAppCard-header-actions-button'))[0].click(); + const originalFakeTrustedAppProvider = getFakeTrustedApp.getMockImplementation(); + getFakeTrustedApp.mockImplementation(() => { + return { + ...originalFakeTrustedAppProvider!(), + effectScope: { + type: 'policy', + policies: ['abc123'], + }, + }; }); + await renderWithListDataAndClickOnEditCard(); + }); + + it('shows a message at the top of the flyout to inform the user their license is expired', () => { + expect( + renderResult.queryByTestId('addTrustedAppFlyout-expired-license-callout') + ).toBeTruthy(); + }); + }); + + describe('the license is downgraded to gold or below and the user is adding a new TA', () => { + beforeEach(async () => { + (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); - act(() => { - fireEvent.click(renderResult.getByTestId('editTrustedAppAction')); + const originalFakeTrustedAppProvider = getFakeTrustedApp.getMockImplementation(); + getFakeTrustedApp.mockImplementation(() => { + return { + ...originalFakeTrustedAppProvider!(), + effectScope: { + type: 'policy', + policies: ['abc123'], + }, + }; }); + await renderWithListDataAndClickAddButton(); + }); + it('does not show the expired license message at the top of the flyout', async () => { + expect( + renderResult.queryByTestId('addTrustedAppFlyout-expired-license-callout') + ).toBeNull(); + }); + }); + + describe('and the edit trusted app button is clicked', () => { + beforeEach(async () => { + await renderWithListDataAndClickOnEditCard(); }); it('should persist edit params to url', () => { @@ -281,7 +375,7 @@ describe('When on the Trusted Apps Page', () => { } ); - const renderResult = await renderWithListData(); + renderResult = await renderWithListData(); await reactTestingLibrary.act(async () => { await apiResponseForEditTrustedApp; @@ -314,7 +408,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should retrieve trusted app via API using url `id`', async () => { - const renderResult = await renderAndWaitForGetApi(); + renderResult = await renderAndWaitForGetApi(); expect(coreStart.http.get).toHaveBeenCalledWith(TRUSTED_APP_GET_URI); From 16c049a2d9e834623fc207a27c3e5c8ccbe48197 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Tue, 12 Oct 2021 11:53:40 -0700 Subject: [PATCH 49/68] [Canvas] Toolbar UI Updates (#113329) --- .../application/top_nav/dashboard_top_nav.tsx | 2 - .../application/top_nav/editor_menu.tsx | 22 ++- .../solution_toolbar/items/popover.tsx | 21 ++- .../solution_toolbar/items/quick_group.scss | 13 -- .../solution_toolbar/items/quick_group.tsx | 10 +- .../solution_toolbar.stories.tsx | 96 +++++----- .../components/workpad_app/workpad_app.scss | 2 +- .../element_menu.stories.storyshot | 4 +- .../__stories__/element_menu.stories.tsx | 8 +- .../element_menu/element_menu.component.tsx | 64 ++----- .../element_menu/element_menu.scss | 3 - .../element_menu/element_menu.tsx | 42 +---- .../workpad_header.component.tsx | 178 ++++++++++++------ .../workpad_header/workpad_header.tsx | 49 ++++- x-pack/plugins/canvas/public/style/index.scss | 1 - x-pack/plugins/canvas/tsconfig.json | 1 + 16 files changed, 266 insertions(+), 250 deletions(-) delete mode 100644 x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.scss diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index e6a2c41fd4ecb..712c070e17b9f 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -543,7 +543,6 @@ export function DashboardTopNav({ createType: title, onClick: createNewVisType(visType as VisTypeAlias), 'data-test-subj': `dashboardQuickButton${name}`, - isDarkModeEnabled: IS_DARK_THEME, }; } else { const { name, icon, title, titleInWizard } = visType as BaseVisType; @@ -553,7 +552,6 @@ export function DashboardTopNav({ createType: titleInWizard || title, onClick: createNewVisType(visType as BaseVisType), 'data-test-subj': `dashboardQuickButton${name}`, - isDarkModeEnabled: IS_DARK_THEME, }; } } diff --git a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx index 46ae4d9456d92..8a46a16c1bf0c 100644 --- a/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/application/top_nav/editor_menu.tsx @@ -238,16 +238,18 @@ export const EditorMenu = ({ dashboardContainer, createNewVisType }: Props) => { panelPaddingSize="none" data-test-subj="dashboardEditorMenuButton" > - + {() => ( + + )} ); }; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx index 33850005b498b..fea6bf41a1601 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx @@ -18,13 +18,17 @@ type AllowedPopoverProps = Omit< 'button' | 'isOpen' | 'closePopover' | 'anchorPosition' >; -export type Props = AllowedButtonProps & AllowedPopoverProps; +export type Props = AllowedButtonProps & + AllowedPopoverProps & { + children: (arg: { closePopover: () => void }) => React.ReactNode; + }; export const SolutionToolbarPopover = ({ label, iconType, primary, iconSide, + children, ...popover }: Props) => { const [isOpen, setIsOpen] = useState(false); @@ -33,10 +37,21 @@ export const SolutionToolbarPopover = ({ const closePopover = () => setIsOpen(false); const button = ( - + ); return ( - + + {children({ closePopover })} + ); }; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss index 876ee659b71d7..535570a51d777 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -8,17 +8,4 @@ border-color: $euiBorderColor !important; } } - - // Temporary fix for two tone icons to make them monochrome - .quickButtonGroup__button--dark { - .euiIcon path { - fill: $euiColorGhost; - } - } - // Temporary fix for two tone icons to make them monochrome - .quickButtonGroup__button--light { - .euiIcon path { - fill: $euiColorInk; - } - } } diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx index eb0a395548cd9..66b22eeb570db 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx @@ -17,27 +17,23 @@ import './quick_group.scss'; export interface QuickButtonProps extends Pick { createType: string; onClick: () => void; - isDarkModeEnabled?: boolean; } export interface Props { buttons: QuickButtonProps[]; } -type Option = EuiButtonGroupOptionProps & - Omit; +type Option = EuiButtonGroupOptionProps & Omit; export const QuickButtonGroup = ({ buttons }: Props) => { const buttonGroupOptions: Option[] = buttons.map((button: QuickButtonProps, index) => { - const { createType: label, isDarkModeEnabled, ...rest } = button; + const { createType: label, ...rest } = button; const title = strings.getAriaButtonLabel(label); return { ...rest, 'aria-label': title, - className: `quickButtonGroup__button ${ - isDarkModeEnabled ? 'quickButtonGroup__button--dark' : 'quickButtonGroup__button--light' - }`, + className: `quickButtonGroup__button`, id: `${htmlIdGenerator()()}${index}`, label, title, diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx index fa33f53f9ae4f..3a04a4c974538 100644 --- a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx @@ -54,29 +54,31 @@ const primaryButtonConfigs = { panelPaddingSize="none" primary={true} > - + {() => ( + + )} ), Dashboard: ( @@ -93,29 +95,31 @@ const extraButtonConfigs = { Canvas: undefined, Dashboard: [ - + {() => ( + + )} , ], }; diff --git a/x-pack/plugins/canvas/public/components/workpad_app/workpad_app.scss b/x-pack/plugins/canvas/public/components/workpad_app/workpad_app.scss index 3f6d6887e0c80..4acdca10d61cc 100644 --- a/x-pack/plugins/canvas/public/components/workpad_app/workpad_app.scss +++ b/x-pack/plugins/canvas/public/components/workpad_app/workpad_app.scss @@ -31,7 +31,7 @@ $canvasLayoutFontSize: $euiFontSizeS; .canvasLayout__stageHeader { flex-grow: 0; flex-basis: auto; - padding: 1px $euiSize 0; + padding: $euiSizeS; font-size: $canvasLayoutFontSize; border-bottom: $euiBorderThin; background: $euiColorLightestShade; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/__snapshots__/element_menu.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/__snapshots__/element_menu.stories.storyshot index 371a5133fe88e..1e71803d22c21 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/__snapshots__/element_menu.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__stories__/__snapshots__/element_menu.stories.storyshot @@ -3,13 +3,13 @@ exports[`Storyshots components/WorkpadHeader/ElementMenu default 1`] = `
- - -
- -
- - -
- - -
-
- Request failed, Error: simulated bulkRequest error + + Load Kibana objects + -
-
-
-
+ + + +

- +
- + + +
+ + +
+
+ + Request failed, Error: simulated bulkRequest error + +
+
+
`; @@ -446,260 +351,161 @@ exports[`bulkCreate should display success message when bulkCreate is successful ] } > - - - - -

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
- - - Load Kibana objects - - -
- - - , - "key": "installStep", - "status": "complete", - "title": "Load Kibana objects", - }, - ] - } + +

+ Load Kibana objects +

+
+
- +
-
- - - - - - - - +
-

- Load Kibana objects +

+ Imports index pattern, visualizations and pre-defined dashboards.

- -
-
+ +
+ + +
+ - -
- -
- -
-

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
-
-
- -
- - - - - -
-
-
-
- -
- - -
-
- 1 saved objects successfully added + + Load Kibana objects + -
-
-
-
+ + + +
- +
+
+ + +
+ + +
+
+ + 1 saved objects successfully added + +
- +
`; exports[`renders 1`] = ` - - - - -

- Imports index pattern, visualizations and pre-defined dashboards. -

-
-
- - - Load Kibana objects - - -
- - , - "key": "installStep", - "status": "incomplete", - "title": "Load Kibana objects", - }, - ] - } -/> + + +

+ Load Kibana objects +

+
+ + + +

+ Imports index pattern, visualizations and pre-defined dashboards. +

+
+
+ + + Load Kibana objects + + +
+ +
`; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap index ac697fae17f69..91dcdabd75dee 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/tutorial.test.js.snap @@ -1,173 +1,146 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`isCloudEnabled is false should not render instruction toggle when ON_PREM_ELASTIC_CLOUD instructions are not provided 1`] = ` - - -
- - -
- - - + + + - -
- - + ], + }, + ] + } + isCloudEnabled={false} + offset={1} + onStatusCheck={[Function]} + paramValues={Object {}} + replaceTemplateStrings={[Function]} + setParameter={[Function]} + statusCheckState="NOT_CHECKED" + title="Instruction title" + /> +
+ `; exports[`isCloudEnabled is false should render ON_PREM instructions with instruction toggle 1`] = ` - - -
- + + + + - -
- - - - - -
- - - + + - -
-
-
+ ], + }, + ] + } + isCloudEnabled={false} + offset={1} + onStatusCheck={[Function]} + paramValues={Object {}} + replaceTemplateStrings={[Function]} + setParameter={[Function]} + statusCheckState="NOT_CHECKED" + title="Instruction title" + /> +
+ `; exports[`should render ELASTIC_CLOUD instructions when isCloudEnabled is true 1`] = ` - - -
- - -
- - - + + + - -
- - + ], + }, + ] + } + isCloudEnabled={true} + offset={1} + onStatusCheck={[Function]} + paramValues={Object {}} + replaceTemplateStrings={[Function]} + setParameter={[Function]} + statusCheckState="NOT_CHECKED" + title="Instruction title" + /> +
+ `; diff --git a/src/plugins/home/public/application/components/tutorial/_tutorial.scss b/src/plugins/home/public/application/components/tutorial/_tutorial.scss index b517476885e2e..6d6ca4781346d 100644 --- a/src/plugins/home/public/application/components/tutorial/_tutorial.scss +++ b/src/plugins/home/public/application/components/tutorial/_tutorial.scss @@ -1,7 +1,3 @@ -.homTutorial__notFoundPanel { - background: $euiColorEmptyShade; - padding: $euiSizeL; -} .homTutorial__instruction { flex-shrink: 0; diff --git a/src/plugins/home/public/application/components/tutorial/content.js b/src/plugins/home/public/application/components/tutorial/content.js index 8b0e09d2eb851..d076957521eee 100644 --- a/src/plugins/home/public/application/components/tutorial/content.js +++ b/src/plugins/home/public/application/components/tutorial/content.js @@ -8,19 +8,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Markdown } from '../../../../../kibana_react/public'; - -const whiteListedRules = ['backticks', 'emphasis', 'link', 'list']; +import { EuiMarkdownFormat } from '@elastic/eui'; export function Content({ text }) { - return ( - - ); + return {text}; } Content.propTypes = { diff --git a/src/plugins/home/public/application/components/tutorial/content.test.js b/src/plugins/home/public/application/components/tutorial/content.test.js index e0b0a256f207c..f8a75d0a04f1c 100644 --- a/src/plugins/home/public/application/components/tutorial/content.test.js +++ b/src/plugins/home/public/application/components/tutorial/content.test.js @@ -11,12 +11,6 @@ import { shallow } from 'enzyme'; import { Content } from './content'; -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); - test('should render content with markdown', () => { const component = shallow( - + + + +

+ +

+
+
- - - -

- -

-
-
- - - - {label} - - -
-
+ + + {label} + + + ); } diff --git a/src/plugins/home/public/application/components/tutorial/instruction.js b/src/plugins/home/public/application/components/tutorial/instruction.js index e4b3b3f321bf9..ebe78b78f300d 100644 --- a/src/plugins/home/public/application/components/tutorial/instruction.js +++ b/src/plugins/home/public/application/components/tutorial/instruction.js @@ -10,18 +10,7 @@ import React, { Suspense, useMemo } from 'react'; import PropTypes from 'prop-types'; import { Content } from './content'; -import { - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiCopy, - EuiButton, - EuiLoadingSpinner, - EuiErrorBoundary, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCodeBlock, EuiSpacer, EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; import { getServices } from '../../kibana_services'; @@ -39,16 +28,21 @@ export function Instruction({ let pre; if (textPre) { - pre = ; + pre = ( + <> + + + + ); } let post; if (textPost) { post = ( -
+ <> -
+ ); } const customComponent = tutorialService.getCustomComponent(customComponentName); @@ -59,7 +53,6 @@ export function Instruction({ } }, [customComponent]); - let copyButton; let commandBlock; if (commands) { const cmdText = commands @@ -67,35 +60,16 @@ export function Instruction({ return replaceTemplateStrings(cmd, paramValues); }) .join('\n'); - copyButton = ( - - {(copy) => ( - - - - )} - - ); commandBlock = ( -
- - {cmdText} -
+ + {cmdText} + ); } return (
- - {pre} - - - {copyButton} - - + {pre} {commandBlock} @@ -114,8 +88,6 @@ export function Instruction({ )} {post} - -
); } diff --git a/src/plugins/home/public/application/components/tutorial/instruction_set.js b/src/plugins/home/public/application/components/tutorial/instruction_set.js index 08b55a527b3cf..822c60cdc31ba 100644 --- a/src/plugins/home/public/application/components/tutorial/instruction_set.js +++ b/src/plugins/home/public/application/components/tutorial/instruction_set.js @@ -21,12 +21,13 @@ import { EuiFlexItem, EuiButton, EuiCallOut, - EuiButtonEmpty, EuiTitle, + EuiSplitPanel, } from '@elastic/eui'; import * as StatusCheckStates from './status_check_states'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme'; class InstructionSetUi extends React.Component { constructor(props) { @@ -97,7 +98,12 @@ class InstructionSetUi extends React.Component { color = 'warning'; break; } - return ; + return ( + <> + + + + ); } getStepStatus(statusCheckState) { @@ -131,27 +137,20 @@ class InstructionSetUi extends React.Component { const { statusCheckState, statusCheckConfig, onStatusCheck } = this.props; const checkStatusStep = ( - - - - - - - - {statusCheckConfig.btnLabel || ( - - )} - - - + + + {statusCheckConfig.btnLabel || ( + + )} + {this.renderStatusCheckMessage()} @@ -202,27 +201,29 @@ class InstructionSetUi extends React.Component { steps.push(this.renderStatusCheck()); } - return ; + return ( + <> + + + + ); }; renderHeader = () => { let paramsVisibilityToggle; if (this.props.params) { - const ariaLabel = this.props.intl.formatMessage({ - id: 'home.tutorial.instructionSet.toggleAriaLabel', - defaultMessage: 'toggle command parameters visibility', - }); paramsVisibilityToggle = ( - - + ); } @@ -245,11 +246,14 @@ class InstructionSetUi extends React.Component { } return ( - + <> + + + ); }; @@ -257,28 +261,29 @@ class InstructionSetUi extends React.Component { let paramsForm; if (this.props.params && this.state.isParamFormVisible) { paramsForm = ( - + <> + + + ); } return ( -
- {this.renderHeader()} - - {this.renderCallOut()} - - {paramsForm} - - {this.renderTabs()} - - - - {this.renderInstructions()} -
+ + + {this.renderTabs()} + + + {this.renderHeader()} + {paramsForm} + {this.renderCallOut()} + {this.renderInstructions()} + + ); } } diff --git a/src/plugins/home/public/application/components/tutorial/instruction_set.test.js b/src/plugins/home/public/application/components/tutorial/instruction_set.test.js index 1bce4f72fde60..6faadf275bea3 100644 --- a/src/plugins/home/public/application/components/tutorial/instruction_set.test.js +++ b/src/plugins/home/public/application/components/tutorial/instruction_set.test.js @@ -34,12 +34,6 @@ const instructionVariants = [ }, ]; -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); - test('render', () => { const component = shallowWithIntl( - ); + />, + ]; } let exportedFields; if (exportedFieldsUrl) { exportedFields = ( -
- - + <> +
+ -
-
- ); - } - let icon; - if (iconType) { - icon = ( - - - + + ); } let betaBadge; @@ -81,31 +64,28 @@ function IntroductionUI({ ); } return ( - - - - {icon} - - -

- {title} - {betaBadge && ( - <> -   - {betaBadge} - - )} -

-
-
-
- - - {exportedFields} -
- - {img} -
+ + {title} + {betaBadge && ( + <> +   + {betaBadge} + + )} + + } + description={ + <> + + {exportedFields} + {notices} + + } + rightSideItems={rightSideItems} + /> ); } @@ -116,6 +96,7 @@ IntroductionUI.propTypes = { exportedFieldsUrl: PropTypes.string, iconType: PropTypes.string, isBeta: PropTypes.bool, + notices: PropTypes.node, }; IntroductionUI.defaultProps = { diff --git a/src/plugins/home/public/application/components/tutorial/introduction.test.js b/src/plugins/home/public/application/components/tutorial/introduction.test.js index a0ab9d8c8e6a7..949f84d0181ed 100644 --- a/src/plugins/home/public/application/components/tutorial/introduction.test.js +++ b/src/plugins/home/public/application/components/tutorial/introduction.test.js @@ -11,12 +11,6 @@ import { shallowWithIntl } from '@kbn/test/jest'; import { Introduction } from './introduction'; -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); - test('render', () => { const component = shallowWithIntl( { + render() { const installMsg = this.props.installMsg ? this.props.installMsg : this.props.intl.formatMessage({ id: 'home.tutorial.savedObject.installLabel', defaultMessage: 'Imports index pattern, visualizations and pre-defined dashboards.', }); - const installStep = ( - + + return ( + <> + +

+ {this.props.intl.formatMessage({ + id: 'home.tutorial.savedObject.loadTitle', + defaultMessage: 'Load Kibana objects', + })} +

+
@@ -190,22 +199,8 @@ Click 'Confirm overwrite' to import and overwrite existing objects. Any changes {this.renderInstallMessage()} -
+ ); - - return { - title: this.props.intl.formatMessage({ - id: 'home.tutorial.savedObject.loadTitle', - defaultMessage: 'Load Kibana objects', - }), - status: this.state.isInstalled ? 'complete' : 'incomplete', - children: installStep, - key: 'installStep', - }; - }; - - render() { - return ; } } diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.js b/src/plugins/home/public/application/components/tutorial/tutorial.js index 52daa53d4585c..508a236bf45d4 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.js @@ -7,26 +7,18 @@ */ import _ from 'lodash'; -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { Footer } from './footer'; import { Introduction } from './introduction'; import { InstructionSet } from './instruction_set'; import { SavedObjectsInstaller } from './saved_objects_installer'; -import { - EuiSpacer, - EuiPage, - EuiPanel, - EuiText, - EuiPageBody, - EuiButtonGroup, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiSpacer, EuiPanel, EuiButton, EuiButtonGroup, EuiFormRow } from '@elastic/eui'; import * as StatusCheckStates from './status_check_states'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getServices } from '../../kibana_services'; +import { KibanaPageTemplate } from '../../../../../kibana_react/public'; const INSTRUCTIONS_TYPE = { ELASTIC_CLOUD: 'elasticCloud', @@ -250,19 +242,22 @@ class TutorialUi extends React.Component { }, ]; return ( - - + <> + + - - + + ); } }; @@ -286,23 +281,25 @@ class TutorialUi extends React.Component { offset += instructionSet.instructionVariants[0].instructions.length; return ( - { - this.onStatusCheck(index); - }} - offset={currentOffset} - params={instructions.params} - paramValues={this.state.paramValues} - setParameter={this.setParameter} - replaceTemplateStrings={this.props.replaceTemplateStrings} - key={index} - isCloudEnabled={this.props.isCloudEnabled} - /> + + { + this.onStatusCheck(index); + }} + offset={currentOffset} + params={instructions.params} + paramValues={this.state.paramValues} + setParameter={this.setParameter} + replaceTemplateStrings={this.props.replaceTemplateStrings} + isCloudEnabled={this.props.isCloudEnabled} + /> + {index < instructions.instructionSets.length - 1 && } + ); }); }; @@ -313,11 +310,16 @@ class TutorialUi extends React.Component { } return ( - + <> + + + + + ); }; @@ -338,22 +340,23 @@ class TutorialUi extends React.Component { } if (url && label) { - return
; + return ( + <> + + +
+ + + ); } }; renderModuleNotices() { const notices = getServices().tutorialService.getModuleNotices(); if (notices.length && this.state.tutorial.moduleName) { - return ( - - {notices.map((ModuleNotice, index) => ( - - - - ))} - - ); + return notices.map((ModuleNotice, index) => ( + + )); } else { return null; } @@ -363,17 +366,34 @@ class TutorialUi extends React.Component { let content; if (this.state.notFound) { content = ( -
- -

+ -

-
-
+ ), + rightSideItems: [ + + {i18n.translate('home.tutorial.backToDirectory', { + defaultMessage: 'Back to directory', + })} + , + ], + }} + /> ); } @@ -405,27 +425,20 @@ class TutorialUi extends React.Component { exportedFieldsUrl={exportedFieldsUrl} iconType={icon} isBeta={this.state.tutorial.isBeta} + notices={this.renderModuleNotices()} /> - {this.renderModuleNotices()} - -
{this.renderInstructionSetsToggle()}
+ {this.renderInstructionSetsToggle()} - - {this.renderInstructionSets(instructions)} - {this.renderSavedObjectsInstaller()} - {this.renderFooter()} - + {this.renderInstructionSets(instructions)} + {this.renderSavedObjectsInstaller()} + {this.renderFooter()}
); } - return ( - - {content} - - ); + return {content}; } } diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.test.js b/src/plugins/home/public/application/components/tutorial/tutorial.test.js index c76b20e63ae95..c68f5ec69e161 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.test.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.test.js @@ -33,11 +33,6 @@ jest.mock('../../kibana_services', () => ({ }, }), })); -jest.mock('../../../../../kibana_react/public', () => { - return { - Markdown: () =>
, - }; -}); function buildInstructionSet(type) { return { diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx index de9c7f651019e..8f66658785b97 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx @@ -24,7 +24,6 @@ import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { AgentIcon } from '../../shared/agent_icon'; import { NewPackagePolicy } from '../apm_policy_form/typings'; import { getCommands } from '../../../tutorial/config_agent/commands/get_commands'; -import { CopyCommands } from '../../../tutorial/config_agent/copy_commands'; import { replaceTemplateStrings } from './replace_template_strings'; function AccordionButtonContent({ @@ -91,14 +90,9 @@ function TutorialConfigAgent({ policyDetails: { apmServerUrl, secretToken }, }); return ( - - - - - - {commandBlock} - - + + {commandBlock} + ); } @@ -153,23 +147,16 @@ export function AgentInstructionsAccordion({ {textPre && ( - - - - - {commandBlock && ( - - - - )} - + )} {commandBlock && ( <> - {commandBlock} + + {commandBlock} + )} {customComponentName === 'TutorialConfigAgent' && ( diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/copy_commands.tsx b/x-pack/plugins/apm/public/tutorial/config_agent/copy_commands.tsx deleted file mode 100644 index c5261cfc1dc04..0000000000000 --- a/x-pack/plugins/apm/public/tutorial/config_agent/copy_commands.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { EuiButton, EuiCopy } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -interface Props { - commands: string; -} -export function CopyCommands({ commands }: Props) { - return ( - - {(copy) => ( - - {i18n.translate('xpack.apm.tutorial.copySnippet', { - defaultMessage: 'Copy snippet', - })} - - )} - - ); -} diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/index.tsx b/x-pack/plugins/apm/public/tutorial/config_agent/index.tsx index 5ff1fd7f42119..bce16ae6ef1f9 100644 --- a/x-pack/plugins/apm/public/tutorial/config_agent/index.tsx +++ b/x-pack/plugins/apm/public/tutorial/config_agent/index.tsx @@ -4,20 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiSpacer, -} from '@elastic/eui'; +import { EuiCodeBlock, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { HttpStart } from 'kibana/public'; import React, { useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { APIReturnType } from '../..//services/rest/createCallApmApi'; import { getCommands } from './commands/get_commands'; -import { CopyCommands } from './copy_commands'; import { getPolicyOptions, PolicyOption } from './get_policy_options'; import { PolicySelector } from './policy_selector'; @@ -136,27 +129,19 @@ function TutorialConfigAgent({ return ( <> - - - - setSelectedOption(newSelectedOption) - } - fleetLink={getFleetLink({ - isFleetEnabled: data.isFleetEnabled, - hasFleetAgents, - basePath, - })} - /> - - - - - + setSelectedOption(newSelectedOption)} + fleetLink={getFleetLink({ + isFleetEnabled: data.isFleetEnabled, + hasFleetAgents, + basePath, + })} + /> + - + {commands} diff --git a/x-pack/plugins/apm/server/tutorial/envs/on_prem.ts b/x-pack/plugins/apm/server/tutorial/envs/on_prem.ts index fb9fbae33ac82..acc0ce69e0e4e 100644 --- a/x-pack/plugins/apm/server/tutorial/envs/on_prem.ts +++ b/x-pack/plugins/apm/server/tutorial/envs/on_prem.ts @@ -76,7 +76,12 @@ export function onPremInstructions({ { id: INSTRUCTION_VARIANT.FLEET, instructions: [ - { customComponentName: 'TutorialFleetInstructions' }, + { + title: i18n.translate('xpack.apm.tutorial.fleet.title', { + defaultMessage: 'Fleet', + }), + customComponentName: 'TutorialFleetInstructions', + }, ], }, ] diff --git a/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx b/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx index 6b9b441551a56..24d9dc8e2c100 100644 --- a/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx +++ b/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx @@ -7,7 +7,8 @@ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiText, EuiLink, EuiSpacer, EuiIcon } from '@elastic/eui'; import type { TutorialModuleNoticeComponent } from 'src/plugins/home/public'; import { useGetPackages, useLink, useCapabilities } from '../../hooks'; @@ -31,16 +32,20 @@ const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName }

- - + ), availableAsIntegrationLink: ( Date: Tue, 12 Oct 2021 13:23:00 -0600 Subject: [PATCH 51/68] [Maps] fix flaky getTile test (#114689) --- x-pack/test/api_integration/apis/maps/get_tile.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index a1d4f10ca7be8..9705064464843 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -66,14 +66,21 @@ export default function ({ getService }) { expect(metadataFeature.type).to.be(3); expect(metadataFeature.extent).to.be(4096); expect(metadataFeature.id).to.be(undefined); + const fieldMeta = JSON.parse(metadataFeature.properties.fieldMeta); + delete metadataFeature.properties.fieldMeta; expect(metadataFeature.properties).to.eql({ __kbn_feature_count__: 2, __kbn_is_tile_complete__: true, __kbn_metadata_feature__: true, __kbn_vector_shape_type_counts__: '{"POINT":2,"LINE":0,"POLYGON":0}', - fieldMeta: - '{"machine.os.raw":{"categories":{"categories":[{"key":"ios","count":1},{"count":1}]}},"bytes":{"range":{"min":9252,"max":9583,"delta":331},"categories":{"categories":[{"key":9252,"count":1},{"key":9583,"count":1}]}}}', }); + expect(fieldMeta.bytes.range).to.eql({ + min: 9252, + max: 9583, + delta: 331, + }); + expect(fieldMeta.bytes.categories.categories.length).to.be(2); + expect(fieldMeta['machine.os.raw'].categories.categories.length).to.be(2); expect(metadataFeature.loadGeometry()).to.eql([ [ { x: 0, y: 4096 }, From 0de7012bf2ba44271d57cb0169403158ea29c08b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 Oct 2021 14:39:43 -0500 Subject: [PATCH 52/68] Update polyfills (master) (#114564) Co-authored-by: Renovate Bot Co-authored-by: spalger --- package.json | 6 +++--- yarn.lock | 53 +++++++++++----------------------------------------- 2 files changed, 14 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index 16422e3fda27e..ce8e928688805 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,7 @@ "@types/redux-logger": "^3.0.8", "JSONStream": "1.3.5", "abort-controller": "^3.0.0", - "abortcontroller-polyfill": "^1.4.0", + "abortcontroller-polyfill": "^1.7.3", "angular": "^1.8.0", "angular-aria": "^1.8.0", "angular-recursion": "^1.0.5", @@ -209,7 +209,7 @@ "constate": "^1.3.2", "content-disposition": "0.5.3", "copy-to-clipboard": "^3.0.8", - "core-js": "^3.6.5", + "core-js": "^3.18.2", "cronstrue": "^1.51.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", @@ -369,7 +369,7 @@ "remark-stringify": "^9.0.0", "require-in-the-middle": "^5.1.0", "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.0", + "resize-observer-polyfill": "^1.5.1", "rison-node": "1.0.2", "rxjs": "^6.5.5", "safe-squel": "^5.12.5", diff --git a/yarn.lock b/yarn.lock index 9d141be857475..31b69268e6e7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7605,10 +7605,10 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" -abortcontroller-polyfill@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.4.0.tgz#0d5eb58e522a461774af8086414f68e1dda7a6c4" - integrity sha512-3ZFfCRfDzx3GFjO6RAkYx81lPGpUS20ISxux9gLxuKnqafNcFQo59+IoZqpO2WvQlyc287B62HDnDdNYRmlvWA== +abortcontroller-polyfill@^1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz#1b5b487bd6436b5b764fd52a612509702c3144b5" + integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q== accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" @@ -9774,18 +9774,7 @@ browserslist@4.14.2: escalade "^3.0.2" node-releases "^1.1.61" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.1, browserslist@^4.6.0, browserslist@^4.8.5: - version "4.16.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717" - integrity sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw== - dependencies: - caniuse-lite "^1.0.30001181" - colorette "^1.2.1" - electron-to-chromium "^1.3.649" - escalade "^3.1.1" - node-releases "^1.1.70" - -browserslist@^4.16.6, browserslist@^4.17.1: +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.1, browserslist@^4.16.6, browserslist@^4.17.1, browserslist@^4.6.0, browserslist@^4.8.5: version "4.17.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.17.1.tgz#a98d104f54af441290b7d592626dd541fa642eb9" integrity sha512-aLD0ZMDSnF4lUt4ZDNgqi5BUn9BZ7YdQdI/cYlILrhdSSZJLU9aNZoD5/NBmM4SK34APB2e83MOsRt1EnkuyaQ== @@ -10128,12 +10117,7 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001181: - version "1.0.30001258" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001258.tgz" - integrity sha512-RBByOG6xWXUp0CR2/WU2amXz3stjKpSl5J1xU49F1n2OxD//uBZO4wCKUiG+QMGf7CHGfDDcqoKriomoGVxTeA== - -caniuse-lite@^1.0.30001259: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001259: version "1.0.30001261" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001261.tgz#96d89813c076ea061209a4e040d8dcf0c66a1d01" integrity sha512-vM8D9Uvp7bHIN0fZ2KQ4wnmYFpJo/Etb4Vwsuc+ka0tfGDHvOPrFm6S/7CCNLSOkAUjenT2HnUPESdOIL91FaA== @@ -11231,15 +11215,10 @@ core-js@^2.4.0, core-js@^2.5.0, core-js@^2.6.9: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== -core-js@^3.0.1, core-js@^3.0.4, core-js@^3.6.5: - version "3.6.5" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== - -core-js@^3.8.2: - version "3.11.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.11.0.tgz#05dac6aa70c0a4ad842261f8957b961d36eb8926" - integrity sha512-bd79DPpx+1Ilh9+30aT5O1sgpQd4Ttg8oqkqi51ZzhedMM1omD2e6IOF48Z/DzDCZ2svp49tN/3vneTK6ZBkXw== +core-js@^3.0.1, core-js@^3.0.4, core-js@^3.18.2, core-js@^3.6.5, core-js@^3.8.2: + version "3.18.2" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.18.2.tgz#63a551e8a29f305cd4123754846e65896619ba5b" + integrity sha512-zNhPOUoSgoizoSQFdX1MeZO16ORRb9FFQLts8gSYbZU5FcgXhp24iMWMxnOQo5uIaIG7/6FA/IqJPwev1o9ZXQ== core-util-is@1.0.2, core-util-is@^1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -13273,11 +13252,6 @@ electron-to-chromium@^1.3.564: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.642.tgz#8b884f50296c2ae2a9997f024d0e3e57facc2b94" integrity sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ== -electron-to-chromium@^1.3.649: - version "1.3.690" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.690.tgz#54df63ec42fba6b8e9e05fe4be52caeeedb6e634" - integrity sha512-zPbaSv1c8LUKqQ+scNxJKv01RYFkVVF1xli+b+3Ty8ONujHjAMg+t/COmdZqrtnS1gT+g4hbSodHillymt1Lww== - electron-to-chromium@^1.3.846: version "1.3.853" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.853.tgz#f3ed1d31f092cb3a17af188bca6c6a3ec91c3e82" @@ -21199,11 +21173,6 @@ node-releases@^1.1.61: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.61.tgz#707b0fca9ce4e11783612ba4a2fcba09047af16e" integrity sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g== -node-releases@^1.1.70: - version "1.1.71" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb" - integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg== - node-releases@^1.1.76: version "1.1.76" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.76.tgz#df245b062b0cafbd5282ab6792f7dccc2d97f36e" @@ -25271,7 +25240,7 @@ reselect@^4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== -resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: +resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== From 3c8662f9faa3cd99ece0cbdb5b34e85f28ba7ef6 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 12 Oct 2021 12:42:38 -0700 Subject: [PATCH 53/68] [Reporting] deprecate capture.viewport setting from reporting config as unused (#114019) * [Reporting] remove capture.viewport setting from reporting config * update snapshots * update snapshot * add helpful version comment * self-review Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/reporting/common/constants.ts | 5 +++++ .../browsers/chromium/driver_factory/args.ts | 7 +++---- .../browsers/chromium/driver_factory/index.ts | 14 ++++++-------- .../chromium/driver_factory/start_logs.ts | 1 - x-pack/plugins/reporting/server/config/index.ts | 1 + .../reporting/server/config/schema.test.ts | 8 -------- x-pack/plugins/reporting/server/config/schema.ts | 4 ---- .../server/lib/layouts/create_layout.test.ts | 4 ++++ .../reporting/server/lib/layouts/print_layout.ts | 15 ++++++++------- .../server/lib/screenshots/observable.ts | 5 +---- .../create_mock_browserdriverfactory.ts | 1 - 11 files changed, 28 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 3fb02677dd981..cafab65677ee4 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -59,6 +59,11 @@ export const LAYOUT_TYPES = { PRINT: 'print', }; +export const DEFAULT_VIEWPORT = { + width: 1950, + height: 1200, +}; + // Export Type Definitions export const CSV_REPORT_TYPE = 'CSV'; export const CSV_JOB_TYPE = 'csv_searchsource'; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index 3659e05bc3618..07ae13fa31849 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -6,18 +6,17 @@ */ import { CaptureConfig } from '../../../../server/types'; +import { DEFAULT_VIEWPORT } from '../../../../common/constants'; -type ViewportConfig = CaptureConfig['viewport']; type BrowserConfig = CaptureConfig['browser']['chromium']; interface LaunchArgs { userDataDir: string; - viewport: ViewportConfig; disableSandbox: BrowserConfig['disableSandbox']; proxy: BrowserConfig['proxy']; } -export const args = ({ userDataDir, viewport, disableSandbox, proxy: proxyConfig }: LaunchArgs) => { +export const args = ({ userDataDir, disableSandbox, proxy: proxyConfig }: LaunchArgs) => { const flags = [ // Disable built-in Google Translate service '--disable-translate', @@ -45,7 +44,7 @@ export const args = ({ userDataDir, viewport, disableSandbox, proxy: proxyConfig // NOTE: setting the window size does NOT set the viewport size: viewport and window size are different. // The viewport may later need to be resized depending on the position of the clip area. // These numbers come from the job parameters, so this is a close guess. - `--window-size=${Math.floor(viewport.width)},${Math.floor(viewport.height)}`, + `--window-size=${Math.floor(DEFAULT_VIEWPORT.width)},${Math.floor(DEFAULT_VIEWPORT.height)}`, // allow screenshot clip region to go outside of the viewport `--mainFrameClipsContent=false`, ]; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index a0487421a9a0d..688dd425fa8f3 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -5,10 +5,10 @@ * 2.0. */ -import apm from 'elastic-apm-node'; import { i18n } from '@kbn/i18n'; import { getDataPath } from '@kbn/utils'; import del from 'del'; +import apm from 'elastic-apm-node'; import fs from 'fs'; import path from 'path'; import puppeteer from 'puppeteer'; @@ -23,7 +23,7 @@ import { LevelLogger } from '../../../lib'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; import { args } from './args'; -import { Metrics, getMetrics } from './metrics'; +import { getMetrics, Metrics } from './metrics'; // Puppeteer type definitions do not match the documentation. // See https://pptr.dev/#?product=Puppeteer&version=v8.0.0&show=api-puppeteerlaunchoptions @@ -38,14 +38,13 @@ declare module 'puppeteer' { } type BrowserConfig = CaptureConfig['browser']['chromium']; -type ViewportConfig = CaptureConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: string; private captureConfig: CaptureConfig; private browserConfig: BrowserConfig; private userDataDir: string; - private getChromiumArgs: (viewport: ViewportConfig) => string[]; + private getChromiumArgs: () => string[]; private core: ReportingCore; constructor(core: ReportingCore, binaryPath: string, logger: LevelLogger) { @@ -60,10 +59,9 @@ export class HeadlessChromiumDriverFactory { } this.userDataDir = fs.mkdtempSync(path.join(getDataPath(), 'chromium-')); - this.getChromiumArgs = (viewport: ViewportConfig) => + this.getChromiumArgs = () => args({ userDataDir: this.userDataDir, - viewport, disableSandbox: this.browserConfig.disableSandbox, proxy: this.browserConfig.proxy, }); @@ -75,7 +73,7 @@ export class HeadlessChromiumDriverFactory { * Return an observable to objects which will drive screenshot capture for a page */ createPage( - { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone?: string }, + { browserTimezone }: { browserTimezone?: string }, pLogger: LevelLogger ): Rx.Observable<{ driver: HeadlessChromiumDriver; exit$: Rx.Observable }> { // FIXME: 'create' is deprecated @@ -83,7 +81,7 @@ export class HeadlessChromiumDriverFactory { const logger = pLogger.clone(['browser-driver']); logger.info(`Creating browser page driver`); - const chromiumArgs = this.getChromiumArgs(viewport); + const chromiumArgs = this.getChromiumArgs(); logger.debug(`Chromium launch args set to: ${chromiumArgs}`); let browser: puppeteer.Browser; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts index aa27e46b85acb..1a739488bf6ed 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts @@ -74,7 +74,6 @@ export const browserStartLogs = ( const kbnArgs = args({ userDataDir, - viewport: { width: 800, height: 600 }, disableSandbox, proxy, }); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index f8fa47bc00bb0..45a71d05165ba 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -20,6 +20,7 @@ export const config: PluginConfigDescriptor = { unused('capture.browser.chromium.maxScreenshotDimension'), // unused since 7.8 unused('poll.jobCompletionNotifier.intervalErrorMultiplier'), // unused since 7.10 unused('poll.jobsRefresh.intervalErrorMultiplier'), // unused since 7.10 + unused('capture.viewport'), // deprecated as unused since 7.16 (settings, fromPath, addDeprecation) => { const reporting = get(settings, fromPath); if (reporting?.index) { diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts index 0b2e2cac6ff7c..6ad7d03bd1a8f 100644 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -63,10 +63,6 @@ describe('Reporting Config Schema', () => { "renderComplete": "PT30S", "waitForElements": "PT30S", }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, "zoom": 2, }, "csv": Object { @@ -168,10 +164,6 @@ describe('Reporting Config Schema', () => { "renderComplete": "PT30S", "waitForElements": "PT30S", }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, "zoom": 2, }, "csv": Object { diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index 832cf6c28e1fa..5b15260be06cb 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -74,10 +74,6 @@ const CaptureSchema = schema.object({ }), }), zoom: schema.number({ defaultValue: 2 }), - viewport: schema.object({ - width: schema.number({ defaultValue: 1950 }), - height: schema.number({ defaultValue: 1200 }), - }), loadDelay: schema.oneOf([schema.number(), schema.duration()], { defaultValue: moment.duration({ seconds: 3 }), }), diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts b/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts index be9a06267a7c8..f5c2373fc4bf0 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts @@ -67,6 +67,10 @@ describe('Create Layout', () => { "timefilterDurationAttribute": "data-shared-timefilter-duration", }, "useReportingBranding": true, + "viewport": Object { + "height": 1200, + "width": 1950, + }, } `); }); diff --git a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts index 03feb36496349..0849f8850f91d 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts @@ -9,7 +9,7 @@ import path from 'path'; import { PageOrientation, PredefinedPageSize } from 'pdfmake/interfaces'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; import { LevelLogger } from '../'; -import { LAYOUT_TYPES } from '../../../common/constants'; +import { DEFAULT_VIEWPORT, LAYOUT_TYPES } from '../../../common/constants'; import { Size } from '../../../common/types'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; @@ -22,7 +22,8 @@ export class PrintLayout extends Layout implements LayoutInstance { screenshot: '[data-shared-item]', // override '[data-shared-items-container]' }; public readonly groupCount = 2; - private captureConfig: CaptureConfig; + private readonly captureConfig: CaptureConfig; + private readonly viewport = DEFAULT_VIEWPORT; constructor(captureConfig: CaptureConfig) { super(LAYOUT_TYPES.PRINT); @@ -34,7 +35,7 @@ export class PrintLayout extends Layout implements LayoutInstance { } public getBrowserViewport() { - return this.captureConfig.viewport; + return this.viewport; } public getBrowserZoom() { @@ -44,8 +45,8 @@ export class PrintLayout extends Layout implements LayoutInstance { public getViewport(itemsCount: number) { return { zoom: this.captureConfig.zoom, - width: this.captureConfig.viewport.width, - height: this.captureConfig.viewport.height * itemsCount, + width: this.viewport.width, + height: this.viewport.height * itemsCount, }; } @@ -56,8 +57,8 @@ export class PrintLayout extends Layout implements LayoutInstance { logger.debug('positioning elements'); const elementSize: Size = { - width: this.captureConfig.viewport.width / this.captureConfig.zoom, - height: this.captureConfig.viewport.height / this.captureConfig.zoom, + width: this.viewport.width / this.captureConfig.zoom, + height: this.viewport.height / this.captureConfig.zoom, }; const evalOptions: { fn: EvaluateFn; args: SerializableOrJSHandle[] } = { fn: (selector: string, height: number, width: number) => { diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts index b7791cb2924e5..317b50b83548f 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts @@ -45,10 +45,7 @@ export function getScreenshots$( const apmTrans = apm.startTransaction(`reporting screenshot pipeline`, 'reporting'); const apmCreatePage = apmTrans?.startSpan('create_page', 'wait'); - const create$ = browserDriverFactory.createPage( - { viewport: layout.getBrowserViewport(), browserTimezone }, - logger - ); + const create$ = browserDriverFactory.createPage({ browserTimezone }, logger); return create$.pipe( mergeMap(({ driver, exit$ }) => { diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts index 8cd0a63f860e8..83cdc986bb048 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts @@ -114,7 +114,6 @@ export const createMockBrowserDriverFactory = async ( autoDownload: false, }, networkPolicy: { enabled: true, rules: [] }, - viewport: { width: 800, height: 600 }, loadDelay: moment.duration(2, 's'), zoom: 2, maxAttempts: 1, From 1afac0ffbba861497521a4f732feebcdba2ef0ee Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 12 Oct 2021 16:13:58 -0400 Subject: [PATCH 54/68] [Fleet] Have EPR register new categories / Show category counts (#114429) --- .../custom_integrations/common/index.ts | 3 - .../custom_integrations/server/index.ts | 2 +- .../plugins/fleet/common/types/models/epm.ts | 2 + .../use_merge_epr_with_replacements.test.ts | 40 +----- .../hooks/use_merge_epr_with_replacements.ts | 19 +-- .../epm/components/package_card.stories.tsx | 1 + .../components/package_list_grid.stories.tsx | 6 + .../epm/screens/home/available_packages.tsx | 130 ++++++++---------- .../epm/screens/home/category_facets.tsx | 33 ++--- .../sections/epm/screens/home/index.tsx | 1 + .../epm/screens/home/installed_packages.tsx | 7 +- .../sections/epm/screens/home/util.ts | 49 ++++--- .../fleet/server/services/epm/packages/get.ts | 1 - 13 files changed, 130 insertions(+), 164 deletions(-) rename x-pack/plugins/fleet/public/{ => applications/integrations}/hooks/use_merge_epr_with_replacements.test.ts (82%) rename x-pack/plugins/fleet/public/{ => applications/integrations}/hooks/use_merge_epr_with_replacements.ts (75%) diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts index 9af7c4ccd4633..de2a6592465a2 100755 --- a/src/plugins/custom_integrations/common/index.ts +++ b/src/plugins/custom_integrations/common/index.ts @@ -42,9 +42,6 @@ export const INTEGRATION_CATEGORY_DISPLAY = { // Kibana added upload_file: 'Upload a file', language_client: 'Language client', - - // Internal - updates_available: 'Updates available', }; /** diff --git a/src/plugins/custom_integrations/server/index.ts b/src/plugins/custom_integrations/server/index.ts index 490627ef90f8d..00372df501435 100755 --- a/src/plugins/custom_integrations/server/index.ts +++ b/src/plugins/custom_integrations/server/index.ts @@ -19,7 +19,7 @@ export function plugin(initializerContext: PluginInitializerContext) { export { CustomIntegrationsPluginSetup, CustomIntegrationsPluginStart } from './types'; -export type { IntegrationCategory, IntegrationCategoryCount, CustomIntegration } from '../common'; +export type { IntegrationCategory, CustomIntegration } from '../common'; export const config = { schema: schema.object({}), diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index a487fd0a37e70..df4cdec184dc8 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -227,6 +227,7 @@ export type RegistrySearchResult = Pick< | 'internal' | 'data_streams' | 'policy_templates' + | 'categories' >; export type ScreenshotItem = RegistryImage | PackageSpecScreenshot; @@ -376,6 +377,7 @@ export interface IntegrationCardItem { icons: Array; integration: string; id: string; + categories: string[]; } export type PackagesGroupedByStatus = Record, PackageList>; diff --git a/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.test.ts b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_merge_epr_with_replacements.test.ts similarity index 82% rename from x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.test.ts rename to x-pack/plugins/fleet/public/applications/integrations/hooks/use_merge_epr_with_replacements.test.ts index 687fb01b04546..d5d8aa093e300 100644 --- a/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.test.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_merge_epr_with_replacements.test.ts @@ -5,10 +5,10 @@ * 2.0. */ -import type { PackageListItem } from '../../common/types/models'; -import type { CustomIntegration } from '../../../../../src/plugins/custom_integrations/common'; +import type { PackageListItem } from '../../../../common/types/models'; +import type { CustomIntegration } from '../../../../../../../src/plugins/custom_integrations/common'; -import type { IntegrationCategory } from '../../../../../src/plugins/custom_integrations/common'; +import type { IntegrationCategory } from '../../../../../../../src/plugins/custom_integrations/common'; import { useMergeEprPackagesWithReplacements } from './use_merge_epr_with_replacements'; @@ -46,7 +46,7 @@ describe('useMergeEprWithReplacements', () => { }, ]); - expect(useMergeEprPackagesWithReplacements(eprPackages, replacements, '')).toEqual([ + expect(useMergeEprPackagesWithReplacements(eprPackages, replacements)).toEqual([ { name: 'aws', release: 'ga', @@ -80,7 +80,7 @@ describe('useMergeEprWithReplacements', () => { }, ]); - expect(useMergeEprPackagesWithReplacements(eprPackages, replacements, '')).toEqual([ + expect(useMergeEprPackagesWithReplacements(eprPackages, replacements)).toEqual([ { eprOverlap: 'activemq', id: 'activemq-logs', @@ -108,7 +108,7 @@ describe('useMergeEprWithReplacements', () => { }, ]); - expect(useMergeEprPackagesWithReplacements(eprPackages, replacements, '')).toEqual([ + expect(useMergeEprPackagesWithReplacements(eprPackages, replacements)).toEqual([ { name: 'activemq', release: 'beta', @@ -120,32 +120,6 @@ describe('useMergeEprWithReplacements', () => { ]); }); - test('should respect category assignment', () => { - const eprPackages: PackageListItem[] = mockEprPackages([ - { - name: 'activemq', - release: 'beta', - }, - ]); - const replacements: CustomIntegration[] = mockIntegrations([ - { - id: 'prometheus', - categories: ['monitoring', 'datastore'], - }, - { - id: 'oracle', - categories: ['datastore'], - }, - ]); - - expect(useMergeEprPackagesWithReplacements(eprPackages, replacements, 'web')).toEqual([ - { - name: 'activemq', - release: 'beta', - }, - ]); - }); - test('should consists of all 3 types (ga eprs, replacements for non-ga eprs, replacements without epr equivalent', () => { const eprPackages: PackageListItem[] = mockEprPackages([ { @@ -190,7 +164,7 @@ describe('useMergeEprWithReplacements', () => { }, ]); - expect(useMergeEprPackagesWithReplacements(eprPackages, replacements, '')).toEqual([ + expect(useMergeEprPackagesWithReplacements(eprPackages, replacements)).toEqual([ { name: 'aws', release: 'ga', diff --git a/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.ts b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_merge_epr_with_replacements.ts similarity index 75% rename from x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.ts rename to x-pack/plugins/fleet/public/applications/integrations/hooks/use_merge_epr_with_replacements.ts index ac53badc2446d..4c59f0ef45123 100644 --- a/x-pack/plugins/fleet/public/hooks/use_merge_epr_with_replacements.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_merge_epr_with_replacements.ts @@ -5,12 +5,9 @@ * 2.0. */ -import type { PackageListItem } from '../../common/types/models'; -import type { - CustomIntegration, - IntegrationCategory, -} from '../../../../../src/plugins/custom_integrations/common'; -import { filterCustomIntegrations } from '../../../../../src/plugins/custom_integrations/public'; +import type { PackageListItem } from '../../../../common/types/models'; +import type { CustomIntegration } from '../../../../../../../src/plugins/custom_integrations/common'; +import { filterCustomIntegrations } from '../../../../../../../src/plugins/custom_integrations/public'; // Export this as a utility to find replacements for a package (e.g. in the overview-page for an EPR package) function findReplacementsForEprPackage( @@ -26,17 +23,13 @@ function findReplacementsForEprPackage( export function useMergeEprPackagesWithReplacements( eprPackages: PackageListItem[], - replacements: CustomIntegration[], - category: IntegrationCategory | '' + replacements: CustomIntegration[] ): Array { const merged: Array = []; - - const filteredReplacements = replacements.filter((customIntegration) => { - return !category || customIntegration.categories.includes(category); - }); + const filteredReplacements = replacements; // Either select replacement or select beat - eprPackages.forEach((eprPackage) => { + eprPackages.forEach((eprPackage: PackageListItem) => { const hits = findReplacementsForEprPackage( filteredReplacements, eprPackage.name, 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 bddbc4f027b4f..94370587ddec8 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 @@ -32,6 +32,7 @@ const args: Args = { url: '/', icons: [], integration: '', + categories: ['foobar'], }; const argTypes = { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx index e4bd1da842867..f43c18d167717 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx @@ -47,6 +47,7 @@ export const List = (props: Args) => ( url: 'https://example.com', icons: [], integration: 'integation', + categories: ['web'], }, { title: 'Package Two', @@ -58,6 +59,7 @@ export const List = (props: Args) => ( url: 'https://example.com', icons: [], integration: 'integation', + categories: ['web'], }, { title: 'Package Three', @@ -69,6 +71,7 @@ export const List = (props: Args) => ( url: 'https://example.com', icons: [], integration: 'integation', + categories: ['web'], }, { title: 'Package Four', @@ -80,6 +83,7 @@ export const List = (props: Args) => ( url: 'https://example.com', icons: [], integration: 'integation', + categories: ['web'], }, { title: 'Package Five', @@ -91,6 +95,7 @@ export const List = (props: Args) => ( url: 'https://example.com', icons: [], integration: 'integation', + categories: ['web'], }, { title: 'Package Six', @@ -102,6 +107,7 @@ export const List = (props: Args) => ( url: 'https://example.com', icons: [], integration: 'integation', + categories: ['web'], }, ]} onSearchChange={action('onSearchChange')} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx index 8aef9121bf67d..812320261e77f 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx @@ -8,6 +8,7 @@ import React, { memo, useMemo } from 'react'; import { useLocation, useHistory, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; import { pagePathGetters } from '../../../../constants'; import { @@ -26,30 +27,53 @@ import type { CustomIntegration } from '../../../../../../../../../../src/plugin import type { PackageListItem } from '../../../../types'; -import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common'; +import type { IntegrationCardItem } from '../../../../../../../common/types/models'; -import { useMergeEprPackagesWithReplacements } from '../../../../../../hooks/use_merge_epr_with_replacements'; +import { useMergeEprPackagesWithReplacements } from '../../../../hooks/use_merge_epr_with_replacements'; -import { mergeAndReplaceCategoryCounts } from './util'; -import { CategoryFacets } from './category_facets'; +import { mergeCategoriesAndCount } from './util'; +import { ALL_CATEGORY, CategoryFacets } from './category_facets'; import type { CategoryFacet } from './category_facets'; import type { CategoryParams } from '.'; import { getParams, categoryExists, mapToCard } from '.'; +function getAllCategoriesFromIntegrations(pkg: PackageListItem) { + if (!doesPackageHaveIntegrations(pkg)) { + return pkg.categories; + } + + const allCategories = pkg.policy_templates?.reduce((accumulator, integration) => { + return [...accumulator, ...(integration.categories || [])]; + }, pkg.categories || []); + + return _.uniq(allCategories); +} + // Packages can export multiple integrations, aka `policy_templates` // In the case where packages ship >1 `policy_templates`, we flatten out the // list of packages by bringing all integrations to top-level so that // each integration is displayed as its own tile const packageListToIntegrationsList = (packages: PackageList): PackageList => { return packages.reduce((acc: PackageList, pkg) => { - const { policy_templates: policyTemplates = [], ...restOfPackage } = pkg; + const { + policy_templates: policyTemplates = [], + categories: topCategories = [], + ...restOfPackage + } = pkg; + + const topPackage = { + ...restOfPackage, + categories: getAllCategoriesFromIntegrations(pkg), + }; + return [ ...acc, - restOfPackage, + topPackage, ...(doesPackageHaveIntegrations(pkg) ? policyTemplates.map((integration) => { - const { name, title, description, icons } = integration; + const { name, title, description, icons, categories = [] } = integration; + const allCategories = [...topCategories, ...categories]; return { ...restOfPackage, id: `${restOfPackage}-${name}`, @@ -57,6 +81,7 @@ const packageListToIntegrationsList = (packages: PackageList): PackageList => { title, description, icons: icons || restOfPackage.icons, + categories: _.uniq(allCategories), }; }) : []), @@ -72,14 +97,11 @@ const title = i18n.translate('xpack.fleet.epmList.allTitle', { // or `location` to load data. Ideally, we'll split this into "connected" and "pure" components. export const AvailablePackages: React.FC = memo(() => { useBreadcrumbs('integrations_all'); - const { selectedCategory, searchParam } = getParams( useParams(), useLocation().search ); - const history = useHistory(); - const { getHref, getAbsolutePath } = useLink(); function setSelectedCategory(categoryId: string) { @@ -89,7 +111,6 @@ export const AvailablePackages: React.FC = memo(() => { })[1]; history.push(url); } - function setSearchTerm(search: string) { // Use .replace so the browser's back button is not tied to single keystroke history.replace( @@ -97,84 +118,51 @@ export const AvailablePackages: React.FC = memo(() => { ); } - const { data: allCategoryPackagesRes, isLoading: isLoadingAllPackages } = useGetPackages({ + const { data: eprPackages, isLoading: isLoadingAllPackages } = useGetPackages({ category: '', }); - - const { data: categoryPackagesRes, isLoading: isLoadingCategoryPackages } = useGetPackages({ - category: selectedCategory, - }); - - const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({ - include_policy_templates: true, - }); - - const eprPackages = useMemo( - () => packageListToIntegrationsList(categoryPackagesRes?.response || []), - [categoryPackagesRes] - ); - - const allEprPackages = useMemo( - () => packageListToIntegrationsList(allCategoryPackagesRes?.response || []), - [allCategoryPackagesRes] + const eprIntegrationList = useMemo( + () => packageListToIntegrationsList(eprPackages?.response || []), + [eprPackages] ); - const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations(); - const mergedEprPackages: Array = useMergeEprPackagesWithReplacements( - eprPackages || [], - replacementCustomIntegrations || [], - selectedCategory as IntegrationCategory + eprIntegrationList || [], + replacementCustomIntegrations || [] ); - const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } = useGetAppendCustomIntegrations(); - - const filteredAddableIntegrations = appendCustomIntegrations - ? appendCustomIntegrations.filter((integration: CustomIntegration) => { - if (!selectedCategory) { - return true; - } - return integration.categories.indexOf(selectedCategory as IntegrationCategory) >= 0; - }) - : []; - const eprAndCustomPackages: Array = [ ...mergedEprPackages, - ...filteredAddableIntegrations, + ...(appendCustomIntegrations || []), ]; - - eprAndCustomPackages.sort((a, b) => { + const cards: IntegrationCardItem[] = eprAndCustomPackages.map((item) => { + return mapToCard(getAbsolutePath, getHref, item); + }); + cards.sort((a, b) => { return a.title.localeCompare(b.title); }); + const { data: eprCategories, isLoading: isLoadingCategories } = useGetCategories({ + include_policy_templates: true, + }); const categories = useMemo(() => { const eprAndCustomCategories: CategoryFacet[] = - isLoadingCategories || - isLoadingAppendCustomIntegrations || - !appendCustomIntegrations || - !categoriesRes + isLoadingCategories || !eprCategories ? [] - : mergeAndReplaceCategoryCounts( - categoriesRes.response as CategoryFacet[], - appendCustomIntegrations + : mergeCategoriesAndCount( + eprCategories.response as Array<{ id: string; title: string; count: number }>, + cards ); - return [ { - id: '', - count: (allEprPackages?.length || 0) + (appendCustomIntegrations?.length || 0), + ...ALL_CATEGORY, + count: cards.length, }, ...(eprAndCustomCategories ? eprAndCustomCategories : []), ] as CategoryFacet[]; - }, [ - allEprPackages?.length, - appendCustomIntegrations, - categoriesRes, - isLoadingAppendCustomIntegrations, - isLoadingCategories, - ]); + }, [cards, eprCategories, isLoadingCategories]); if (!isLoadingCategories && !categoryExists(selectedCategory, categories)) { history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]); @@ -183,7 +171,6 @@ export const AvailablePackages: React.FC = memo(() => { const controls = categories ? ( { /> ) : null; - const cards = eprAndCustomPackages.map((item) => { - return mapToCard(getAbsolutePath, getHref, item); + const filteredCards = cards.filter((c) => { + if (selectedCategory === '') { + return true; + } + return c.categories.includes(selectedCategory); }); return ( ) : ( categories.map((category) => { - let title; - - if (category.id === 'updates_available') { - title = i18n.translate('xpack.fleet.epmList.updatesAvailableFilterLinkText', { - defaultMessage: 'Updates available', - }); - } else if (category.id === '') { - title = i18n.translate('xpack.fleet.epmList.allPackagesFilterLinkText', { - defaultMessage: 'All', - }); - } else { - title = INTEGRATION_CATEGORY_DISPLAY[category.id]; - } return ( onCategoryChange(category)} > - {title} + {category.title} ); }) 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 bbebf9e90b16c..9528bd73f9192 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 @@ -72,6 +72,7 @@ export const mapToCard = ( name: 'name' in item ? item.name || '' : '', version: 'version' in item ? item.version || '' : '', release: 'release' in item ? item.release : undefined, + categories: ((item.categories || []) as string[]).filter((c: string) => !!c), }; }; 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 404e8820f90b7..efcdb7b169edf 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 @@ -23,6 +23,7 @@ import { CategoryFacets } from './category_facets'; import type { CategoryParams } from '.'; import { getParams, categoryExists, mapToCard } from '.'; +import { ALL_CATEGORY } from './category_facets'; const AnnouncementLink = () => { const { docLinks } = useStartServices(); @@ -114,12 +115,15 @@ export const InstalledPackages: React.FC = memo(() => { const categories: CategoryFacet[] = useMemo( () => [ { - id: '', + ...ALL_CATEGORY, count: allInstalledPackages.length, }, { id: 'updates_available', count: updatablePackages.length, + title: i18n.translate('xpack.fleet.epmList.updatesAvailableFilterLinkText', { + defaultMessage: 'Updates available', + }), }, ], [allInstalledPackages.length, updatablePackages.length] @@ -135,7 +139,6 @@ export const InstalledPackages: React.FC = memo(() => { const controls = ( setSelectedCategory(id)} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/util.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/util.ts index 53a62555650ab..70902b2bc1897 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/util.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/util.ts @@ -5,46 +5,57 @@ * 2.0. */ -import type { - CustomIntegration, - IntegrationCategory, -} from '../../../../../../../../../../src/plugins/custom_integrations/common'; +import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common'; +import { INTEGRATION_CATEGORY_DISPLAY } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +import type { IntegrationCardItem } from '../../../../../../../common/types/models'; import type { CategoryFacet } from './category_facets'; -export function mergeAndReplaceCategoryCounts( - eprCounts: CategoryFacet[], - addableIntegrations: CustomIntegration[] +export function mergeCategoriesAndCount( + eprCategoryList: Array<{ id: string; title: string; count: number }>, // EPR-categories from backend call to EPR + cards: IntegrationCardItem[] ): CategoryFacet[] { - const merged: CategoryFacet[] = []; + const facets: CategoryFacet[] = []; - const addIfMissing = (category: string, count: number) => { - const match = merged.find((c) => { + const addIfMissing = (category: string, count: number, title: string) => { + const match = facets.find((c) => { return c.id === category; }); if (match) { match.count += count; } else { - merged.push({ - id: category as IntegrationCategory, + facets.push({ + id: category, count, + title, }); } }; - eprCounts.forEach((facet) => { - addIfMissing(facet.id, facet.count); + // Seed the list with the dynamic categories + eprCategoryList.forEach((facet) => { + addIfMissing(facet.id, 0, facet.title); }); - addableIntegrations.forEach((integration) => { - integration.categories.forEach((cat) => { - addIfMissing(cat, 1); + + // Count all the categories + cards.forEach((integration) => { + integration.categories.forEach((cat: string) => { + const title = INTEGRATION_CATEGORY_DISPLAY[cat as IntegrationCategory] + ? INTEGRATION_CATEGORY_DISPLAY[cat as IntegrationCategory] + : cat; + addIfMissing(cat, 1, title); }); }); - merged.sort((a, b) => { + const filledFacets = facets.filter((facet) => { + return facet.count > 0; + }); + + filledFacets.sort((a, b) => { return a.id.localeCompare(b.id); }); - return merged; + return filledFacets; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index d4f988e5fba8c..cf847cdf62bc2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -53,7 +53,6 @@ export async function getPackages( }); // get the installed packages const packageSavedObjects = await getPackageSavedObjects(savedObjectsClient); - // filter out any internal packages const savedObjectsVisible = packageSavedObjects.saved_objects.filter( (o) => !o.attributes.internal From d014729ae2b7603d48d9e431095c02012a015d4a Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Tue, 12 Oct 2021 16:25:23 -0400 Subject: [PATCH 55/68] Add APM and Synthetics to auto update package list (#114641) --- x-pack/plugins/fleet/common/constants/epm.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index e482db6ae73ab..131cc276fc073 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -13,6 +13,8 @@ export const FLEET_SYSTEM_PACKAGE = 'system'; export const FLEET_ELASTIC_AGENT_PACKAGE = 'elastic_agent'; export const FLEET_SERVER_PACKAGE = 'fleet_server'; export const FLEET_ENDPOINT_PACKAGE = 'endpoint'; +export const FLEET_APM_PACKAGE = 'apm'; +export const FLEET_SYNTHETICS_PACKAGE = 'synthetics'; /* Package rules: @@ -37,7 +39,11 @@ export const unremovablePackages = [ export const defaultPackages = unremovablePackages.filter((p) => p !== FLEET_ENDPOINT_PACKAGE); -export const autoUpdatePackages = [FLEET_ENDPOINT_PACKAGE]; +export const autoUpdatePackages = [ + FLEET_ENDPOINT_PACKAGE, + FLEET_APM_PACKAGE, + FLEET_SYNTHETICS_PACKAGE, +]; export const agentAssetTypes = { Input: 'input', From 7290b6936a4b6647001e1f344b8f56b226eb41f6 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 12 Oct 2021 13:58:12 -0700 Subject: [PATCH 56/68] [ci] Updates APM server (#114588) Signed-off-by: Tyler Smalley --- .buildkite/scripts/common/env.sh | 7 ++++++- packages/kbn-apm-config-loader/src/config.test.ts | 8 ++++---- packages/kbn-apm-config-loader/src/config.ts | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh index ac80a66d33fa0..cd33cdc714cbe 100755 --- a/.buildkite/scripts/common/env.sh +++ b/.buildkite/scripts/common/env.sh @@ -36,7 +36,12 @@ export ELASTIC_APM_ENVIRONMENT=ci export ELASTIC_APM_TRANSACTION_SAMPLE_RATE=0.1 if is_pr; then - export ELASTIC_APM_ACTIVE=false + if [[ "${GITHUB_PR_LABELS:-}" == *"ci:collect-apm"* ]]; then + export ELASTIC_APM_ACTIVE=true + else + export ELASTIC_APM_ACTIVE=false + fi + export CHECKS_REPORTER_ACTIVE=true # These can be removed once we're not supporting Jenkins and Buildkite at the same time diff --git a/packages/kbn-apm-config-loader/src/config.test.ts b/packages/kbn-apm-config-loader/src/config.test.ts index 4e4dbf81740b3..60d773e3a420b 100644 --- a/packages/kbn-apm-config-loader/src/config.test.ts +++ b/packages/kbn-apm-config-loader/src/config.test.ts @@ -94,8 +94,8 @@ describe('ApmConfiguration', () => { "globalLabels": Object {}, "logUncaughtExceptions": true, "metricsInterval": "30s", - "secretToken": "ZQHYvrmXEx04ozge8F", - "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "secretToken": "7YKhoXsO4MzjhXjx2c", + "serverUrl": "https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io", "serviceName": "serviceName", "serviceVersion": "8.0.0", "transactionSampleRate": 1, @@ -117,8 +117,8 @@ describe('ApmConfiguration', () => { }, "logUncaughtExceptions": true, "metricsInterval": "120s", - "secretToken": "ZQHYvrmXEx04ozge8F", - "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "secretToken": "7YKhoXsO4MzjhXjx2c", + "serverUrl": "https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io", "serviceName": "serviceName", "serviceVersion": "8.0.0", "transactionSampleRate": 1, diff --git a/packages/kbn-apm-config-loader/src/config.ts b/packages/kbn-apm-config-loader/src/config.ts index ad2fd63f0fec4..999e4ce3a6805 100644 --- a/packages/kbn-apm-config-loader/src/config.ts +++ b/packages/kbn-apm-config-loader/src/config.ts @@ -23,14 +23,14 @@ const DEFAULT_CONFIG: AgentConfigOptions = { }; const CENTRALIZED_SERVICE_BASE_CONFIG: AgentConfigOptions = { - serverUrl: 'https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io', + serverUrl: 'https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io', // The secretToken below is intended to be hardcoded in this file even though // it makes it public. This is not a security/privacy issue. Normally we'd // instead disable the need for a secretToken in the APM Server config where // the data is transmitted to, but due to how it's being hosted, it's easier, // for now, to simply leave it in. - secretToken: 'ZQHYvrmXEx04ozge8F', + secretToken: '7YKhoXsO4MzjhXjx2c', centralConfig: false, metricsInterval: '30s', From 636581ac26d540411ec4d385b04125515f0bd0b6 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 12 Oct 2021 14:04:41 -0700 Subject: [PATCH 57/68] [Reporting/Docs] remove note about host name set to "0" (#114710) --- docs/settings/reporting-settings.asciidoc | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 560f2d850c6d5..af10430ef8d01 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -79,11 +79,6 @@ The protocol for accessing {kib}, typically `http` or `https`. [[xpack-kibanaServer-hostname]] `xpack.reporting.kibanaServer.hostname`:: The hostname for accessing {kib}, if different from the <> value. -NOTE: Reporting authenticates requests on the {kib} page only when the hostname matches the -<> setting. Therefore Reporting fails if the -set value redirects to another server. For that reason, `"0"` is an invalid setting -because, in the Reporting browser, it becomes an automatic redirect to `"0.0.0.0"`. - [float] [[reporting-job-queue-settings]] ==== Background job settings From a51545ecc3ac6fc640dd901252535d2efb82d85b Mon Sep 17 00:00:00 2001 From: Bree Hall <40739624+breehall@users.noreply.github.com> Date: Tue, 12 Oct 2021 17:11:04 -0400 Subject: [PATCH 58/68] Bumping EUI to 39.0.0 (#113633) * Upgraded the version of EUI to 38.2.0 from 38.0.1 * Updated the i18n mappings required for EUI v.38.2.0 * Update i18n snapshots and resolve linting error * Removed html_id_generator mocks. Current mock was failing due to missing useGeneratedHtmlId export. This is safe to remove because EUI contains a .testenv that contains an mock for html_id_generator. More info at https://github.com/elastic/eui/blob/master/src/services/accessibility/html_id_generator.testenv.ts * Resolve linting error in i18n mapping file * Removed html_id_generator mocks. Current mock was failing due to missing useGeneratedHtmlId export. This is safe to remove because EUI contains a .testenv that contains a mock for html_id_generator. More info at https://github.com/elastic/eui/blob/master/src/services/accessibility/html_id_generator.testenv.ts * Update plugin snapshots * Resolve merge conflict in license_checker config.ts file * Upgrade EUI to version 39.0.0 from the original target (38.2.0) to handle an issue found with a functional test during the original upgrade * Updated the i18n mapping for EUI v.39.0.0 * Update various snapshots to account for the an i18n translation token addition in EUI v. 39.0.0 * Updated test cases marked as obsolete by CI * Update src/dev/license_checker/config.ts Removing TODO comments from src/dev/license_checker/config.ts as they are no longer needed. Co-authored-by: Constance Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Constance --- package.json | 2 +- .../__snapshots__/status_table.test.tsx.snap | 7 ++- .../__snapshots__/i18n_service.test.tsx.snap | 7 ++- src/core/public/i18n/i18n_eui_mapping.tsx | 23 +++++++-- src/dev/license_checker/config.ts | 2 +- .../__snapshots__/data_view.test.tsx.snap | 14 +++++- .../cron_editor/cron_editor.test.tsx | 6 --- .../client_integration/helpers/jest.mocks.tsx | 6 --- .../color/__snapshots__/color.test.tsx.snap | 21 ++++++-- .../__snapshots__/static_lookup.test.tsx.snap | 14 +++++- .../url/__snapshots__/url.test.tsx.snap | 2 +- .../editors/url/url.test.tsx | 6 --- .../__snapshots__/samples.test.tsx.snap | 7 ++- .../__snapshots__/table.test.tsx.snap | 14 +++++- .../error_group_overview/List/List.test.tsx | 6 --- .../List/__snapshots__/List.test.tsx.snap | 6 +-- .../shared/Stacktrace/Stackframe.test.tsx | 6 --- .../__snapshots__/managed_table.test.tsx.snap | 14 +++++- .../asset_manager.stories.storyshot | 8 +-- .../expression_input.stories.storyshot | 2 +- .../file_upload.stories.storyshot | 4 +- .../workpad_table.stories.storyshot | 14 +++--- .../footer/settings/settings.test.tsx | 6 +-- .../canvas/storybook/storyshots.test.tsx | 7 --- .../__jest__/add_license.test.js | 5 -- .../__jest__/request_trial_extension.test.js | 5 -- .../__jest__/revert_to_basic.test.js | 5 -- .../__jest__/start_trial.test.js | 6 --- .../__snapshots__/latest_active.test.js.snap | 7 ++- .../__snapshots__/latest_types.test.js.snap | 7 ++- .../latest_versions.test.js.snap | 7 ++- .../logs/__snapshots__/logs.test.js.snap | 7 ++- .../report_listing.test.tsx.snap | 50 +++++++++---------- .../public/management/report_listing.test.tsx | 6 --- .../__snapshots__/index.test.tsx.snap | 21 ++++++-- .../view/trusted_apps_page.test.tsx | 4 -- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../__snapshots__/donut_chart.test.tsx.snap | 12 ++++- .../location_status_tags.test.tsx | 6 --- .../monitor_list/monitor_list.test.tsx | 6 --- yarn.lock | 8 +-- 42 files changed, 204 insertions(+), 166 deletions(-) diff --git a/package.json b/package.json index ce8e928688805..fa03604178e27 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.21", "@elastic/ems-client": "7.15.0", - "@elastic/eui": "38.0.1", + "@elastic/eui": "39.0.0", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", diff --git a/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap b/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap index 16b67bfa0584f..5b0e831286aad 100644 --- a/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap +++ b/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap @@ -35,7 +35,12 @@ exports[`StatusTable renders when statuses is provided 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} rowProps={[Function]} tableLayout="fixed" diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index 54e223cdc5d41..d714f2159d1a2 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -7,6 +7,7 @@ exports[`#start() returns \`Context\` component 1`] = ` Object { "mapping": Object { "euiAccordion.isLoading": "Loading", + "euiBasicTable.noItemsMessage": "No items found", "euiBasicTable.selectAllRows": "Select all rows", "euiBasicTable.selectThisRow": "Select this row", "euiBasicTable.tableAutoCaptionWithPagination": [Function], @@ -120,13 +121,15 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiLink.external.ariaLabel": "External link", "euiLink.newTarget.screenReaderOnlyText": "(opens in a new tab or window)", "euiMarkdownEditorFooter.closeButton": "Close", - "euiMarkdownEditorFooter.descriptionPrefix": "This editor uses", - "euiMarkdownEditorFooter.descriptionSuffix": "You can also utilize these additional syntax plugins to add rich content to your text.", "euiMarkdownEditorFooter.errorsTitle": "Errors", + "euiMarkdownEditorFooter.mdSyntaxLink": "GitHub flavored markdown", "euiMarkdownEditorFooter.openUploadModal": "Open upload files modal", "euiMarkdownEditorFooter.showMarkdownHelp": "Show markdown help", "euiMarkdownEditorFooter.showSyntaxErrors": "Show errors", "euiMarkdownEditorFooter.supportedFileTypes": [Function], + "euiMarkdownEditorFooter.syntaxModalDescriptionPrefix": "This editor uses", + "euiMarkdownEditorFooter.syntaxModalDescriptionSuffix": "You can also utilize these additional syntax plugins to add rich content to your text.", + "euiMarkdownEditorFooter.syntaxPopoverDescription": "This editor uses", "euiMarkdownEditorFooter.syntaxTitle": "Syntax help", "euiMarkdownEditorFooter.unsupportedFileType": "File type not supported", "euiMarkdownEditorFooter.uploadingFiles": "Click to upload files", diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 2fe9657bce8c9..7585ada886c05 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -68,6 +68,9 @@ export const getEuiContextMapping = (): EuiTokensObject => { values: { tableCaption }, description: 'Screen reader text to describe the pagination controls', }), + 'euiBasicTable.noItemsMessage': i18n.translate('core.euiBasicTable.noItemsMessage', { + defaultMessage: 'No items found', + }), 'euiBottomBar.customScreenReaderAnnouncement': ({ landmarkHeading }: EuiValues) => i18n.translate('core.euiBottomBar.customScreenReaderAnnouncement', { defaultMessage: @@ -634,19 +637,31 @@ export const getEuiContextMapping = (): EuiTokensObject => { defaultMessage: 'Syntax help', } ), - 'euiMarkdownEditorFooter.descriptionPrefix': i18n.translate( - 'core.euiMarkdownEditorFooter.descriptionPrefix', + 'euiMarkdownEditorFooter.mdSyntaxLink': i18n.translate( + 'core.euiMarkdownEditorFooter.mdSyntaxLink', + { + defaultMessage: 'GitHub flavored markdown', + } + ), + 'euiMarkdownEditorFooter.syntaxModalDescriptionPrefix': i18n.translate( + 'core.euiMarkdownEditorFooter.syntaxModalDescriptionPrefix', { defaultMessage: 'This editor uses', } ), - 'euiMarkdownEditorFooter.descriptionSuffix': i18n.translate( - 'core.euiMarkdownEditorFooter.descriptionSuffix', + 'euiMarkdownEditorFooter.syntaxModalDescriptionSuffix': i18n.translate( + 'core.euiMarkdownEditorFooter.syntaxModalDescriptionSuffix', { defaultMessage: 'You can also utilize these additional syntax plugins to add rich content to your text.', } ), + 'euiMarkdownEditorFooter.syntaxPopoverDescription': i18n.translate( + 'core.euiMarkdownEditorFooter.syntaxPopoverDescription', + { + defaultMessage: 'This editor uses', + } + ), 'euiMarkdownEditorToolbar.editor': i18n.translate('core.euiMarkdownEditorToolbar.editor', { defaultMessage: 'Editor', }), diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index e4f1c6a2d2e01..818ea3675194e 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -75,6 +75,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@7.15.0': ['Elastic License 2.0'], - '@elastic/eui@38.0.1': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@39.0.0': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index 9cd0687a1074d..eae2032748396 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -884,7 +884,12 @@ exports[`Inspector Data View component should render single table without select }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { @@ -2449,7 +2454,12 @@ exports[`Inspector Data View component should support multiple datatables 1`] = }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx index 26ef7483bbbbd..0ae82872124a4 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx @@ -14,12 +14,6 @@ import { mountWithI18nProvider } from '@kbn/test/jest'; import { Frequency } from './types'; import { CronEditor } from './cron_editor'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - describe('CronEditor', () => { ['MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'].forEach((unit) => { test(`is rendered with a ${unit} frequency`, () => { diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx index e291ec7b4ca08..d33a0d2a87fb5 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx @@ -9,12 +9,6 @@ import React from 'react'; const EDITOR_ID = 'testEditor'; -jest.mock('@elastic/eui/lib/services/accessibility', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap index 7406e5ae9bb2d..1cff82729e6f9 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/color/__snapshots__/color.test.tsx.snap @@ -76,7 +76,12 @@ exports[`ColorFormatEditor should render multiple colors 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> @@ -170,7 +175,12 @@ exports[`ColorFormatEditor should render other type normally (range field) 1`] = }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> @@ -264,7 +274,12 @@ exports[`ColorFormatEditor should render string type normally (regex field) 1`] }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap index 664912789b0e3..3b476a6037aed 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.tsx.snap @@ -55,7 +55,12 @@ exports[`StaticLookupFormatEditor should render multiple lookup entries and unkn }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} style={ Object { @@ -159,7 +164,12 @@ exports[`StaticLookupFormatEditor should render normally 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} style={ Object { diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap index 79c1a11cfef84..3890d6c2b9ddb 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/url/__snapshots__/url.test.tsx.snap @@ -292,7 +292,7 @@ exports[`UrlFormatEditor should render normally 1`] = `

{ - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - const fieldType = 'string'; const format = { getConverterFor: jest diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap index 1a0b96c14fe35..71693a1e5cb8c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/samples/__snapshots__/samples.test.tsx.snap @@ -52,7 +52,12 @@ exports[`FormatEditorSamples should render normally 1`] = ` }, ] } - noItemsMessage="No items found" + noItemsMessage={ + + } responsive={true} tableLayout="fixed" /> diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 8325e7dc886e8..bca54ff67591c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -188,7 +188,12 @@ exports[`Table prevents saved objects from being deleted 1`] = ` ] } loading={false} - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { @@ -403,7 +408,12 @@ exports[`Table should render normally 1`] = ` ] } loading={false} - noItemsMessage="No items found" + noItemsMessage={ + + } onChange={[Function]} pagination={ Object { diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx index a2a92b7e16f8e..12fa1c955ccc8 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx @@ -15,12 +15,6 @@ import props from './__fixtures__/props.json'; import { MemoryRouter } from 'react-router-dom'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { - return { - htmlIdGenerator: () => () => `generated-id`, - }; -}); - describe('ErrorGroupOverview -> List', () => { beforeAll(() => { mockMoment(); diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap index 890c692096a66..c8c7bf82dff04 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap @@ -56,7 +56,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = `
List should render with data 1`] = `
List should render with data 1`] = ` className="euiPagination__item" >