diff --git a/.buildkite/disabled_jest_configs.json b/.buildkite/disabled_jest_configs.json index a64c34ae741b4..9727d38158520 100644 --- a/.buildkite/disabled_jest_configs.json +++ b/.buildkite/disabled_jest_configs.json @@ -1,3 +1,4 @@ [ + "x-pack/plugins/triggers_actions_ui/jest.config.js", "x-pack/plugins/watcher/jest.config.js" ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 51968181ef567..fa1476ad45c17 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -117,6 +117,11 @@ /docs/settings/reporting-settings.asciidoc @elastic/kibana-global-experience /docs/setup/configuring-reporting.asciidoc @elastic/kibana-global-experience +### Global Experience Tagging +/src/plugins/saved_objects_tagging_oss @elastic/kibana-global-experience +/x-pack/plugins/saved_objects_tagging/ @elastic/kibana-global-experience +/x-pack/test/saved_object_tagging/ @elastic/kibana-global-experience + ### Kibana React (to be deprecated) /src/plugins/kibana_react/ @elastic/kibana-global-experience /src/plugins/kibana_react/public/code_editor @elastic/kibana-global-experience @elastic/kibana-presentation @@ -302,7 +307,6 @@ # Core /examples/hello_world/ @elastic/kibana-core /src/core/ @elastic/kibana-core -/src/plugins/saved_objects_tagging_oss @elastic/kibana-core /config/kibana.yml @elastic/kibana-core /typings/ @elastic/kibana-core /x-pack/plugins/global_search_providers @elastic/kibana-core @@ -312,9 +316,7 @@ /x-pack/plugins/global_search/ @elastic/kibana-core /x-pack/plugins/cloud/ @elastic/kibana-core /x-pack/plugins/cloud_integrations/ @elastic/kibana-core -/x-pack/plugins/saved_objects_tagging/ @elastic/kibana-core /x-pack/test/saved_objects_field_count/ @elastic/kibana-core -/x-pack/test/saved_object_tagging/ @elastic/kibana-core /src/plugins/saved_objects_management/ @elastic/kibana-core /src/plugins/advanced_settings/ @elastic/kibana-core /x-pack/plugins/global_search_bar/ @elastic/kibana-core diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index 6c695cd3a74a9..de23c3a9962e0 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -172,6 +172,20 @@ Compare your real-time data to the results that are offset by a time increment. For a time shift example, refer to <>. +[float] +[[multi-metric-partition-chart]] +==== Build a partition chart from multiple metrics + +By default, partition charts (e.g. pie) are built from one or more "slice-by" dimensions to define the partitions and a single metric dimension to define their size. However, you can also build a partition chart from multiple metric dimensions. + +. Open the layer context menu at the top right of the layer panel. + +. Click *Layer settings*. + +. Click the switch labeled *Multiple metrics*. + +Note: this option is not available for mosaic charts. + [float] [[add-annotations]] ==== Add annotations diff --git a/examples/guided_onboarding_example/public/components/main.tsx b/examples/guided_onboarding_example/public/components/main.tsx index d3636470ec64e..0b8099595d5cd 100644 --- a/examples/guided_onboarding_example/public/components/main.tsx +++ b/examples/guided_onboarding_example/public/components/main.tsx @@ -113,7 +113,10 @@ export const Main = (props: MainProps) => { guideId: selectedGuide!, }; - const response = await guidedOnboardingApi?.updateGuideState(updatedGuideState, true); + const response = await guidedOnboardingApi?.updatePluginState( + { status: 'in_progress', guide: updatedGuideState }, + true + ); if (response) { notifications.toasts.addSuccess( i18n.translate('guidedOnboardingExample.updateGuideState.toastLabel', { diff --git a/package.json b/package.json index 667b1bec59753..adbb82da3a909 100644 --- a/package.json +++ b/package.json @@ -684,25 +684,25 @@ "devDependencies": { "@apidevtools/swagger-parser": "^10.0.3", "@babel/cli": "^7.19.3", - "@babel/core": "^7.19.6", + "@babel/core": "^7.20.2", "@babel/eslint-parser": "^7.19.1", "@babel/eslint-plugin": "^7.19.1", - "@babel/generator": "^7.20.1", - "@babel/helper-plugin-utils": "^7.19.0", - "@babel/parser": "^7.20.1", + "@babel/generator": "^7.20.3", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/parser": "^7.20.3", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-proposal-export-namespace-from": "^7.18.9", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", - "@babel/plugin-proposal-object-rest-spread": "^7.19.4", + "@babel/plugin-proposal-object-rest-spread": "^7.20.2", "@babel/plugin-proposal-optional-chaining": "^7.18.9", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-transform-runtime": "^7.19.6", - "@babel/preset-env": "^7.19.4", + "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@babel/register": "^7.18.9", "@babel/traverse": "^7.20.1", - "@babel/types": "^7.20.0", + "@babel/types": "^7.20.2", "@bazel/ibazel": "^0.16.2", "@bazel/typescript": "4.6.2", "@cypress/code-coverage": "^3.10.0", diff --git a/packages/content-management/table_list/src/__jest__/tests.helpers.tsx b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx index 544044d6ec76c..9fd8fa66cee6c 100644 --- a/packages/content-management/table_list/src/__jest__/tests.helpers.tsx +++ b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx @@ -21,7 +21,9 @@ export const getMockServices = (overrides?: Partial) => { currentAppId$: from('mockedApp'), navigateToUrl: () => undefined, TagList, + getTagList: () => [], itemHasTags: () => true, + getTagManagementUrl: () => '', getTagIdsFromReferences: () => [], ...overrides, }; diff --git a/packages/content-management/table_list/src/actions.ts b/packages/content-management/table_list/src/actions.ts index 9eff5f445079d..ba706025b036a 100644 --- a/packages/content-management/table_list/src/actions.ts +++ b/packages/content-management/table_list/src/actions.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import type { IHttpFetchError } from '@kbn/core-http-browser'; -import type { CriteriaWithPagination, Direction } from '@elastic/eui'; +import type { CriteriaWithPagination, Direction, Query } from '@elastic/eui'; import type { SortColumnField } from './components'; @@ -71,7 +71,10 @@ export interface ShowConfirmDeleteItemsModalAction { /** Action to update the search bar query text */ export interface OnSearchQueryChangeAction { type: 'onSearchQueryChange'; - data: string; + data: { + query: Query; + text: string; + }; } export type Action = diff --git a/packages/content-management/table_list/src/components/index.ts b/packages/content-management/table_list/src/components/index.ts index 004222d7729d0..a4a09a5e6bbc6 100644 --- a/packages/content-management/table_list/src/components/index.ts +++ b/packages/content-management/table_list/src/components/index.ts @@ -12,5 +12,6 @@ export { ConfirmDeleteModal } from './confirm_delete_modal'; export { ListingLimitWarning } from './listing_limit_warning'; export { ItemDetails } from './item_details'; export { TableSortSelect } from './table_sort_select'; +export { TagFilterPanel } from './tag_filter_panel'; export type { SortColumnField } from './table_sort_select'; diff --git a/packages/content-management/table_list/src/components/item_details.tsx b/packages/content-management/table_list/src/components/item_details.tsx index 1d5c5a65902a1..ccfbb5e3ea55a 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -7,11 +7,13 @@ */ import React, { useCallback, useMemo } from 'react'; -import { EuiText, EuiLink, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiText, EuiLink, EuiTitle, EuiSpacer, EuiHighlight } from '@elastic/eui'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import type { Tag } from '../types'; import { useServices } from '../services'; import type { UserContentCommonSchema, Props as TableListViewProps } from '../table_list_view'; +import { TagBadge } from './tag_badge'; type InheritedProps = Pick< TableListViewProps, @@ -20,14 +22,15 @@ type InheritedProps = Pick< interface Props extends InheritedProps { item: T; searchTerm?: string; + onClickTag: (tag: Tag, isCtrlKey: boolean) => void; } /** * Copied from https://stackoverflow.com/a/9310752 */ -// const escapeRegExp = (text: string) => { -// return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); -// }; +const escapeRegExp = (text: string) => { + return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +}; export function ItemDetails({ id, @@ -35,6 +38,7 @@ export function ItemDetails({ searchTerm = '', getDetailViewLink, onClickTitle, + onClickTag, }: Props) { const { references, @@ -79,7 +83,9 @@ export function ItemDetails({ onClick={onClickTitleHandler} data-test-subj={`${id}ListingTitleLink-${item.attributes.title.split(' ').join('-')}`} > - {title} + + {title} + ); @@ -90,6 +96,7 @@ export function ItemDetails({ onClickTitle, onClickTitleHandler, redirectAppLinksCoreStart, + searchTerm, title, ]); @@ -100,13 +107,20 @@ export function ItemDetails({ {renderTitle()} {Boolean(description) && ( -

{description!}

+

+ + {description!} + +

)} {hasTags && ( <> - + } + /> )} diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 846fa087a8db8..1e4ee84204dd4 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -16,6 +16,8 @@ import { PropertySort, SearchFilterConfig, Direction, + Query, + Ast, } from '@elastic/eui'; import { useServices } from '../services'; @@ -26,6 +28,9 @@ import type { UserContentCommonSchema, } from '../table_list_view'; import { TableSortSelect } from './table_sort_select'; +import { TagFilterPanel } from './tag_filter_panel'; +import { useTagFilterPanel } from './use_tag_filter_panel'; +import type { Params as UseTagFilterPanelParams } from './use_tag_filter_panel'; import type { SortColumnField } from './table_sort_select'; type State = Pick< @@ -33,7 +38,12 @@ type State = Pick< 'items' | 'selectedIds' | 'searchQuery' | 'tableSort' | 'pagination' >; -interface Props extends State { +type TagManagementProps = Pick< + UseTagFilterPanelParams, + 'addOrRemoveIncludeTagFilter' | 'addOrRemoveExcludeTagFilter' | 'tagsToTableItemMap' +>; + +interface Props extends State, TagManagementProps { dispatch: Dispatch>; entityName: string; entityNamePlural: string; @@ -44,6 +54,7 @@ interface Props extends State { deleteItems: TableListViewProps['deleteItems']; onSortChange: (column: SortColumnField, direction: Direction) => void; onTableChange: (criteria: CriteriaWithPagination) => void; + clearTagSelection: () => void; } export function Table({ @@ -58,12 +69,16 @@ export function Table({ hasUpdatedAtMetadata, entityName, entityNamePlural, + tagsToTableItemMap, deleteItems, tableCaption, onTableChange, onSortChange, + addOrRemoveExcludeTagFilter, + addOrRemoveIncludeTagFilter, + clearTagSelection, }: Props) { - const { getSearchBarFilters } = useServices(); + const { getTagList } = useServices(); const renderToolsLeft = useCallback(() => { if (!deleteItems || selectedIds.length === 0) { @@ -97,8 +112,37 @@ export function Table({ } : undefined; - const searchFilters = useMemo(() => { - const tableSortSelectFilter: SearchFilterConfig = { + const { + isPopoverOpen, + isInUse, + closePopover, + onFilterButtonClick, + onSelectChange, + options, + totalActiveFilters, + } = useTagFilterPanel({ + query: searchQuery.query, + getTagList, + tagsToTableItemMap, + addOrRemoveExcludeTagFilter, + addOrRemoveIncludeTagFilter, + }); + + const onSearchQueryChange = useCallback( + (arg: { query: Query | null; queryText: string }) => { + dispatch({ + type: 'onSearchQueryChange', + data: { + query: arg.query ?? new Query(Ast.create([]), undefined, arg.queryText), + text: arg.queryText, + }, + }); + }, + [dispatch] + ); + + const tableSortSelectFilter = useMemo(() => { + return { type: 'custom_component', component: () => { return ( @@ -110,25 +154,53 @@ export function Table({ ); }, }; + }, [hasUpdatedAtMetadata, onSortChange, tableSort]); + + const tagFilterPanel = useMemo(() => { + return { + type: 'custom_component', + component: () => { + return ( + + ); + }, + }; + }, [ + isPopoverOpen, + isInUse, + closePopover, + options, + totalActiveFilters, + onFilterButtonClick, + onSelectChange, + clearTagSelection, + ]); - return getSearchBarFilters - ? [tableSortSelectFilter, ...getSearchBarFilters()] - : [tableSortSelectFilter]; - }, [onSortChange, hasUpdatedAtMetadata, tableSort, getSearchBarFilters]); + const searchFilters = useMemo(() => { + return [tableSortSelectFilter, tagFilterPanel]; + }, [tableSortSelectFilter, tagFilterPanel]); const search = useMemo(() => { return { - onChange: ({ queryText }: { queryText: string }) => - dispatch({ type: 'onSearchQueryChange', data: queryText }), + onChange: onSearchQueryChange, toolsLeft: renderToolsLeft(), - defaultQuery: searchQuery, + query: searchQuery.query ?? undefined, box: { incremental: true, 'data-test-subj': 'tableListSearchBox', }, filters: searchFilters, }; - }, [dispatch, renderToolsLeft, searchFilters, searchQuery]); + }, [onSearchQueryChange, renderToolsLeft, searchFilters, searchQuery.query]); const noItemsMessage = ( ({ message={noItemsMessage} selection={selection} search={search} + executeQueryOptions={{ enabled: false }} sorting={tableSort ? { sort: tableSort as PropertySort } : undefined} onChange={onTableChange} data-test-subj="itemsInMemTable" diff --git a/packages/content-management/table_list/src/components/tag_badge.tsx b/packages/content-management/table_list/src/components/tag_badge.tsx new file mode 100644 index 0000000000000..bfbd758884940 --- /dev/null +++ b/packages/content-management/table_list/src/components/tag_badge.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import type { Tag } from '../types'; + +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + +export interface Props { + tag: Tag; + onClick: (tag: Tag, withModifierKey: boolean) => void; +} + +/** + * The badge representation of a Tag, which is the default display to be used for them. + */ +export const TagBadge: FC = ({ tag, onClick }) => { + return ( + { + const withModifierKey = (isMac && e.metaKey) || (!isMac && e.ctrlKey); + onClick(tag, withModifierKey); + }} + onClickAriaLabel={i18n.translate('contentManagement.tableList.tagBadge.buttonLabel', { + defaultMessage: '{tagName} tag button.', + values: { + tagName: tag.name, + }, + })} + > + {tag.name} + + ); +}; diff --git a/packages/content-management/table_list/src/components/tag_filter_panel.tsx b/packages/content-management/table_list/src/components/tag_filter_panel.tsx new file mode 100644 index 0000000000000..03439f9dec161 --- /dev/null +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { FC } from 'react'; +import { + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiFilterButton, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButtonEmpty, + EuiTextColor, + EuiSpacer, + EuiLink, + useEuiTheme, + EuiPopoverFooter, + EuiButton, +} from '@elastic/eui'; +import type { EuiSelectableProps, ExclusiveUnion } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; + +import { useServices } from '../services'; +import type { TagOptionItem } from './use_tag_filter_panel'; + +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; +const modifierKeyPrefix = isMac ? '⌘' : '^'; + +const clearSelectionBtnCSS = css` + height: auto; +`; + +const saveBtnWrapperCSS = css` + width: 100%; +`; + +interface Props { + clearTagSelection: () => void; + closePopover: () => void; + isPopoverOpen: boolean; + isInUse: boolean; + options: TagOptionItem[]; + totalActiveFilters: number; + onFilterButtonClick: () => void; + onSelectChange: (updatedOptions: TagOptionItem[]) => void; +} + +export const TagFilterPanel: FC = ({ + isPopoverOpen, + isInUse, + options, + totalActiveFilters, + onFilterButtonClick, + onSelectChange, + closePopover, + clearTagSelection, +}) => { + const { euiTheme } = useEuiTheme(); + const { navigateToUrl, currentAppId$, getTagManagementUrl } = useServices(); + const isSearchVisible = options.length > 10; + + const searchBoxCSS = css` + padding: ${euiTheme.size.s}; + border-bottom: ${euiTheme.border.thin}; + `; + + const popoverTitleCSS = css` + height: ${euiTheme.size.xxxl}; + `; + + let searchProps: ExclusiveUnion< + { searchable: false }, + { + searchable: true; + searchProps: EuiSelectableProps['searchProps']; + } + > = { + searchable: false, + }; + + if (isSearchVisible) { + searchProps = { + searchable: true, + searchProps: { + compressed: true, + }, + }; + } + + return ( + <> + 0} + numActiveFilters={totalActiveFilters} + grow + > + Tags + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downCenter" + panelClassName="euiFilterGroup__popoverPanel" + panelStyle={isInUse ? { transition: 'none' } : undefined} + > + + + Tags + + {totalActiveFilters > 0 && ( + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.clearSelectionButtonLabelLabel', + { + defaultMessage: 'Clear selection', + } + )} + + )} + + + + + singleSelection={false} + aria-label="some aria label" + options={options} + renderOption={(option) => option.view} + emptyMessage="There aren't any tags" + noMatchesMessage="No tag matches the search" + onChange={onSelectChange} + data-test-subj="tagSelectableList" + {...searchProps} + > + {(list, search) => { + return ( + <> + {isSearchVisible ?
{search}
: } + {list} + + ); + }} + + + + + + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.modifierKeyHelpText', + { + defaultMessage: '{modifierKeyPrefix} + click exclude', + values: { + modifierKeyPrefix, + }, + } + )} + + + + + + Save + + + + + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.manageAllTagsLinkLabel', + { + defaultMessage: 'Manage tags', + } + )} + + + + + +
+ + ); +}; diff --git a/packages/content-management/table_list/src/components/use_tag_filter_panel.tsx b/packages/content-management/table_list/src/components/use_tag_filter_panel.tsx new file mode 100644 index 0000000000000..ca7aab6f8bb08 --- /dev/null +++ b/packages/content-management/table_list/src/components/use_tag_filter_panel.tsx @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { useEffect, useState, useCallback } from 'react'; +import type { MouseEvent } from 'react'; +import { Query, EuiFlexGroup, EuiFlexItem, EuiText, EuiHealth, EuiBadge } from '@elastic/eui'; +import type { FieldValueOptionType } from '@elastic/eui'; + +import type { Tag } from '../types'; + +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + +const toArray = (item: unknown) => (Array.isArray(item) ? item : [item]); + +const testSubjFriendly = (name: string) => { + return name.replace(' ', '_'); +}; + +export interface TagSelection { + [tagId: string]: 'include' | 'exclude' | undefined; +} + +export interface TagOptionItem extends FieldValueOptionType { + label: string; + checked?: 'on' | 'off'; + tag: Tag; +} + +export interface Params { + query: Query | null; + tagsToTableItemMap: { [tagId: string]: string[] }; + getTagList: () => Tag[]; + addOrRemoveIncludeTagFilter: (tag: Tag) => void; + addOrRemoveExcludeTagFilter: (tag: Tag) => void; +} + +export const useTagFilterPanel = ({ + query, + tagsToTableItemMap, + getTagList, + addOrRemoveExcludeTagFilter, + addOrRemoveIncludeTagFilter, +}: Params) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + // When the panel is "in use" it means that it is opened and the user is interacting with it. + // When the user clicks on a tag to select it, the component is unmounted and mounted immediately, which + // creates a new EUI transition "IN" which makes the UI "flicker". To avoid that we pass this + // "isInUse" state which disable the transition. + const [isInUse, setIsInUse] = useState(false); + const [options, setOptions] = useState([]); + const [tagSelection, setTagSelection] = useState({}); + const totalActiveFilters = Object.keys(tagSelection).length; + + const onSelectChange = useCallback( + (updatedOptions: TagOptionItem[]) => { + // Note: see data flow comment in useEffect() below + const diff = updatedOptions.find((item, index) => item.checked !== options[index].checked); + if (diff) { + addOrRemoveIncludeTagFilter(diff.tag); + } + }, + [options, addOrRemoveIncludeTagFilter] + ); + + const onOptionClick = useCallback( + (tag: Tag) => (e: MouseEvent) => { + const withModifierKey = (isMac && e.metaKey) || (!isMac && e.ctrlKey); + + if (withModifierKey) { + addOrRemoveExcludeTagFilter(tag); + } else { + addOrRemoveIncludeTagFilter(tag); + } + }, + [addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter] + ); + + const updateTagList = useCallback(() => { + const tags = getTagList(); + + const tagsToSelectOptions = tags.map((tag) => { + const { name, id, color } = tag; + let checked: 'on' | 'off' | undefined; + + if (tagSelection[name]) { + checked = tagSelection[name] === 'include' ? 'on' : 'off'; + } + + return { + name, + label: name, + value: id ?? '', + tag, + checked, + view: ( + + + + {name} + + + + + {tagsToTableItemMap[id ?? '']?.length ?? 0} + + + + ), + }; + }); + + setOptions(tagsToSelectOptions); + }, [getTagList, tagsToTableItemMap, tagSelection, onOptionClick]); + + const onFilterButtonClick = useCallback(() => { + setIsPopoverOpen((prev) => !prev); + }, []); + + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + useEffect(() => { + /** + * Data flow for tag filter panel state: + * When we click (or Ctrl + click) on a tag in the filter panel: + * 1. The "onSelectChange()" handler is called + * 2. It updates the Query in the parent component + * 3. Which in turns update the search bar + * 4. We receive the updated query back here + * 5. The useEffect() executes and we check which tag is "included" or "excluded" + * 6. We update the "tagSelection" state + * 7. Which updates the "options" state (which is then passed to the stateless ) + */ + if (query) { + const clauseInclude = query.ast.getOrFieldClause('tag', undefined, true, 'eq'); + const clauseExclude = query.ast.getOrFieldClause('tag', undefined, false, 'eq'); + + const updatedTagSelection: TagSelection = {}; + + if (clauseInclude) { + toArray(clauseInclude.value).forEach((tagName) => { + updatedTagSelection[tagName] = 'include'; + }); + } + + if (clauseExclude) { + toArray(clauseExclude.value).forEach((tagName) => { + updatedTagSelection[tagName] = 'exclude'; + }); + } + + setTagSelection(updatedTagSelection); + } + }, [query]); + + useEffect(() => { + if (isPopoverOpen) { + // Refresh the tag list whenever we open the pop over + updateTagList(); + + // To avoid "cutting" the inflight css transition when opening the popover + // we add a slight delay to switch the "isInUse" flag. + setTimeout(() => { + setIsInUse(true); + }, 250); + } else { + setIsInUse(false); + } + }, [isPopoverOpen, updateTagList]); + + return { + isPopoverOpen, + isInUse, + options, + totalActiveFilters, + onFilterButtonClick, + onSelectChange, + closePopover, + }; +}; diff --git a/packages/content-management/table_list/src/constants.ts b/packages/content-management/table_list/src/constants.ts new file mode 100644 index 0000000000000..d8afaa75d4d94 --- /dev/null +++ b/packages/content-management/table_list/src/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const TAG_MANAGEMENT_APP_URL = '/app/management/kibana/tags'; diff --git a/packages/content-management/table_list/src/mocks.tsx b/packages/content-management/table_list/src/mocks.tsx index e2105bb9f7831..ff398d7b131be 100644 --- a/packages/content-management/table_list/src/mocks.tsx +++ b/packages/content-management/table_list/src/mocks.tsx @@ -7,9 +7,8 @@ */ import React from 'react'; import { from } from 'rxjs'; -import { EuiBadgeGroup, EuiBadge } from '@elastic/eui'; -import { Services } from './services'; +import type { Services, TagListProps } from './services'; /** * Parameters drawn from the Storybook arguments collection that customize a component story. @@ -17,56 +16,42 @@ import { Services } from './services'; export type Params = Record, any>; type ActionFn = (name: string) => any; -const tags = [ - { - name: 'elastic', - color: '#8dc4de', - description: 'elastic tag', - }, - { - name: 'cloud', - color: '#f5ed14', - description: 'cloud tag', - }, -]; - -interface Props { - onClick?: (tag: { name: string }) => void; - tags?: typeof tags | null; -} - -export const TagList = ({ onClick, tags: _tags = tags }: Props) => { - if (_tags === null) { +export const TagList = ({ onClick, references, tagRender }: TagListProps) => { + if (references.length === 0) { return null; } return ( - - {_tags.map((tag) => ( - { - if (onClick) { - onClick(tag); - } - }} - onClickAriaLabel="tag button" - iconOnClick={() => undefined} - iconOnClickAriaLabel="" - color={tag.color} - title={tag.description} - > - {tag.name} - - ))} - +
+ {references.map((ref) => { + const tag = { ...ref, color: 'blue', description: '' }; + + if (tagRender) { + return tagRender(tag); + } + + return ( + + ); + })} +
); }; export const getTagList = - ({ tags: _tags }: Props = {}) => - ({ onClick }: Props) => { - return ; + ({ references: _tags }: TagListProps = { references: [] }) => + ({ onClick }: TagListProps) => { + return ; }; /** @@ -82,7 +67,9 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) => currentAppId$: from('mockedApp'), navigateToUrl: () => undefined, TagList, + getTagList: () => [], itemHasTags: () => true, + getTagManagementUrl: () => '', getTagIdsFromReferences: () => [], ...params, }; diff --git a/packages/content-management/table_list/src/reducer.tsx b/packages/content-management/table_list/src/reducer.tsx index c90cb4c883957..2c82d37fc496e 100644 --- a/packages/content-management/table_list/src/reducer.tsx +++ b/packages/content-management/table_list/src/reducer.tsx @@ -5,8 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { sortBy } from 'lodash'; - import type { State, UserContentCommonSchema } from './table_list_view'; import type { Action } from './actions'; @@ -40,7 +38,7 @@ export function getReducer() { ...state, hasInitialFetchReturned: true, isFetchingItems: false, - items: !state.searchQuery ? sortBy(items, 'title') : items, + items, totalItems: action.data.response.total, hasUpdatedAtMetadata, tableSort: tableSort ?? state.tableSort, diff --git a/packages/content-management/table_list/src/services.tsx b/packages/content-management/table_list/src/services.tsx index f450190ce9d0a..3ad0acd5f93bd 100644 --- a/packages/content-management/table_list/src/services.tsx +++ b/packages/content-management/table_list/src/services.tsx @@ -7,7 +7,6 @@ */ import React, { FC, useContext, useMemo, useCallback } from 'react'; -import type { SearchFilterConfig } from '@elastic/eui'; import type { Observable } from 'rxjs'; import type { FormattedRelative } from '@kbn/i18n-react'; import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser'; @@ -15,6 +14,9 @@ import type { OverlayFlyoutOpenOptions } from '@kbn/core-overlays-browser'; import { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app'; import { InspectorKibanaProvider } from '@kbn/content-management-inspector'; +import { TAG_MANAGEMENT_APP_URL } from './constants'; +import type { Tag } from './types'; + type NotifyFn = (title: JSX.Element, text?: string) => void; export interface SavedObjectsReference { @@ -30,6 +32,12 @@ export type DateFormatter = (props: { children: (formattedDate: string) => JSX.Element; }) => JSX.Element; +export interface TagListProps { + references: SavedObjectsReference[]; + onClick?: (tag: Tag) => void; + tagRender?: (tag: Tag) => JSX.Element; +} + /** * Abstract external services for this component. */ @@ -42,12 +50,16 @@ export interface Services { searchQueryParser?: (searchQuery: string) => { searchQuery: string; references?: SavedObjectsFindOptionsReference[]; + referencesToExclude?: SavedObjectsFindOptionsReference[]; }; - getSearchBarFilters?: () => SearchFilterConfig[]; DateFormatterComp?: DateFormatter; - TagList: FC<{ references: SavedObjectsReference[]; onClick?: (tag: { name: string }) => void }>; - /** Predicate function to indicate if the saved object references include tags */ + /** Handler to retrieve the list of available tags */ + getTagList: () => Tag[]; + TagList: FC; + /** Predicate function to indicate if some of the saved object references are tags */ itemHasTags: (references: SavedObjectsReference[]) => boolean; + /** Handler to return the url to navigate to the kibana tags management */ + getTagManagementUrl: () => string; getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[]; } @@ -81,6 +93,11 @@ export interface TableListViewKibanaDependencies { addDanger: (notifyArgs: { title: MountPoint; text?: string }) => void; }; }; + http: { + basePath: { + prepend: (path: string) => string; + }; + }; overlays: { openFlyout(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef; }; @@ -111,7 +128,8 @@ export interface TableListViewKibanaDependencies { object: { references: SavedObjectsReference[]; }; - onClick?: (tag: { name: string; description: string; color: string }) => void; + onClick?: (tag: Tag) => void; + tagRender?: (tag: Tag) => JSX.Element; }>; SavedObjectSaveModalTagSelector: React.FC<{ initialSelection: string[]; @@ -127,12 +145,10 @@ export interface TableListViewKibanaDependencies { ) => { searchTerm: string; tagReferences: SavedObjectsFindOptionsReference[]; + tagReferencesToExclude: SavedObjectsFindOptionsReference[]; valid: boolean; }; - getSearchBarFilter: (options?: { - useName?: boolean; - tagField?: string; - }) => SearchFilterConfig; + getTagList: () => Tag[]; getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[]; }; }; @@ -149,12 +165,6 @@ export const TableListViewKibanaProvider: FC = }) => { const { core, toMountPoint, savedObjectsTagging, FormattedRelative } = services; - const getSearchBarFilters = useMemo(() => { - if (savedObjectsTagging) { - return () => [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })]; - } - }, [savedObjectsTagging]); - const searchQueryParser = useMemo(() => { if (savedObjectsTagging) { return (searchQuery: string) => { @@ -162,18 +172,19 @@ export const TableListViewKibanaProvider: FC = return { searchQuery: res.searchTerm, references: res.tagReferences, + referencesToExclude: res.tagReferencesToExclude, }; }; } }, [savedObjectsTagging]); const TagList = useMemo(() => { - const Comp: Services['TagList'] = ({ references, onClick }) => { + const Comp: Services['TagList'] = ({ references, onClick, tagRender }) => { if (!savedObjectsTagging?.ui.components.TagList) { return null; } const PluginTagList = savedObjectsTagging.ui.components.TagList; - return ; + return ; }; return Comp; @@ -190,6 +201,14 @@ export const TableListViewKibanaProvider: FC = [savedObjectsTagging?.ui] ); + const getTagList = useCallback(() => { + if (!savedObjectsTagging?.ui.getTagList) { + return []; + } + + return savedObjectsTagging.ui.getTagList(); + }, [savedObjectsTagging?.ui]); + const itemHasTags = useCallback( (references: SavedObjectsReference[]) => { return getTagIdsFromReferences(references).length > 0; @@ -214,14 +233,15 @@ export const TableListViewKibanaProvider: FC = notifyError={(title, text) => { core.notifications.toasts.addDanger({ title: toMountPoint(title), text }); }} - getSearchBarFilters={getSearchBarFilters} searchQueryParser={searchQueryParser} DateFormatterComp={(props) => } currentAppId$={core.application.currentAppId$} navigateToUrl={core.application.navigateToUrl} + getTagList={getTagList} TagList={TagList} itemHasTags={itemHasTags} getTagIdsFromReferences={getTagIdsFromReferences} + getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)} > {children} diff --git a/packages/content-management/table_list/src/table_list_view.stories.tsx b/packages/content-management/table_list/src/table_list_view.stories.tsx index 7b197c0fa1b5b..4943c9d0be657 100644 --- a/packages/content-management/table_list/src/table_list_view.stories.tsx +++ b/packages/content-management/table_list/src/table_list_view.stories.tsx @@ -52,21 +52,28 @@ const itemTypes = ['foo', 'bar', 'baz', 'elastic']; const mockItems: UserContentCommonSchema[] = createMockItems(500); export const ConnectedComponent = (params: Params) => { + const findItems = (searchQuery: string) => { + const hits = mockItems + .filter((_, i) => i < params.numberOfItemsToRender) + .filter((item) => { + return ( + item.attributes.title.includes(searchQuery) || + item.attributes.description?.includes(searchQuery) + ); + }); + + return Promise.resolve({ + total: hits.length, + hits, + }); + }; + return ( { - const hits = mockItems - .filter((_, i) => i < params.numberOfItemsToRender) - .filter((item) => item.attributes.title.includes(searchQuery)); - - return Promise.resolve({ - total: hits.length, - hits, - }); - }} + findItems={findItems} getDetailViewLink={() => 'http://elastic.co'} createItem={ params.canCreateItem diff --git a/packages/content-management/table_list/src/table_list_view.test.tsx b/packages/content-management/table_list/src/table_list_view.test.tsx index e8b452ececfae..92e1ddaa45cc0 100644 --- a/packages/content-management/table_list/src/table_list_view.test.tsx +++ b/packages/content-management/table_list/src/table_list_view.test.tsx @@ -15,7 +15,11 @@ import type { ReactWrapper } from 'enzyme'; import { WithServices } from './__jest__'; import { getTagList } from './mocks'; -import { TableListView, Props as TableListViewProps } from './table_list_view'; +import { + TableListView, + Props as TableListViewProps, + UserContentCommonSchema, +} from './table_list_view'; const mockUseEffect = useEffect; @@ -115,23 +119,27 @@ describe('TableListView', () => { const twoDaysAgoToString = new Date(twoDaysAgo.getTime()).toDateString(); const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)); const yesterdayToString = new Date(yesterday.getTime()).toDateString(); - const hits = [ + const hits: UserContentCommonSchema[] = [ { id: '123', - updatedAt: twoDaysAgo, + updatedAt: twoDaysAgo.toISOString(), + type: 'dashboard', attributes: { title: 'Item 1', description: 'Item 1 description', }, + references: [], }, { id: '456', // This is the latest updated and should come first in the table - updatedAt: yesterday, + updatedAt: yesterday.toISOString(), + type: 'dashboard', attributes: { title: 'Item 2', description: 'Item 2 description', }, + references: [], }, ]; @@ -150,8 +158,8 @@ describe('TableListView', () => { const { tableCellsValues } = table.getMetaData('itemsInMemTable'); expect(tableCellsValues).toEqual([ - ['Item 2Item 2 descriptionelasticcloud', yesterdayToString], // Comes first as it is the latest updated - ['Item 1Item 1 descriptionelasticcloud', twoDaysAgoToString], + ['Item 2Item 2 description', yesterdayToString], // Comes first as it is the latest updated + ['Item 1Item 1 description', twoDaysAgoToString], ]); }); @@ -160,7 +168,7 @@ describe('TableListView', () => { const updatedAtValues: Moment[] = []; - const updatedHits = hits.map(({ id, attributes }, i) => { + const updatedHits = hits.map(({ id, attributes, references }, i) => { const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i))); updatedAtValues.push(moment(updatedAt)); @@ -168,6 +176,7 @@ describe('TableListView', () => { id, updatedAt, attributes, + references, }; }); @@ -187,8 +196,8 @@ describe('TableListView', () => { expect(tableCellsValues).toEqual([ // Renders the datetime with this format: "July 28, 2022" - ['Item 1Item 1 descriptionelasticcloud', updatedAtValues[0].format('LL')], - ['Item 2Item 2 descriptionelasticcloud', updatedAtValues[1].format('LL')], + ['Item 1Item 1 description', updatedAtValues[0].format('LL')], + ['Item 2Item 2 description', updatedAtValues[1].format('LL')], ]); }); @@ -200,7 +209,7 @@ describe('TableListView', () => { findItems: jest.fn().mockResolvedValue({ total: hits.length, // Not including the "updatedAt" metadata - hits: hits.map(({ attributes }) => ({ attributes })), + hits: hits.map(({ attributes, references }) => ({ attributes, references })), }), }); }); @@ -211,8 +220,8 @@ describe('TableListView', () => { const { tableCellsValues } = table.getMetaData('itemsInMemTable'); expect(tableCellsValues).toEqual([ - ['Item 1Item 1 descriptionelasticcloud'], // Sorted by title - ['Item 2Item 2 descriptionelasticcloud'], + ['Item 1Item 1 description'], // Sorted by title + ['Item 2Item 2 description'], ]); }); @@ -225,7 +234,11 @@ describe('TableListView', () => { total: hits.length + 1, hits: [ ...hits, - { id: '789', attributes: { title: 'Item 3', description: 'Item 3 description' } }, + { + id: '789', + attributes: { title: 'Item 3', description: 'Item 3 description' }, + references: [], + }, ], }), }); @@ -237,9 +250,9 @@ describe('TableListView', () => { const { tableCellsValues } = table.getMetaData('itemsInMemTable'); expect(tableCellsValues).toEqual([ - ['Item 2Item 2 descriptionelasticcloud', yesterdayToString], - ['Item 1Item 1 descriptionelasticcloud', twoDaysAgoToString], - ['Item 3Item 3 descriptionelasticcloud', '-'], // Empty column as no updatedAt provided + ['Item 2Item 2 description', yesterdayToString], + ['Item 1Item 1 description', twoDaysAgoToString], + ['Item 3Item 3 description', '-'], // Empty column as no updatedAt provided ]); }); }); @@ -248,10 +261,14 @@ describe('TableListView', () => { const initialPageSize = 20; const totalItems = 30; - const hits = [...Array(totalItems)].map((_, i) => ({ + const hits: UserContentCommonSchema[] = [...Array(totalItems)].map((_, i) => ({ + id: `item${i}`, + type: 'dashboard', + updatedAt: new Date().toISOString(), attributes: { title: `Item ${i < 10 ? `0${i}` : i}`, // prefix with "0" for correct A-Z sorting }, + references: [], })); const props = { @@ -275,8 +292,8 @@ describe('TableListView', () => { const [[firstRowTitle]] = tableCellsValues; const [lastRowTitle] = tableCellsValues[tableCellsValues.length - 1]; - expect(firstRowTitle).toBe('Item 00elasticcloud'); - expect(lastRowTitle).toBe('Item 19elasticcloud'); + expect(firstRowTitle).toBe('Item 00'); + expect(lastRowTitle).toBe('Item 19'); }); test('should navigate to page 2', async () => { @@ -304,38 +321,48 @@ describe('TableListView', () => { const [[firstRowTitle]] = tableCellsValues; const [lastRowTitle] = tableCellsValues[tableCellsValues.length - 1]; - expect(firstRowTitle).toBe('Item 20elasticcloud'); - expect(lastRowTitle).toBe('Item 29elasticcloud'); + expect(firstRowTitle).toBe('Item 20'); + expect(lastRowTitle).toBe('Item 29'); }); }); describe('column sorting', () => { const setupColumnSorting = registerTestBed( - WithServices(TableListView, { TagList: getTagList({ tags: null }) }), + WithServices(TableListView, { TagList: getTagList({ references: [] }) }), { defaultProps: { ...requiredProps }, memoryRouter: { wrapComponent: false }, } ); + const getActions = (testBed: TestBed) => ({ + openSortSelect() { + testBed.find('tableSortSelectBtn').at(0).simulate('click'); + }, + }); + const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2)); const twoDaysAgoToString = new Date(twoDaysAgo.getTime()).toDateString(); const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)); const yesterdayToString = new Date(yesterday.getTime()).toDateString(); - const hits = [ + const hits: UserContentCommonSchema[] = [ { id: '123', - updatedAt: twoDaysAgo, // first asc, last desc + updatedAt: twoDaysAgo.toISOString(), // first asc, last desc + type: 'dashboard', attributes: { title: 'z-foo', // first desc, last asc }, + references: [{ id: 'id-tag-1', name: 'tag-1', type: 'tag' }], }, { id: '456', - updatedAt: yesterday, // first desc, last asc + updatedAt: yesterday.toISOString(), // first desc, last asc + type: 'dashboard', attributes: { title: 'a-foo', // first asc, last desc }, + references: [], }, ]; @@ -367,11 +394,12 @@ describe('TableListView', () => { findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }), }); }); + const { openSortSelect } = getActions(testBed!); const { component, find } = testBed!; component.update(); act(() => { - find('tableSortSelectBtn').simulate('click'); + openSortSelect(); }); component.update(); @@ -396,6 +424,7 @@ describe('TableListView', () => { }); const { component, table, find } = testBed!; + const { openSortSelect } = getActions(testBed!); component.update(); let { tableCellsValues } = table.getMetaData('itemsInMemTable'); @@ -406,7 +435,7 @@ describe('TableListView', () => { ]); act(() => { - find('tableSortSelectBtn').simulate('click'); + openSortSelect(); }); component.update(); const filterOptions = find('sortSelect').find('li'); @@ -451,10 +480,11 @@ describe('TableListView', () => { }); const { component, table, find } = testBed!; + const { openSortSelect } = getActions(testBed!); component.update(); act(() => { - find('tableSortSelectBtn').simulate('click'); + openSortSelect(); }); component.update(); let filterOptions = find('sortSelect').find('li'); @@ -493,7 +523,7 @@ describe('TableListView', () => { ]); act(() => { - find('tableSortSelectBtn').simulate('click'); + openSortSelect(); }); component.update(); filterOptions = find('sortSelect').find('li'); @@ -516,22 +546,26 @@ describe('TableListView', () => { } ); - const hits = [ + const hits: UserContentCommonSchema[] = [ { id: '123', - updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)), + updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(), attributes: { title: 'Item 1', description: 'Item 1 description', }, + references: [], + type: 'dashboard', }, { id: '456', - updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)), + updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(), attributes: { title: 'Item 2', description: 'Item 2 description', }, + references: [], + type: 'dashboard', }, ]; @@ -553,4 +587,154 @@ describe('TableListView', () => { expect(tableCellsValues[1][2]).toBe('Inspect Item 2'); }); }); + + describe('tag filtering', () => { + const setupTagFiltering = registerTestBed( + WithServices(TableListView, { + getTagList: () => [ + { id: 'id-tag-1', name: 'tag-1', type: 'tag', description: '', color: '' }, + { id: 'id-tag-2', name: 'tag-2', type: 'tag', description: '', color: '' }, + { id: 'id-tag-3', name: 'tag-3', type: 'tag', description: '', color: '' }, + { id: 'id-tag-4', name: 'tag-4', type: 'tag', description: '', color: '' }, + ], + }), + { + defaultProps: { ...requiredProps }, + memoryRouter: { wrapComponent: false }, + } + ); + + const hits: UserContentCommonSchema[] = [ + { + id: '123', + updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(), + type: 'dashboard', + attributes: { + title: 'Item 1', + description: 'Item 1 description', + }, + references: [ + { id: 'id-tag-1', name: 'tag-1', type: 'tag' }, + { id: 'id-tag-2', name: 'tag-2', type: 'tag' }, + ], + }, + { + id: '456', + updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(), + type: 'dashboard', + attributes: { + title: 'Item 2', + description: 'Item 2 description', + }, + references: [], + }, + ]; + + test('should filter by tag from the table', async () => { + let testBed: TestBed; + + const findItems = jest.fn().mockResolvedValue({ total: hits.length, hits }); + + await act(async () => { + testBed = await setupTagFiltering({ + findItems, + }); + }); + + const { component, table, find } = testBed!; + component.update(); + + const getSearchBoxValue = () => find('tableListSearchBox').props().defaultValue; + + const getLastCallArgsFromFindItems = () => + findItems.mock.calls[findItems.mock.calls.length - 1]; + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + // "tag-1" and "tag-2" are rendered in the column + expect(tableCellsValues[0][0]).toBe('Item 1Item 1 descriptiontag-1tag-2'); + + await act(async () => { + find('tag-id-tag-1').simulate('click'); + }); + component.update(); + + // The search bar should be updated + let expected = 'tag:(tag-1)'; + let [searchTerm] = getLastCallArgsFromFindItems(); + expect(getSearchBoxValue()).toBe(expected); + expect(searchTerm).toBe(expected); + + await act(async () => { + find('tag-id-tag-2').simulate('click'); + }); + component.update(); + + expected = 'tag:(tag-1 or tag-2)'; + [searchTerm] = getLastCallArgsFromFindItems(); + expect(getSearchBoxValue()).toBe(expected); + expect(searchTerm).toBe(expected); + + // Ctrl + click on a tag + await act(async () => { + find('tag-id-tag-2').simulate('click', { ctrlKey: true }); + }); + component.update(); + + expected = 'tag:(tag-1) -tag:(tag-2)'; + [searchTerm] = getLastCallArgsFromFindItems(); + expect(getSearchBoxValue()).toBe(expected); + expect(searchTerm).toBe(expected); + }); + + test('should filter by tag from the search bar filter', async () => { + let testBed: TestBed; + const findItems = jest.fn().mockResolvedValue({ total: hits.length, hits }); + + await act(async () => { + testBed = await setupTagFiltering({ + findItems, + }); + }); + + const { component, find, exists } = testBed!; + component.update(); + + const getSearchBoxValue = () => find('tableListSearchBox').props().defaultValue; + + const getLastCallArgsFromFindItems = () => + findItems.mock.calls[findItems.mock.calls.length - 1]; + + const openTagFilterDropdown = async () => { + await act(async () => { + find('tagFilterPopoverButton').simulate('click'); + }); + component.update(); + }; + + await openTagFilterDropdown(); + + expect(exists('tagSelectableList')).toBe(true); + await act(async () => { + find('tag-searchbar-option-tag-1').simulate('click'); + }); + component.update(); + + // The search bar should be updated and search term sent to the findItems() handler + let expected = 'tag:(tag-1)'; + let [searchTerm] = getLastCallArgsFromFindItems(); + expect(getSearchBoxValue()).toBe(expected); + expect(searchTerm).toBe(expected); + + // Ctrl + click one item + await act(async () => { + find('tag-searchbar-option-tag-2').simulate('click', { ctrlKey: true }); + }); + component.update(); + + expected = 'tag:(tag-1) -tag:(tag-2)'; + [searchTerm] = getLastCallArgsFromFindItems(); + expect(getSearchBoxValue()).toBe(expected); + expect(searchTerm).toBe(expected); + }); + }); }); diff --git a/packages/content-management/table_list/src/table_list_view.tsx b/packages/content-management/table_list/src/table_list_view.tsx index bcc68b27b5424..a42bc4cb9a1c7 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -18,6 +18,8 @@ import { EuiSpacer, EuiTableActionsColumnType, CriteriaWithPagination, + Query, + Ast, } from '@elastic/eui'; import { keyBy, uniq, get } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -39,6 +41,7 @@ import type { SavedObjectsReference, SavedObjectsFindOptionsReference } from './ import type { Action } from './actions'; import { getReducer } from './reducer'; import type { SortColumnField } from './components'; +import { useTags } from './use_tags'; interface InspectorConfig extends Pick { enabled?: boolean; @@ -49,7 +52,7 @@ export interface Props; /** Handler to set the item title "href" value. If it returns undefined there won't be a link for this item. */ getDetailViewLink?: (entity: T) => string | undefined; @@ -83,7 +89,10 @@ export interface State; - searchQuery: string; + searchQuery: { + text: string; + query: Query; + }; selectedIds: string[]; totalItems: number; hasUpdatedAtMetadata: boolean; @@ -105,6 +114,8 @@ export interface UserContentCommonSchema { }; } +const ast = Ast.create([]); + function TableListViewComp({ tableListTitle, entityName, @@ -170,7 +181,10 @@ function TableListViewComp({ showDeleteModal: false, hasUpdatedAtMetadata: false, selectedIds: [], - searchQuery: initialQuery, + searchQuery: + initialQuery !== undefined + ? { text: initialQuery, query: new Query(ast, undefined, initialQuery) } + : { text: '', query: new Query(ast, undefined, '') }, pagination: { pageIndex: 0, totalItemCount: 0, @@ -197,11 +211,31 @@ function TableListViewComp({ pagination, tableSort, } = state; - const hasNoItems = !isFetchingItems && items.length === 0 && !searchQuery; + + const hasQuery = searchQuery.text !== ''; + const hasNoItems = !isFetchingItems && items.length === 0 && !hasQuery; const pageDataTestSubject = `${entityName}LandingPage`; const showFetchError = Boolean(fetchError); const showLimitError = !showFetchError && totalItems > listingLimit; + const updateQuery = useCallback((query: Query) => { + dispatch({ + type: 'onSearchQueryChange', + data: { query, text: query.text }, + }); + }, []); + + const { + addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, + clearTagSelection, + tagsToTableItemMap, + } = useTags({ + query: searchQuery.query, + updateQuery, + items, + }); + const inspectItem = useCallback( (item: T) => { const tags = getTagIdsFromReferences(item.references).map((_id) => { @@ -237,7 +271,14 @@ function TableListViewComp({ item={record} getDetailViewLink={getDetailViewLink} onClickTitle={onClickTitle} - searchTerm={searchQuery} + onClickTag={(tag, withModifierKey) => { + if (withModifierKey) { + addOrRemoveExcludeTagFilter(tag); + } else { + addOrRemoveIncludeTagFilter(tag); + } + }} + searchTerm={searchQuery.text} /> ); }, @@ -328,7 +369,9 @@ function TableListViewComp({ id, getDetailViewLink, onClickTitle, - searchQuery, + searchQuery.text, + addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, DateFormatterComp, inspector, inspectItem, @@ -351,11 +394,15 @@ function TableListViewComp({ try { const idx = ++fetchIdx.current; - const { searchQuery: searchQueryParsed, references } = searchQueryParser - ? searchQueryParser(searchQuery) - : { searchQuery, references: undefined }; + const { + searchQuery: searchQueryParsed, + references, + referencesToExclude, + } = searchQueryParser + ? searchQueryParser(searchQuery.text) + : { searchQuery: searchQuery.text, references: undefined, referencesToExclude: undefined }; - const response = await findItems(searchQueryParsed, references); + const response = await findItems(searchQueryParsed, { references, referencesToExclude }); if (!isMounted.current) { return; @@ -504,7 +551,7 @@ function TableListViewComp({ return null; } - if (!fetchError && hasNoItems) { + if (!showFetchError && hasNoItems) { return ( ({ selectedIds={selectedIds} entityName={entityName} entityNamePlural={entityNamePlural} + tagsToTableItemMap={tagsToTableItemMap} deleteItems={deleteItems} tableCaption={tableListTitle} onTableChange={onTableChange} onSortChange={onSortChange} + addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter} + addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter} + clearTagSelection={clearTagSelection} /> {/* Delete modal */} diff --git a/packages/content-management/table_list/src/types.ts b/packages/content-management/table_list/src/types.ts new file mode 100644 index 0000000000000..0e716e6d59cf3 --- /dev/null +++ b/packages/content-management/table_list/src/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface Tag { + id?: string; + name: string; + description: string; + color: string; +} diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts new file mode 100644 index 0000000000000..c72f550bc54b3 --- /dev/null +++ b/packages/content-management/table_list/src/use_tags.ts @@ -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 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 { useCallback, useMemo } from 'react'; +import { Query } from '@elastic/eui'; + +import type { Tag } from './types'; +import type { UserContentCommonSchema } from './table_list_view'; + +type QueryUpdater = (query: Query, tag: Tag) => Query; + +export function useTags({ + query, + updateQuery, + items, +}: { + query: Query; + updateQuery: (query: Query) => void; + items: UserContentCommonSchema[]; +}) { + // Return a map of tag.id to an array of saved object ids having that tag + // { 'abc-123': ['saved_object_id_1', 'saved_object_id_2', ...] } + const tagsToTableItemMap = useMemo(() => { + return items.reduce((acc, item) => { + const tagReferences = item.references.filter((ref) => ref.type === 'tag'); + + if (tagReferences.length > 0) { + tagReferences.forEach((ref) => { + if (!acc[ref.id]) { + acc[ref.id] = []; + } + acc[ref.id].push(item.id); + }); + } + + return acc; + }, {} as { [tagId: string]: string[] }); + }, [items]); + + const updateTagClauseGetter = useCallback( + (queryUpdater: QueryUpdater) => + (tag: Tag, q?: Query, doUpdate: boolean = true) => { + const updatedQuery = queryUpdater(q !== undefined ? q : query, tag); + if (doUpdate) { + updateQuery(updatedQuery); + } + return updatedQuery; + }, + [query, updateQuery] + ); + + const hasTagInClauseGetter = useCallback( + (matchValue: 'must' | 'must_not') => (tag: Tag, _query?: Query) => { + const q = Boolean(_query) ? _query! : query; + const tagsClauses = q.ast.getFieldClauses('tag'); + + if (tagsClauses) { + const mustHaveTagClauses = q.ast + .getFieldClauses('tag') + .find(({ match }) => match === matchValue)?.value as string[]; + + if (mustHaveTagClauses && mustHaveTagClauses.includes(tag.name)) { + return true; + } + } + return false; + }, + [query] + ); + + const addTagToIncludeClause = useMemo( + () => updateTagClauseGetter((q, tag) => q.addOrFieldValue('tag', tag.name, true, 'eq')), + [updateTagClauseGetter] + ); + + const removeTagFromIncludeClause = useMemo( + () => updateTagClauseGetter((q, tag) => q.removeOrFieldValue('tag', tag.name)), + [updateTagClauseGetter] + ); + + const addTagToExcludeClause = useMemo( + () => updateTagClauseGetter((q, tag) => q.addOrFieldValue('tag', tag.name, false, 'eq')), + [updateTagClauseGetter] + ); + + const removeTagFromExcludeClause = useMemo( + () => updateTagClauseGetter((q, tag) => q.removeOrFieldValue('tag', tag.name)), + [updateTagClauseGetter] + ); + + const hasTagInInclude = useMemo(() => hasTagInClauseGetter('must'), [hasTagInClauseGetter]); + const hasTagInExclude = useMemo(() => hasTagInClauseGetter('must_not'), [hasTagInClauseGetter]); + + const addOrRemoveIncludeTagFilter = useCallback( + (tag: Tag) => { + let q: Query | undefined; + + // Remove the tag in the "Exclude" list if it is there + if (hasTagInExclude(tag)) { + q = removeTagFromExcludeClause(tag, undefined, false); + } else if (hasTagInInclude(tag, q)) { + // Already selected, remove the filter + removeTagFromIncludeClause(tag, q); + return; + } + addTagToIncludeClause(tag, q); + }, + [ + hasTagInExclude, + hasTagInInclude, + removeTagFromExcludeClause, + addTagToIncludeClause, + removeTagFromIncludeClause, + ] + ); + + const addOrRemoveExcludeTagFilter = useCallback( + (tag: Tag) => { + let q: Query | undefined; + + // Remove the tag in the "Include" list if it is there + if (hasTagInInclude(tag)) { + q = removeTagFromIncludeClause(tag, undefined, false); + } + + if (hasTagInExclude(tag, q)) { + // Already selected, remove the filter + removeTagFromExcludeClause(tag, q); + return; + } + + addTagToExcludeClause(tag, q); + }, + [ + hasTagInInclude, + hasTagInExclude, + removeTagFromIncludeClause, + addTagToExcludeClause, + removeTagFromExcludeClause, + ] + ); + + const clearTagSelection = useCallback(() => { + const updatedQuery = query.removeOrFieldClauses('tag'); + updateQuery(updatedQuery); + return updateQuery; + }, [query, updateQuery]); + + return { + addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, + clearTagSelection, + tagsToTableItemMap, + }; +} diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 485bf613a7988..3f83216aa1b30 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -20,7 +20,7 @@ pageLoadAssetSize: controls: 40000 core: 435325 crossClusterReplication: 65408 - customIntegrations: 44305 + customIntegrations: 22034 dashboard: 82025 dashboardEnhanced: 65646 data: 454087 @@ -61,7 +61,7 @@ pageLoadAssetSize: globalSearchProviders: 25554 graph: 31504 grokdebugger: 26779 - guidedOnboarding: 26875 + guidedOnboarding: 42965 home: 30182 indexLifecycleManagement: 107090 indexManagement: 140608 diff --git a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts index 1f0a043782311..f3794e96ba991 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts @@ -53,7 +53,7 @@ describe('checking migration metadata changes on all registered SO types', () => Object { "action": "7858e6d5a9f231bf23f6f2e57328eb0095b26735", "action_task_params": "bbd38cbfd74bf6713586fe078e3fa92db2234299", - "alert": "48461f3375d9ba22882ea23a318b62a5b0921a9b", + "alert": "eefada4a02ce05962387c0679d7b292771a931c4", "api_key_pending_invalidation": "9b4bc1235337da9a87ef05a1d1f4858b2a3b77c6", "apm-indices": "ceb0870f3a74e2ffc3a1cd3a3c73af76baca0999", "apm-server-schema": "2bfd2998d3873872e1366458ce553def85418f91", @@ -92,6 +92,7 @@ describe('checking migration metadata changes on all registered SO types', () => "fleet-preconfiguration-deletion-record": "7b28f200513c28ae774f1b7d7d7906954e3c6e16", "graph-workspace": "3342f2cd561afdde8f42f5fb284bf550dee8ebb5", "guided-onboarding-guide-state": "561db8d481b131a2bbf46b1e534d6ce960255135", + "guided-onboarding-plugin-state": "a802ed58e9d0076b9632c59d7943861ba476f99c", "index-pattern": "48e77ca393c254e93256f11a7cdc0232dd754c08", "infrastructure-monitoring-log-view": "e2c78c1076bd35e57d7c5fa1b410e5c126d12327", "infrastructure-ui-source": "7c8dbbc0a608911f1b683a944f4a65383f6153ed", diff --git a/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts index eb9eb8a420695..22aedb2ad6382 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts @@ -62,6 +62,7 @@ const previouslyRegisteredTypes = [ 'graph-workspace', 'guided-setup-state', 'guided-onboarding-guide-state', + 'guided-onboarding-plugin-state', 'index-pattern', 'infrastructure-monitoring-log-view', 'infrastructure-ui-source', diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap index 859f644454169..c00de511b8afb 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap @@ -89,14 +89,16 @@ Object { "type": "vis_dimension", }, ], - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], "splitColumn": undefined, "splitRow": undefined, }, @@ -122,6 +124,7 @@ Object { }, "type": "vis_dimension", }, + "metricsToLabels": Object {}, "nestedLegend": true, "palette": Object { "name": "kibana_palette", diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap index 3fd9966e7524e..65cd755d51a07 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap @@ -87,14 +87,16 @@ Object { "type": "vis_dimension", }, ], - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], "splitColumn": undefined, "splitRow": undefined, }, @@ -115,14 +117,17 @@ Object { "legendPosition": "right", "legendSize": "small", "maxLegendLines": 2, - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], + "metricsToLabels": Object {}, "nestedLegend": true, "palette": Object { "name": "kibana_palette", @@ -222,14 +227,16 @@ Object { "type": "vis_dimension", }, ], - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], "splitColumn": undefined, "splitRow": undefined, }, @@ -250,14 +257,17 @@ Object { "legendPosition": "right", "legendSize": "small", "maxLegendLines": 2, - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], + "metricsToLabels": Object {}, "nestedLegend": true, "palette": Object { "name": "kibana_palette", diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap index ef1c7be526670..5388a47242fb4 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap @@ -89,14 +89,16 @@ Object { "type": "vis_dimension", }, ], - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], "splitColumn": undefined, "splitRow": undefined, }, @@ -114,14 +116,17 @@ Object { "legendPosition": "right", "legendSize": "medium", "maxLegendLines": 2, - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], + "metricsToLabels": Object {}, "nestedLegend": true, "palette": Object { "name": "kibana_palette", diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap index 9cdc69904460a..180c3221240ce 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap @@ -63,14 +63,16 @@ Object { "type": "vis_dimension", }, ], - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], "splitColumn": undefined, "splitRow": undefined, }, @@ -88,14 +90,17 @@ Object { "legendPosition": "right", "legendSize": "medium", "maxLegendLines": 2, - "metric": Object { - "accessor": 0, - "format": Object { - "id": "number", - "params": Object {}, + "metrics": Array [ + Object { + "accessor": 0, + "format": Object { + "id": "number", + "params": Object {}, + }, + "type": "vis_dimension", }, - "type": "vis_dimension", - }, + ], + "metricsToLabels": Object {}, "palette": Object { "name": "kibana_palette", "type": "system_palette", diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts index ec4357c269f37..b312de7bf1583 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts @@ -17,6 +17,10 @@ export const strings = { i18n.translate('expressionPartitionVis.reusable.function.args.metricHelpText', { defaultMessage: 'Metric dimensions config', }), + getMetricToLabelHelp: () => + i18n.translate('expressionPartitionVis.metricToLabel.help', { + defaultMessage: 'JSON key-value pairs of column ID to label', + }), getBucketsArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.bucketsHelpText', { defaultMessage: 'Buckets dimensions config', diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts index ae3f17ff8df3a..75f2aa3c17dc1 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -130,13 +130,14 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ const visConfig: PartitionVisParams = { ...args, + metricsToLabels: {}, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? handlers.getExecutionContext?.()?.description, palette: args.palette, dimensions: { - metric: args.metric, + metrics: [args.metric], buckets: args.buckets, splitColumn: args.splitColumn, splitRow: args.splitRow, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts index 9a18a348be16f..0c222758d912a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts @@ -30,6 +30,7 @@ describe('interpreter/functions#pieVis', () => { const visConfig: PieVisConfig = { addTooltip: true, + metricsToLabels: JSON.stringify({}), legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', legendSize: LegendSize.SMALL, @@ -53,14 +54,16 @@ describe('interpreter/functions#pieVis', () => { truncate: 100, last_level: false, }, - metric: { - type: 'vis_dimension', - accessor: 0, - format: { - id: 'number', - params: {}, + metrics: [ + { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: {}, + }, }, - }, + ], buckets: [ { type: 'vis_dimension', diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts index 119d45f579ebf..4bf2ead1b9c52 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -25,10 +25,15 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ inputTypes: ['datatable'], help: strings.getPieVisFunctionName(), args: { - metric: { + metrics: { types: ['vis_dimension', 'string'], help: strings.getMetricArgHelp(), required: true, + multi: true, + }, + metricsToLabels: { + types: ['string'], + help: strings.getMetricToLabelHelp(), }, buckets: { types: ['vis_dimension', 'string'], @@ -137,9 +142,10 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } - validateAccessor(args.metric, context.columns); + args.metrics.forEach((accessor) => validateAccessor(accessor, context.columns)); + if (args.buckets) { - args.buckets.forEach((bucket) => validateAccessor(bucket, context.columns)); + args.buckets.forEach((accessor) => validateAccessor(accessor, context.columns)); } if (args.splitColumn) { args.splitColumn.forEach((splitColumn) => validateAccessor(splitColumn, context.columns)); @@ -150,13 +156,14 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ const visConfig: PartitionVisParams = { ...args, + metricsToLabels: args.metricsToLabels ? JSON.parse(args.metricsToLabels) : {}, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? handlers.getExecutionContext?.()?.description, palette: args.palette, dimensions: { - metric: args.metric, + metrics: args.metrics, buckets: args.buckets, splitColumn: args.splitColumn, splitRow: args.splitRow, @@ -170,7 +177,7 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ const logTable = prepareLogTable( context, [ - [[args.metric], strings.getSliceSizeHelp()], + [args.metrics, strings.getSliceSizeHelp()], [args.buckets, strings.getSliceHelp()], [args.splitColumn, strings.getColumnSplitHelp()], [args.splitRow, strings.getRowSplitHelp()], diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts index 14aaea3a0cf5e..e5bc4115c1461 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts @@ -34,6 +34,7 @@ describe('interpreter/functions#treemapVis', () => { const visConfig: TreemapVisConfig = { addTooltip: true, + metricsToLabels: JSON.stringify({}), legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', nestedLegend: true, @@ -53,14 +54,16 @@ describe('interpreter/functions#treemapVis', () => { truncate: 100, last_level: false, }, - metric: { - type: 'vis_dimension', - accessor: 0, - format: { - id: 'number', - params: {}, + metrics: [ + { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: {}, + }, }, - }, + ], buckets: [ { type: 'vis_dimension', diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts index 427179ca5a25a..d5f91b1f0e1d3 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -25,10 +25,15 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => inputTypes: ['datatable'], help: strings.getPieVisFunctionName(), args: { - metric: { + metrics: { types: ['vis_dimension'], help: strings.getMetricArgHelp(), required: true, + multi: true, + }, + metricsToLabels: { + types: ['string'], + help: strings.getMetricToLabelHelp(), }, buckets: { types: ['vis_dimension'], @@ -117,7 +122,8 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } - validateAccessor(args.metric, context.columns); + args.metrics.forEach((accessor) => validateAccessor(accessor, context.columns)); + if (args.buckets) { args.buckets.forEach((bucket) => validateAccessor(bucket, context.columns)); } @@ -130,13 +136,14 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => const visConfig: PartitionVisParams = { ...args, + metricsToLabels: args.metricsToLabels ? JSON.parse(args.metricsToLabels) : {}, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? handlers.getExecutionContext?.()?.description, palette: args.palette, dimensions: { - metric: args.metric, + metrics: args.metrics, buckets: args.buckets, splitColumn: args.splitColumn, splitRow: args.splitRow, @@ -150,7 +157,7 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => const logTable = prepareLogTable( context, [ - [[args.metric], strings.getSliceSizeHelp()], + [args.metrics, strings.getSliceSizeHelp()], [args.buckets, strings.getSliceHelp()], [args.splitColumn, strings.getColumnSplitHelp()], [args.splitRow, strings.getRowSplitHelp()], diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts index 608c40b501066..4c81f64428a74 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts @@ -35,6 +35,7 @@ describe('interpreter/functions#waffleVis', () => { const visConfig: WaffleVisConfig = { addTooltip: true, showValuesInLegend: true, + metricsToLabels: JSON.stringify({}), legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', truncateLegend: true, @@ -53,14 +54,16 @@ describe('interpreter/functions#waffleVis', () => { truncate: 100, last_level: false, }, - metric: { - type: 'vis_dimension', - accessor: 0, - format: { - id: 'number', - params: {}, + metrics: [ + { + type: 'vis_dimension', + accessor: 0, + format: { + id: 'number', + params: {}, + }, }, - }, + ], bucket: { type: 'vis_dimension', accessor: 1, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts index 0867e6cb9bd76..1568454b86eb2 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -25,10 +25,15 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ inputTypes: ['datatable'], help: strings.getPieVisFunctionName(), args: { - metric: { + metrics: { types: ['vis_dimension'], help: strings.getMetricArgHelp(), required: true, + multi: true, + }, + metricsToLabels: { + types: ['string'], + help: strings.getMetricToLabelHelp(), }, bucket: { types: ['vis_dimension'], @@ -111,7 +116,8 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ throw new Error(errors.splitRowAndSplitColumnAreSpecifiedError()); } - validateAccessor(args.metric, context.columns); + args.metrics.forEach((accessor) => validateAccessor(accessor, context.columns)); + if (args.bucket) { validateAccessor(args.bucket, context.columns); } @@ -125,13 +131,14 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ const buckets = args.bucket ? [args.bucket] : []; const visConfig: PartitionVisParams = { ...args, + metricsToLabels: args.metricsToLabels ? JSON.parse(args.metricsToLabels) : {}, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? handlers.getExecutionContext?.()?.description, palette: args.palette, dimensions: { - metric: args.metric, + metrics: args.metrics, buckets, splitColumn: args.splitColumn, splitRow: args.splitRow, @@ -145,7 +152,7 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ const logTable = prepareLogTable( context, [ - [[args.metric], strings.getSliceSizeHelp()], + [args.metrics, strings.getSliceSizeHelp()], [buckets, strings.getSliceHelp()], [args.splitColumn, strings.getColumnSplitHelp()], [args.splitRow, strings.getRowSplitHelp()], diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts index 6a8fd2935ba54..9584a810d7ca4 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts @@ -29,7 +29,7 @@ export interface Dimension { } export interface Dimensions { - metric?: ExpressionValueVisDimension | string; + metrics: Array; buckets?: Array; splitRow?: Array; splitColumn?: Array; @@ -58,7 +58,9 @@ interface VisCommonParams { } interface VisCommonConfig extends VisCommonParams { - metric: ExpressionValueVisDimension | string; + metrics: Array; + metricsToLabels?: string; + buckets?: Array; splitColumn?: Array; splitRow?: Array; labels: ExpressionValuePartitionLabels; @@ -67,6 +69,7 @@ interface VisCommonConfig extends VisCommonParams { export interface PartitionVisParams extends VisCommonParams { dimensions: Dimensions; + metricsToLabels: Record; labels: LabelsParams; palette: PaletteOutput; isDonut?: boolean; @@ -79,7 +82,7 @@ export interface PartitionVisParams extends VisCommonParams { } export interface PieVisConfig extends VisCommonConfig { - buckets?: Array; + partitionByColumn?: boolean; isDonut: boolean; emptySizeRatio?: EmptySizeRatios; respectSourceOrder?: boolean; @@ -89,16 +92,15 @@ export interface PieVisConfig extends VisCommonConfig { } export interface TreemapVisConfig extends VisCommonConfig { - buckets?: Array; nestedLegend: boolean; } -export interface MosaicVisConfig extends VisCommonConfig { - buckets?: Array; +export interface MosaicVisConfig extends Omit { + metric: ExpressionValueVisDimension | string; nestedLegend: boolean; } -export interface WaffleVisConfig extends VisCommonConfig { +export interface WaffleVisConfig extends Omit { bucket?: ExpressionValueVisDimension | string; showValuesInLegend: boolean; } diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/utils/consolidate_metric_columns.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/utils/consolidate_metric_columns.test.ts new file mode 100644 index 0000000000000..6d94809e403b7 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/utils/consolidate_metric_columns.test.ts @@ -0,0 +1,287 @@ +/* + * 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 { Datatable } from '@kbn/expressions-plugin/common'; +import { consolidateMetricColumns } from './consolidate_metric_columns'; + +describe('consolidateMetricColumns', () => { + it('collapses multiple metrics into a single metric column', () => { + const table: Datatable = { + type: 'datatable', + columns: [ + { + id: '1', + name: 'bucket1', + meta: { + type: 'string', + }, + }, + { + id: '2', + name: 'bucket2', + meta: { + type: 'string', + }, + }, + { + id: '3', + name: 'metric1', + meta: { + type: 'number', + }, + }, + { + id: '4', + name: 'metric2', + meta: { + type: 'number', + }, + }, + ], + rows: [ + { '1': 'square', '2': 'red', '3': 1, '4': 2 }, + { '1': 'square', '2': 'blue', '3': 3, '4': 4 }, + { '1': 'circle', '2': 'red', '3': 5, '4': 6 }, + { '1': 'circle', '2': 'blue', '3': 7, '4': 8 }, + ], + }; + + const result = consolidateMetricColumns(table, ['1', '2'], ['3', '4'], { + 3: 'metric1 label', + 4: 'metric2 label', + }); + expect(result.bucketAccessors).toEqual(['1', '2', 'metric-name']); + expect(result.metricAccessor).toEqual('value'); + expect(result.table).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "1", + "meta": Object { + "type": "string", + }, + "name": "bucket1", + }, + Object { + "id": "2", + "meta": Object { + "type": "string", + }, + "name": "bucket2", + }, + Object { + "id": "metric-name", + "meta": Object { + "sourceParams": Object { + "consolidatedMetricsColumn": true, + }, + "type": "string", + }, + "name": "metric-name", + }, + Object { + "id": "value", + "meta": Object { + "type": "number", + }, + "name": "value", + }, + ], + "rows": Array [ + Object { + "1": "square", + "2": "red", + "metric-name": "metric1 label", + "value": 1, + }, + Object { + "1": "square", + "2": "red", + "metric-name": "metric2 label", + "value": 2, + }, + Object { + "1": "square", + "2": "blue", + "metric-name": "metric1 label", + "value": 3, + }, + Object { + "1": "square", + "2": "blue", + "metric-name": "metric2 label", + "value": 4, + }, + Object { + "1": "circle", + "2": "red", + "metric-name": "metric1 label", + "value": 5, + }, + Object { + "1": "circle", + "2": "red", + "metric-name": "metric2 label", + "value": 6, + }, + Object { + "1": "circle", + "2": "blue", + "metric-name": "metric1 label", + "value": 7, + }, + Object { + "1": "circle", + "2": "blue", + "metric-name": "metric2 label", + "value": 8, + }, + ], + "type": "datatable", + } + `); + }); + + it('leaves single metric tables alone', () => { + const table: Datatable = { + type: 'datatable', + columns: [ + { + id: '1', + name: 'bucket1', + meta: { + type: 'string', + }, + }, + { + id: '2', + name: 'bucket2', + meta: { + type: 'string', + }, + }, + { + id: '3', + name: 'metric1', + meta: { + type: 'number', + }, + }, + ], + rows: [ + { '1': 'square', '2': 'red', '3': 1 }, + { '1': 'square', '2': 'blue', '3': 3 }, + { '1': 'circle', '2': 'red', '3': 5 }, + { '1': 'circle', '2': 'blue', '3': 7 }, + ], + }; + + const bucketAccessors = ['1', '2']; + const metricAccessors = ['3']; + const result = consolidateMetricColumns(table, bucketAccessors, metricAccessors, { + 3: 'metric1', + }); + + expect(result.table).toEqual(table); + expect(result.bucketAccessors).toEqual(bucketAccessors); + expect(result.metricAccessor).toEqual(metricAccessors[0]); + }); + + it('does not blow up when there are no bucket accessors', () => { + const table: Datatable = { + type: 'datatable', + columns: [ + { + id: '3', + name: 'metric1', + meta: { + type: 'number', + }, + }, + { + id: '4', + name: 'metric2', + meta: { + type: 'number', + }, + }, + ], + rows: [ + { '3': 1, '4': 2 }, + { '3': 3, '4': 4 }, + { '3': 5, '4': 6 }, + { '3': 7, '4': 8 }, + ], + }; + + const result = consolidateMetricColumns(table, undefined, ['3', '4'], { + 3: 'metric1', + 4: 'metric2', + }); + expect(result.bucketAccessors).toEqual(['metric-name']); + expect(result.metricAccessor).toEqual('value'); + expect(result.table).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "metric-name", + "meta": Object { + "sourceParams": Object { + "consolidatedMetricsColumn": true, + }, + "type": "string", + }, + "name": "metric-name", + }, + Object { + "id": "value", + "meta": Object { + "type": "number", + }, + "name": "value", + }, + ], + "rows": Array [ + Object { + "metric-name": "metric1", + "value": 1, + }, + Object { + "metric-name": "metric2", + "value": 2, + }, + Object { + "metric-name": "metric1", + "value": 3, + }, + Object { + "metric-name": "metric2", + "value": 4, + }, + Object { + "metric-name": "metric1", + "value": 5, + }, + Object { + "metric-name": "metric2", + "value": 6, + }, + Object { + "metric-name": "metric1", + "value": 7, + }, + Object { + "metric-name": "metric2", + "value": 8, + }, + ], + "type": "datatable", + } + `); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/utils/consolidate_metric_columns.ts b/src/plugins/chart_expressions/expression_partition_vis/common/utils/consolidate_metric_columns.ts new file mode 100644 index 0000000000000..009744cc06f3e --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/utils/consolidate_metric_columns.ts @@ -0,0 +1,93 @@ +/* + * 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 { Datatable, DatatableColumn, DatatableRow } from '@kbn/expressions-plugin/common'; +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; +import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; + +function nonNullable(value: T): value is NonNullable { + return value !== null && value !== undefined; +} + +export const consolidateMetricColumns = ( + table: Datatable, + bucketAccessors: Array = [], + metricAccessors: Array, + metricsToLabels: Record +): { + table: Datatable; + metricAccessor: string | ExpressionValueVisDimension | undefined; + bucketAccessors: Array; +} => { + if (metricAccessors.length < 2) { + return { + table, + metricAccessor: metricAccessors[0], + bucketAccessors, + }; + } + + const bucketColumns = bucketAccessors + ?.map((accessor) => getColumnByAccessor(accessor, table.columns)) + .filter(nonNullable); + + const metricColumns = metricAccessors + ?.map((accessor) => getColumnByAccessor(accessor, table.columns)) + .filter(nonNullable); + + const transposedRows: DatatableRow[] = []; + + const nameColumnId = 'metric-name'; + const valueColumnId = 'value'; + + table.rows.forEach((row) => { + metricColumns.forEach((metricCol) => { + const newRow: DatatableRow = {}; + + bucketColumns.forEach(({ id }) => { + newRow[id] = row[id]; + }); + + newRow[nameColumnId] = metricsToLabels[metricCol.id]; + newRow[valueColumnId] = row[metricCol.id]; + + transposedRows.push(newRow); + }); + }); + + const transposedColumns: DatatableColumn[] = [ + ...bucketColumns, + { + id: nameColumnId, + name: nameColumnId, + meta: { + type: 'string', + sourceParams: { + consolidatedMetricsColumn: true, + }, + }, + }, + { + id: valueColumnId, + name: valueColumnId, + meta: { + type: 'number', + }, + }, + ]; + + return { + metricAccessor: valueColumnId, + bucketAccessors: [...bucketColumns.map(({ id }) => id), nameColumnId], + table: { + type: 'datatable', + columns: transposedColumns, + rows: transposedRows, + }, + }; +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/utils/index.ts b/src/plugins/chart_expressions/expression_partition_vis/common/utils/index.ts new file mode 100644 index 0000000000000..12ea01c9177cb --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/common/utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './consolidate_metric_columns'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts index d16802518cce4..aa4023006d486 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/config.ts @@ -17,6 +17,7 @@ import { export const config: RenderValue['visConfig'] = { addTooltip: true, legendDisplay: LegendDisplay.HIDE, + metricsToLabels: { percent_uptime: 'percent_uptime' }, truncateLegend: true, respectSourceOrder: true, legendPosition: Position.Bottom, @@ -35,20 +36,22 @@ export const config: RenderValue['visConfig'] = { last_level: false, }, dimensions: { - metric: { - type: 'vis_dimension', - accessor: { - id: 'percent_uptime', - name: 'percent_uptime', - meta: { - type: 'number', + metrics: [ + { + type: 'vis_dimension', + accessor: { + id: 'percent_uptime', + name: 'percent_uptime', + meta: { + type: 'number', + }, + }, + format: { + id: 'string', + params: {}, }, }, - format: { - id: 'string', - params: {}, - }, - }, + ], }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap index 121c40d50afe2..c91e491887a99 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap @@ -194,14 +194,6 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = ` "vis.colors", Object {}, ], - Array [ - "vis.legendOpen", - true, - ], - Array [ - "vis.colors", - Object {}, - ], ], "results": Array [ Object { @@ -212,14 +204,6 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = ` "type": "return", "value": Object {}, }, - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": Object {}, - }, ], }, "set": [MockFunction], @@ -597,30 +581,6 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = "vis.colors", Object {}, ], - Array [ - "vis.legendOpen", - true, - ], - Array [ - "vis.colors", - Object {}, - ], - Array [ - "vis.legendOpen", - true, - ], - Array [ - "vis.colors", - Object {}, - ], - Array [ - "vis.legendOpen", - true, - ], - Array [ - "vis.colors", - Object {}, - ], ], "results": Array [ Object { @@ -631,30 +591,6 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = "type": "return", "value": Object {}, }, - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": Object {}, - }, - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": Object {}, - }, - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": Object {}, - }, ], }, "set": [MockFunction], @@ -840,7 +776,7 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = `; -exports[`PartitionVisComponent should render correct structure for pie 1`] = ` +exports[`PartitionVisComponent should render correct structure for multi-metric pie 1`] = `
`; -exports[`PartitionVisComponent should render correct structure for treemap 1`] = ` +exports[`PartitionVisComponent should render correct structure for pie 1`] = `
} @@ -1520,12 +1546,12 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] = "linkLabel": Object { "fontSize": 11, "maxCount": 5, - "maxTextLength": undefined, + "maxTextLength": 100, "textColor": undefined, }, "maxFontSize": 16, "minFontSize": 10, - "outerSizeRatio": 1, + "outerSizeRatio": undefined, "sectorLineStroke": undefined, "sectorLineWidth": 1.5, }, @@ -1599,7 +1625,7 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] = }, ] } - id="treemap" + id="pie" layers={ Array [ Object { @@ -1632,7 +1658,7 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] = }, ] } - layout="treemap" + layout="sunburst" percentFormatter={[Function]} smallMultiples="__pie_chart_sm__" valueAccessor={[Function]} @@ -1645,7 +1671,7 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] =
`; -exports[`PartitionVisComponent should render correct structure for waffle 1`] = ` +exports[`PartitionVisComponent should render correct structure for treemap 1`] = `
+ + + + + } + onElementClick={[Function]} + onRenderChange={[Function]} + showLegend={true} + theme={ + Array [ + Object { + "background": Object { + "color": "transparent", }, - Object { - "type": "return", - "value": Object {}, + }, + Object { + "chartMargins": Object { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, }, - Object { - "type": "return", - "value": true, + "partition": Object { + "circlePadding": 4, + "emptySizeRatio": 0, + "fontFamily": undefined, + "linkLabel": Object { + "fontSize": 11, + "maxCount": 5, + "maxTextLength": undefined, + "textColor": undefined, + }, + "maxFontSize": 16, + "minFontSize": 10, + "outerSizeRatio": 1, + "sectorLineStroke": undefined, + "sectorLineWidth": 1.5, }, - Object { - "type": "return", - "value": Object {}, + }, + Object {}, + Object { + "legend": Object { + "labelOptions": Object { + "maxLines": 1, + }, }, - Object { - "type": "return", - "value": true, + }, + ] + } + tooltip={ + Object { + "type": "follow", + } + } + /> + + + +
+
+`; + +exports[`PartitionVisComponent should render correct structure for waffle 1`] = ` +
+
+ { const original = jest.requireActual('@elastic/charts'); @@ -83,11 +84,27 @@ describe('PartitionVisComponent', function () { }; }); + afterEach(() => { + mockState.clear(); + jest.clearAllMocks(); + }); + it('should render correct structure for pie', function () { const component = shallow(); expect(component).toMatchSnapshot(); }); + it('should render correct structure for multi-metric pie', function () { + const localParams = cloneDeep(wrapperProps.visParams); + + localParams.dimensions.metrics = [...localParams.dimensions.metrics, 'col-3-1']; + + localParams.metricsToLabels = { 'col-3-1': 'metric1 label', 'col-1-1': 'metric2 label' }; + + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + it('should render correct structure for donut', function () { const donutVisParams = createMockDonutParams(); const component = shallow( diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index 6ef1bb79f0f3f..ed1789f2ae4a9 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -34,6 +34,7 @@ import { IInterpreterRenderHandlers, } from '@kbn/expressions-plugin/public'; import type { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { consolidateMetricColumns } from '../../common/utils'; import { DEFAULT_PERCENT_DECIMALS } from '../../common/constants'; import { PartitionVisParams, @@ -91,14 +92,40 @@ export interface PartitionVisComponentProps { } const PartitionVisComponent = (props: PartitionVisComponentProps) => { - const { visData, visParams: preVisParams, visType, services, syncColors } = props; + const { + visData: originalVisData, + visParams: preVisParams, + visType, + services, + syncColors, + } = props; const visParams = useMemo(() => filterOutConfig(visType, preVisParams), [preVisParams, visType]); const chartTheme = props.chartsThemeService.useChartsTheme(); const chartBaseTheme = props.chartsThemeService.useChartsBaseTheme(); + const { + table: visData, + metricAccessor, + bucketAccessors, + } = useMemo( + () => + consolidateMetricColumns( + originalVisData, + visParams.dimensions.buckets, + visParams.dimensions.metrics, + visParams.metricsToLabels + ), + [ + originalVisData, + visParams.dimensions.buckets, + visParams.dimensions.metrics, + visParams.metricsToLabels, + ] + ); + const { bucketColumns, metricColumn } = useMemo( - () => getColumns(props.visParams, props.visData), - [props.visData, props.visParams] + () => getColumns({ metric: metricAccessor, buckets: bucketAccessors }, visData), + [bucketAccessors, metricAccessor, visData] ); const formatters = useMemo( @@ -115,7 +142,9 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { const showToggleLegendElement = props.uiState !== undefined; - const [dimensions, setDimensions] = useState(); + const [containerDimensions, setContainerDimensions] = useState< + undefined | PieContainerDimensions + >(); const parentRef = useRef(null); @@ -123,7 +152,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { if (parentRef && parentRef.current) { const parentHeight = parentRef.current!.getBoundingClientRect().height; const parentWidth = parentRef.current!.getBoundingClientRect().width; - setDimensions({ width: parentWidth, height: parentHeight }); + setContainerDimensions({ width: parentWidth, height: parentHeight }); } }, [parentRef]); @@ -153,13 +182,16 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { const data = getFilterClickData( clickedLayers, buckets, + metricColumn.id, vData, + originalVisData, + visParams.dimensions.metrics.length, splitChartDimension, splitChartFormatter ); props.fireEvent({ name: 'filter', data: { data } }); }, - [props] + [metricColumn.id, originalVisData, props, visParams.dimensions.metrics.length] ); // handles legend action event data @@ -292,8 +324,8 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { }, [visData.rows, metricColumn]); const themeOverrides = useMemo( - () => getPartitionTheme(visType, visParams, chartTheme, dimensions, rescaleFactor), - [visType, visParams, chartTheme, dimensions, rescaleFactor] + () => getPartitionTheme(visType, visParams, chartTheme, containerDimensions, rescaleFactor), + [visType, visParams, chartTheme, containerDimensions, rescaleFactor] ); const fixedViewPort = document.getElementById('app-fixed-viewport'); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/mocks.ts b/src/plugins/chart_expressions/expression_partition_vis/public/mocks.ts index 455dd111179d2..c125243f3a09a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/mocks.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/mocks.ts @@ -292,17 +292,20 @@ export const createMockPartitionVisParams = (): PartitionVisParams => { name: 'default', type: 'palette', }, + metricsToLabels: {}, dimensions: { - metric: { - type: 'vis_dimension', - accessor: 1, - format: { - id: 'number', - params: { + metrics: [ + { + type: 'vis_dimension', + accessor: 1, + format: { id: 'number', + params: { + id: 'number', + }, }, }, - }, + ], buckets: [ { type: 'vis_dimension', diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.test.ts index 62da48b6ec5a0..07646450a43a0 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.test.ts @@ -5,16 +5,19 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { DatatableColumn } from '@kbn/expressions-plugin/public'; +import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/public'; import { getFilterClickData, getFilterEventData } from './filter_helpers'; import { createMockBucketColumns, createMockVisData } from '../mocks'; +import { consolidateMetricColumns } from '../../common/utils'; +import { LayerValue } from '@elastic/charts'; +import faker from 'faker'; const bucketColumns = createMockBucketColumns(); const visData = createMockVisData(); describe('getFilterClickData', () => { it('returns the correct filter data for the specific layer', () => { - const clickedLayers = [ + const clickedLayers: LayerValue[] = [ { groupByRollup: 'Logstash Airways', value: 729, @@ -24,7 +27,14 @@ describe('getFilterClickData', () => { smAccessorValue: '', }, ]; - const data = getFilterClickData(clickedLayers, bucketColumns, visData); + const data = getFilterClickData( + clickedLayers, + bucketColumns, + visData.columns[1].id, + visData, + visData, + 1 + ); expect(data.length).toEqual(clickedLayers.length); expect(data[0].value).toEqual('Logstash Airways'); expect(data[0].row).toEqual(0); @@ -32,7 +42,7 @@ describe('getFilterClickData', () => { }); it('changes the filter if the user clicks on another layer', () => { - const clickedLayers = [ + const clickedLayers: LayerValue[] = [ { groupByRollup: 'ES-Air', value: 572, @@ -42,7 +52,14 @@ describe('getFilterClickData', () => { smAccessorValue: '', }, ]; - const data = getFilterClickData(clickedLayers, bucketColumns, visData); + const data = getFilterClickData( + clickedLayers, + bucketColumns, + visData.columns[1].id, + visData, + visData, + 1 + ); expect(data.length).toEqual(clickedLayers.length); expect(data[0].value).toEqual('ES-Air'); expect(data[0].row).toEqual(4); @@ -50,7 +67,7 @@ describe('getFilterClickData', () => { }); it('returns the correct filters for small multiples', () => { - const clickedLayers = [ + const clickedLayers: LayerValue[] = [ { groupByRollup: 'ES-Air', value: 572, @@ -64,7 +81,15 @@ describe('getFilterClickData', () => { id: 'col-2-3', name: 'Cancelled: Descending', } as DatatableColumn; - const data = getFilterClickData(clickedLayers, bucketColumns, visData, splitDimension); + const data = getFilterClickData( + clickedLayers, + bucketColumns, + visData.columns[1].id, + visData, + visData, + 1, + splitDimension + ); expect(data.length).toEqual(2); expect(data[0].value).toEqual('ES-Air'); expect(data[0].row).toEqual(5); @@ -87,12 +112,132 @@ describe('getFilterClickData', () => { id: 'col-0-2', name: 'Carrier: Descending', } as DatatableColumn; - const data = getFilterClickData(clickedLayers, [{ name: 'Count' }], visData, splitDimension); + const data = getFilterClickData( + clickedLayers, + [{ name: 'Count' }], + visData.columns[1].id, + visData, + visData, + 1, + splitDimension + ); expect(data.length).toEqual(2); expect(data[0].value).toEqual('Count'); expect(data[0].row).toEqual(4); - expect(data[1].column).toEqual(0); + expect(data[0].column).toEqual(1); + expect(data[1].value).toEqual('ES-Air'); + expect(data[1].row).toEqual(4); + expect(data[1].column).toEqual(0); + }); + + describe('multi-metric scenarios', () => { + describe('with original bucket columns', () => { + const originalTable: Datatable = { + type: 'datatable', + columns: [ + { name: 'shape', id: '0', meta: { type: 'string' } }, + { name: 'color', id: '1', meta: { type: 'string' } }, + { + name: 'metric1', + id: '2', + meta: { + type: 'number', + }, + }, + { + name: 'metric2', + id: '3', + meta: { + type: 'number', + }, + }, + ], + rows: [ + { '0': 'square', '1': 'red', '2': 1, '3': 2 }, + { '0': 'square', '1': 'blue', '2': 3, '3': 4 }, + { '0': 'circle', '1': 'green', '2': 5, '3': 6 }, + { '0': 'circle', '1': 'gray', '2': 7, '3': 8 }, + ], + }; + + const { table: consolidatedTable } = consolidateMetricColumns( + originalTable, + ['0', '1'], + ['2', '3'], + { + 2: 'metric1', + 3: 'metric2', + } + ); + + it('generates the correct filters', () => { + const localBucketColumns = consolidatedTable.columns.slice(0, 3); + + const clickedLayers: LayerValue[] = [ + { + groupByRollup: 'circle', + value: faker.random.number(), + depth: faker.random.number(), + path: [], + sortIndex: faker.random.number(), + smAccessorValue: '', + }, + { + groupByRollup: 'green', + value: faker.random.number(), + depth: faker.random.number(), + path: [], + sortIndex: faker.random.number(), + smAccessorValue: '', + }, + { + groupByRollup: 'metric2', + value: faker.random.number(), + depth: faker.random.number(), + path: [], + sortIndex: faker.random.number(), + smAccessorValue: '', + }, + ]; + + const data = getFilterClickData( + clickedLayers, + localBucketColumns, + 'value', + consolidatedTable, + originalTable, + 2 + ); + + expect(data).toHaveLength(3); + + expect(data.map((datum) => ({ ...datum, table: undefined }))).toMatchInlineSnapshot(` + Array [ + Object { + "column": 0, + "row": 2, + "table": undefined, + "value": "circle", + }, + Object { + "column": 1, + "row": 2, + "table": undefined, + "value": "green", + }, + Object { + "column": 3, + "row": 2, + "table": undefined, + "value": "metric2", + }, + ] + `); + + expect(data.map((datum) => datum.table === originalTable).every(Boolean)).toBe(true); + }); + }); }); }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts index 3d129094ebb19..6a42bc6f7b601 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts @@ -28,12 +28,15 @@ export const canFilter = async ( export const getFilterClickData = ( clickedLayers: LayerValue[], bucketColumns: Array>, + metricColId: string, visData: Datatable, + originalVisData: Datatable, // before multiple metrics are consolidated with collapseMetricColumns + numOriginalMetrics: number, splitChartDimension?: DatatableColumn, splitChartFormatter?: FieldFormat ): ValueClickContext['data']['data'] => { const data: ValueClickContext['data']['data'] = []; - const matchingIndex = visData.rows.findIndex((row) => + const rowIndex = visData.rows.findIndex((row) => clickedLayers.every((layer, index) => { const columnId = bucketColumns[index].id; if (!columnId && !splitChartDimension) return; @@ -48,20 +51,53 @@ export const getFilterClickData = ( }) ); + const originalRowIndex = Math.floor(rowIndex / numOriginalMetrics); + data.push( - ...clickedLayers.map((clickedLayer, index) => ({ - column: visData.columns.findIndex((col) => col.id === bucketColumns[index].id), - row: matchingIndex, - value: clickedLayer.groupByRollup, - table: visData, - })) + ...(clickedLayers + .map((clickedLayer, index) => { + const currentColumnIndex = visData.columns.findIndex( + (col) => col.id === bucketColumns[index].id + ); + + if (currentColumnIndex === -1) { + return undefined; + } + + const currentColumn = visData.columns[currentColumnIndex]; + + // this logic maps the indices of the elements in the + // visualization's table to the indices in the table before + // any multiple metrics were collapsed into one metric column + const originalColumnIndex = currentColumn.meta?.sourceParams?.consolidatedMetricsColumn + ? currentColumnIndex + (rowIndex % numOriginalMetrics) + : currentColumnIndex; + + return { + column: originalColumnIndex, + row: originalRowIndex, + value: clickedLayer.groupByRollup, + table: originalVisData, + }; + }) + .filter(Boolean) as ValueClickContext['data']['data']) ); // Allows filtering with the small multiples value if (splitChartDimension) { + if (!bucketColumns[0].id) { + // this is a split chart without any real bucket columns, so filter by the metric column + data.push({ + column: visData.columns.findIndex((col) => col.id === metricColId), + row: rowIndex, + table: visData, + value: visData.columns.find((col) => col.id === metricColId)?.name, + }); + } + data.push({ column: visData.columns.findIndex((col) => col.id === splitChartDimension.id), - row: matchingIndex, + row: rowIndex, table: visData, value: clickedLayers[0].smAccessorValue, }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.test.ts index 157336599a26e..a544dfe1f8537 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.test.ts @@ -7,20 +7,18 @@ */ import { getColumns } from './get_columns'; -import { - LabelPositions, - LegendDisplay, - PartitionVisParams, - ValueFormats, -} from '../../common/types'; import { createMockPieParams, createMockVisData } from '../mocks'; const visParams = createMockPieParams(); +const dimensions = { + metric: visParams.dimensions.metrics[0], + buckets: visParams.dimensions.buckets!, +}; const visData = createMockVisData(); describe('getColumns', () => { it('should return the correct bucket columns if visParams returns dimensions', () => { - const { bucketColumns } = getColumns(visParams, visData); + const { bucketColumns } = getColumns(dimensions, visData); expect(bucketColumns.length).toEqual(visParams.dimensions.buckets?.length); expect(bucketColumns).toEqual([ { @@ -115,11 +113,8 @@ describe('getColumns', () => { it('should return the correct metric column if visParams returns dimensions', () => { const { metricColumn } = getColumns( { - ...visParams, - dimensions: { - ...visParams.dimensions, - metric: undefined, - }, + ...dimensions, + metric: undefined, }, visData ); @@ -144,28 +139,8 @@ describe('getColumns', () => { }); it('should return the first data column if no buckets specified', () => { - const visParamsOnlyMetric: PartitionVisParams = { - legendDisplay: LegendDisplay.SHOW, - addTooltip: true, - labels: { - position: LabelPositions.DEFAULT, - show: true, - truncate: 100, - values: true, - valuesFormat: ValueFormats.PERCENT, - percentDecimals: 2, - last_level: false, - }, - legendPosition: 'right', - nestedLegend: false, - maxLegendLines: 1, - truncateLegend: false, - distinctColors: false, - palette: { - name: 'default', - type: 'palette', - }, - dimensions: { + const { metricColumn } = getColumns( + { metric: { type: 'vis_dimension', accessor: 1, @@ -174,9 +149,10 @@ describe('getColumns', () => { params: {}, }, }, + buckets: [], }, - }; - const { metricColumn } = getColumns(visParamsOnlyMetric, visData); + visData + ); expect(metricColumn).toEqual({ id: 'col-1-1', meta: { @@ -200,29 +176,8 @@ describe('getColumns', () => { }); it('should return an object with the name of the metric if no buckets specified', () => { - const visParamsOnlyMetric: PartitionVisParams = { - legendDisplay: LegendDisplay.SHOW, - addTooltip: true, - isDonut: true, - labels: { - position: LabelPositions.DEFAULT, - show: true, - truncate: 100, - values: true, - valuesFormat: ValueFormats.PERCENT, - percentDecimals: 2, - last_level: false, - }, - truncateLegend: false, - maxLegendLines: 100, - distinctColors: false, - legendPosition: 'right', - nestedLegend: false, - palette: { - name: 'default', - type: 'palette', - }, - dimensions: { + const { bucketColumns, metricColumn } = getColumns( + { metric: { type: 'vis_dimension', accessor: 1, @@ -231,9 +186,10 @@ describe('getColumns', () => { params: {}, }, }, + buckets: [], }, - }; - const { bucketColumns, metricColumn } = getColumns(visParamsOnlyMetric, visData); + visData + ); expect(bucketColumns).toEqual([{ name: metricColumn.name }]); }); }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts index 0b5d0d101cf38..53d039a9e5199 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_columns.ts @@ -9,7 +9,7 @@ import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { DatatableColumn, Datatable } from '@kbn/expressions-plugin/public'; -import { BucketColumns, PartitionVisParams } from '../../common/types'; +import { BucketColumns } from '../../common/types'; const getMetricColumn = ( metricAccessor: ExpressionValueVisDimension | string, @@ -19,14 +19,17 @@ const getMetricColumn = ( }; export const getColumns = ( - visParams: PartitionVisParams, + dimensions: { + metric: string | ExpressionValueVisDimension | undefined; + buckets: Array; + }, visData: Datatable ): { metricColumn: DatatableColumn; bucketColumns: Array>; } => { - const { metric, buckets } = visParams.dimensions; - if (buckets && buckets.length > 0) { + const { metric, buckets } = dimensions; + if (buckets.length > 0) { const bucketColumns: Array> = buckets.map((bucket) => { const column = getColumnByAccessor(bucket, visData.columns); return { diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts index 5a0080039e21a..f27298f371f07 100644 --- a/src/plugins/controls/common/options_list/types.ts +++ b/src/plugins/controls/common/options_list/types.ts @@ -7,7 +7,7 @@ */ import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query'; -import { FieldSpec, DataView } from '@kbn/data-views-plugin/common'; +import { FieldSpec, DataView, RuntimeFieldSpec } from '@kbn/data-views-plugin/common'; import { DataControlInput } from '../types'; @@ -57,9 +57,11 @@ export type OptionsListRequest = Omit< * The Options list request body is sent to the serverside Options List route and is used to create the ES query. */ export interface OptionsListRequestBody { + runtimeFieldMap?: Record; filters?: Array<{ bool: BoolQuery }>; selectedOptions?: string[]; runPastTimeout?: boolean; + parentFieldName?: string; textFieldName?: string; searchString?: string; fieldSpec?: FieldSpec; diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index 1e9c42ff420de..bce776a3922a3 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -52,7 +52,7 @@ import { } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { pluginServices } from '../../services'; -import { loadFieldRegistryFromDataViewId } from './data_control_editor_tools'; +import { getDataControlFieldRegistry } from './data_control_editor_tools'; interface EditControlProps { embeddable?: ControlEmbeddable; isCreate: boolean; @@ -116,10 +116,10 @@ export const ControlEditor = ({ useEffect(() => { (async () => { if (state.selectedDataView?.id) { - setFieldRegistry(await loadFieldRegistryFromDataViewId(state.selectedDataView.id)); + setFieldRegistry(await getDataControlFieldRegistry(await get(state.selectedDataView.id))); } })(); - }, [state.selectedDataView]); + }, [state.selectedDataView?.id, get]); useMount(() => { let mounted = true; diff --git a/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts index cb0d1db5f4a89..4344891280ce6 100644 --- a/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts +++ b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts @@ -6,13 +6,20 @@ * Side Public License, v 1. */ +import { memoize } from 'lodash'; + import { IFieldSubTypeMulti } from '@kbn/es-query'; import { DataView } from '@kbn/data-views-plugin/common'; import { pluginServices } from '../../services'; import { DataControlFieldRegistry, IEditableControlFactory } from '../../types'; -const dataControlFieldRegistryCache: { [key: string]: DataControlFieldRegistry } = {}; +export const getDataControlFieldRegistry = memoize( + async (dataView: DataView) => { + return await loadFieldRegistryFromDataView(dataView); + }, + (dataView: DataView) => [dataView.id, JSON.stringify(dataView.fields.getAll())].join('|') +); const doubleLinkFields = (dataView: DataView) => { // double link the parent-child relationship specifically for case-sensitivity support for options lists @@ -22,6 +29,7 @@ const doubleLinkFields = (dataView: DataView) => { if (!fieldRegistry[field.name]) { fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; } + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; if (parentFieldName) { fieldRegistry[field.name].parentFieldName = parentFieldName; @@ -36,20 +44,13 @@ const doubleLinkFields = (dataView: DataView) => { return fieldRegistry; }; -export const loadFieldRegistryFromDataViewId = async ( - dataViewId: string +const loadFieldRegistryFromDataView = async ( + dataView: DataView ): Promise => { - if (dataControlFieldRegistryCache[dataViewId]) { - return dataControlFieldRegistryCache[dataViewId]; - } const { - dataViews, controls: { getControlTypes, getControlFactory }, } = pluginServices.getServices(); - const dataView = await dataViews.get(dataViewId); - const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(dataView); - const controlFactories = getControlTypes().map( (controlType) => getControlFactory(controlType) as IEditableControlFactory ); @@ -64,7 +65,6 @@ export const loadFieldRegistryFromDataViewId = async ( delete newFieldRegistry[dataViewField.name]; } }); - dataControlFieldRegistryCache[dataViewId] = newFieldRegistry; return newFieldRegistry; }; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 35c251a179d09..06dd2b069bc44 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -43,7 +43,7 @@ import { ControlEmbeddable, ControlInput, ControlOutput, DataControlInput } from import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; import { CreateTimeSliderControlButton } from '../editor/create_time_slider_control'; import { TIME_SLIDER_CONTROL } from '../../time_slider'; -import { loadFieldRegistryFromDataViewId } from '../editor/data_control_editor_tools'; +import { getDataControlFieldRegistry } from '../editor/data_control_editor_tools'; let flyoutRef: OverlayRef | undefined; export const setFlyoutRef = (newRef: OverlayRef | undefined) => { @@ -102,7 +102,8 @@ export class ControlGroupContainer extends Container< fieldName: string; title?: string; }) { - const fieldRegistry = await loadFieldRegistryFromDataViewId(dataViewId); + const dataView = await pluginServices.getServices().dataViews.get(dataViewId); + const fieldRegistry = await getDataControlFieldRegistry(dataView); const field = fieldRegistry[fieldName]; return this.addNewEmbeddable(field.compatibleControlTypes[0], { id: uuid, diff --git a/src/plugins/controls/public/services/options_list/options_list_service.ts b/src/plugins/controls/public/services/options_list/options_list_service.ts index 27867b5724cec..857a363154b7b 100644 --- a/src/plugins/controls/public/services/options_list/options_list_service.ts +++ b/src/plugins/controls/public/services/options_list/options_list_service.ts @@ -89,6 +89,7 @@ class OptionsListService implements ControlsOptionsListService { fieldName: field.name, fieldSpec: field, textFieldName: (field as OptionsListField).textFieldName, + runtimeFieldMap: dataView.toSpec().runtimeFieldMap, }; }; diff --git a/src/plugins/controls/server/options_list/options_list_suggestions_route.ts b/src/plugins/controls/server/options_list/options_list_suggestions_route.ts index fe2218c3f7135..95900d82cbd83 100644 --- a/src/plugins/controls/server/options_list/options_list_suggestions_route.ts +++ b/src/plugins/controls/server/options_list/options_list_suggestions_route.ts @@ -85,8 +85,7 @@ export const setupOptionsListSuggestionsRoute = ( /** * Build ES Query */ - const { runPastTimeout, filters, fieldName } = request; - + const { runPastTimeout, filters, fieldName, runtimeFieldMap } = request; const { terminateAfter, timeout } = getAutocompleteSettings(); const timeoutSettings = runPastTimeout ? {} @@ -124,6 +123,9 @@ export const setupOptionsListSuggestionsRoute = ( }, }, }, + runtime_mappings: { + ...runtimeFieldMap, + }, }; /** diff --git a/src/plugins/custom_integrations/public/language_components.tsx b/src/plugins/custom_integrations/public/language_components.tsx new file mode 100644 index 0000000000000..a8dae31b2999a --- /dev/null +++ b/src/plugins/custom_integrations/public/language_components.tsx @@ -0,0 +1,11 @@ +/* + * 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 { SampleClientReadme } from './components/fleet_integration/sample/sample_client_readme'; +export { ElasticsearchJsClientReadme } from './components/fleet_integration/elasticsearch_js/elasticsearch_js_readme'; +export { ElasticsearchPyClientReadme } from './components/fleet_integration/elasticsearch_py/elasticsearch_py_readme'; diff --git a/src/plugins/custom_integrations/public/plugin.tsx b/src/plugins/custom_integrations/public/plugin.tsx index dbd62d59adcf6..6be4466fdc144 100755 --- a/src/plugins/custom_integrations/public/plugin.tsx +++ b/src/plugins/custom_integrations/public/plugin.tsx @@ -22,10 +22,6 @@ import { import { CustomIntegrationsServicesProvider } from './services'; import { servicesFactory } from './services/kibana'; -import { SampleClientReadme } from './components/fleet_integration/sample/sample_client_readme'; -import { ElasticsearchJsClientReadme } from './components/fleet_integration/elasticsearch_js/elasticsearch_js_readme'; -import { ElasticsearchPyClientReadme } from './components/fleet_integration/elasticsearch_py/elasticsearch_py_readme'; - export class CustomIntegrationsPlugin implements Plugin { @@ -49,9 +45,15 @@ export class CustomIntegrationsPlugin const services = servicesFactory({ coreStart, startPlugins }); const languageClientsUiComponents = { - sample: SampleClientReadme, - javascript: ElasticsearchJsClientReadme, - python: ElasticsearchPyClientReadme, + sample: React.lazy(async () => ({ + default: (await import('./language_components')).SampleClientReadme, + })), + javascript: React.lazy(async () => ({ + default: (await import('./language_components')).ElasticsearchJsClientReadme, + })), + python: React.lazy(async () => ({ + default: (await import('./language_components')).ElasticsearchPyClientReadme, + })), }; const ContextProvider: React.FC = ({ children }) => ( diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index 9534b54b16408..9ff31f266468e 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -70,6 +70,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da overlays, savedObjectsTagging, settings: { uiSettings }, + http, } = pluginServices.getServices(); let globalEmbedSettings: DashboardEmbedSettings | undefined; @@ -172,6 +173,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da core: { application: application as TableListViewApplicationService, notifications, + http, overlays, }, toMountPoint, diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index 9ce1e18e31d82..c70f2c48ffce0 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -32,7 +32,7 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps }) const wrappingComponent: React.FC<{ children: React.ReactNode; }> = ({ children }) => { - const { application, notifications, savedObjectsTagging, overlays } = + const { application, notifications, savedObjectsTagging, http, overlays } = pluginServices.getServices(); return ( @@ -42,6 +42,7 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps }) application: application as unknown as TableListViewKibanaDependencies['core']['application'], notifications, + http, overlays, }} savedObjectsTagging={ diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 1e78b94303478..4752348246b00 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -261,12 +261,22 @@ export const DashboardListing = ({ ]); const fetchItems = useCallback( - (searchTerm: string, references?: SavedObjectsFindOptionsReference[]) => { + ( + searchTerm: string, + { + references, + referencesToExclude, + }: { + references?: SavedObjectsFindOptionsReference[]; + referencesToExclude?: SavedObjectsFindOptionsReference[]; + } = {} + ) => { return findDashboards .findSavedObjects({ search: searchTerm, size: listingLimit, hasReference: references, + hasNoReference: referencesToExclude, }) .then(({ total, hits }) => { return { diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts index 7fb558309936e..f64658802e0e5 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts @@ -50,8 +50,14 @@ export const dashboardSavedObjectServiceFactory: DashboardSavedObjectServiceFact ...requiredServices, }), findDashboards: { - findSavedObjects: ({ hasReference, search, size }) => - findDashboardSavedObjects({ hasReference, search, size, savedObjectsClient }), + findSavedObjects: ({ hasReference, hasNoReference, search, size }) => + findDashboardSavedObjects({ + hasReference, + hasNoReference, + search, + size, + savedObjectsClient, + }), findByIds: (ids) => findDashboardSavedObjectsByIds(savedObjectsClient, ids), findByTitle: (title) => findDashboardIdByTitle(title, savedObjectsClient), }, diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts index c24511f56d3e2..da677c4441941 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts @@ -18,6 +18,7 @@ import type { DashboardAttributes } from '../../../application'; export interface FindDashboardSavedObjectsArgs { hasReference?: SavedObjectsFindOptionsReference[]; + hasNoReference?: SavedObjectsFindOptionsReference[]; savedObjectsClient: SavedObjectsClientContract; search: string; size: number; @@ -31,6 +32,7 @@ export interface FindDashboardSavedObjectsResponse { export async function findDashboardSavedObjects({ savedObjectsClient, hasReference, + hasNoReference, search, size, }: FindDashboardSavedObjectsArgs): Promise { @@ -41,6 +43,7 @@ export async function findDashboardSavedObjects({ defaultSearchOperator: 'AND' as 'AND', perPage: size, hasReference, + hasNoReference, page: 1, }); return { diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts index dd817c751aa8d..f7c00c3d31fb4 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts @@ -53,7 +53,10 @@ export interface DashboardSavedObjectService { ) => Promise; findDashboards: { findSavedObjects: ( - props: Pick + props: Pick< + FindDashboardSavedObjectsArgs, + 'hasReference' | 'hasNoReference' | 'search' | 'size' + > ) => Promise; findByIds: (ids: string[]) => Promise; findByTitle: (title: string) => Promise<{ id: string } | undefined>; diff --git a/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts b/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts index a100282b4cff2..2cb84d5366492 100644 --- a/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts +++ b/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts @@ -33,6 +33,7 @@ export const savedObjectsTaggingServiceFactory: SavedObjectsTaggingServiceFactor updateTagsReferences, getTagIdsFromReferences, getTableColumnDefinition, + getTagList, }, } = taggingApi; @@ -45,5 +46,6 @@ export const savedObjectsTaggingServiceFactory: SavedObjectsTaggingServiceFactor updateTagsReferences, getTagIdsFromReferences, getTableColumnDefinition, + getTagList, }; }; diff --git a/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts b/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts index ba08a53709346..dd4b8bc484504 100644 --- a/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts +++ b/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts @@ -18,4 +18,5 @@ export interface DashboardSavedObjectsTaggingService { updateTagsReferences?: SavedObjectsTaggingApi['ui']['updateTagsReferences']; getTagIdsFromReferences?: SavedObjectsTaggingApi['ui']['getTagIdsFromReferences']; getTableColumnDefinition?: SavedObjectsTaggingApi['ui']['getTableColumnDefinition']; + getTagList?: SavedObjectsTaggingApi['ui']['getTagList']; } diff --git a/src/plugins/guided_onboarding/common/types.ts b/src/plugins/guided_onboarding/common/types.ts new file mode 100644 index 0000000000000..74e0ed38f8142 --- /dev/null +++ b/src/plugins/guided_onboarding/common/types.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 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 { GuideState } from '@kbn/guided-onboarding'; + +/** + * Guided onboarding overall status: + * not_started: no guides have been started yet + * in_progress: a guide is currently active + * complete: at least one guide has been completed + * quit: the user quit a guide before completion + * skipped: the user skipped on the landing page + */ +export type PluginStatus = 'not_started' | 'in_progress' | 'complete' | 'quit' | 'skipped'; + +export interface PluginState { + status: PluginStatus; + // a specific period after deployment creation when guided onboarding UI is highlighted + isActivePeriod: boolean; + activeGuide?: GuideState; +} diff --git a/src/plugins/guided_onboarding/public/components/guide_button.tsx b/src/plugins/guided_onboarding/public/components/guide_button.tsx index 317f760f3a1e9..c4b1458a1323b 100644 --- a/src/plugins/guided_onboarding/public/components/guide_button.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_button.tsx @@ -11,13 +11,15 @@ import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { GuideState } from '@kbn/guided-onboarding'; +import type { PluginState } from '../../common/types'; import { getStepConfig } from '../services/helpers'; import { GuideButtonPopover } from './guide_button_popover'; interface GuideButtonProps { - guideState: GuideState; + pluginState: PluginState | undefined; toggleGuidePanel: () => void; isGuidePanelOpen: boolean; + navigateToLandingPage: () => void; } const getStepNumber = (state: GuideState): number | undefined => { @@ -39,12 +41,45 @@ const getStepNumber = (state: GuideState): number | undefined => { }; export const GuideButton = ({ - guideState, + pluginState, toggleGuidePanel, isGuidePanelOpen, + navigateToLandingPage, }: GuideButtonProps) => { - const stepNumber = getStepNumber(guideState); - const stepReadyToComplete = guideState.steps.find((step) => step.status === 'ready_to_complete'); + // TODO handle loading, error state + // https://github.com/elastic/kibana/issues/139799, https://github.com/elastic/kibana/issues/139798 + + // if there is no active guide + if (!pluginState || !pluginState.activeGuide || !pluginState.activeGuide.isActive) { + // if still active period and the user has not started a guide or skipped the guide, + // display the button that redirects to the landing page + if ( + !( + pluginState?.isActivePeriod && + (pluginState?.status === 'not_started' || pluginState?.status === 'skipped') + ) + ) { + return null; + } else { + return ( + + {i18n.translate('guidedOnboarding.guidedSetupRedirectButtonLabel', { + defaultMessage: 'Setup guide', + })} + + ); + } + } + const stepNumber = getStepNumber(pluginState.activeGuide); + const stepReadyToComplete = pluginState.activeGuide.steps.find( + (step) => step.status === 'ready_to_complete' + ); const button = ( ); if (stepReadyToComplete) { - const stepConfig = getStepConfig(guideState.guideId, stepReadyToComplete.id); + const stepConfig = getStepConfig(pluginState.activeGuide.guideId, stepReadyToComplete.id); // check if the stepConfig has manualCompletion info if (stepConfig && stepConfig.manualCompletion) { return ( diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx index ac2c8e166ed82..e9576b5c61da2 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel.test.tsx @@ -11,77 +11,46 @@ import React from 'react'; import { applicationServiceMock } from '@kbn/core-application-browser-mocks'; import { httpServiceMock } from '@kbn/core/public/mocks'; -import { HttpSetup } from '@kbn/core/public'; -import type { GuideState } from '@kbn/guided-onboarding'; +import type { HttpSetup } from '@kbn/core/public'; +import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; +import type { PluginState } from '../../common/types'; import { guidesConfig } from '../constants/guides_config'; import { apiService } from '../services/api'; +import type { GuidedOnboardingApi } from '../types'; +import { + testGuideStep1ActiveState, + testGuideStep1InProgressState, + testGuideStep2InProgressState, + testGuideStep2ReadyToCompleteState, + testGuideStep3ActiveState, + readyToCompleteGuideState, + mockPluginStateNotStarted, +} from '../services/api.mocks'; import { GuidePanel } from './guide_panel'; -import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; const applicationMock = applicationServiceMock.createStartContract(); -const mockActiveTestGuideState: GuideState = { - guideId: 'testGuide', - isActive: true, - status: 'in_progress', - steps: [ - { - id: 'step1', - status: 'active', - }, - { - id: 'step2', - status: 'inactive', - }, - { - id: 'step3', - status: 'inactive', - }, - ], -}; - -const mockInProgressTestGuideState: GuideState = { - ...mockActiveTestGuideState, - steps: [ - { - ...mockActiveTestGuideState.steps[0], - status: 'in_progress', - }, - mockActiveTestGuideState.steps[1], - mockActiveTestGuideState.steps[2], - ], -}; - -const mockReadyToCompleteTestGuideState: GuideState = { - ...mockActiveTestGuideState, - steps: [ - { - ...mockActiveTestGuideState.steps[0], - status: 'complete', - }, - { - ...mockActiveTestGuideState.steps[1], - status: 'ready_to_complete', - }, - mockActiveTestGuideState.steps[2], - ], +const setupComponentWithPluginStateMock = async ( + httpClient: jest.Mocked, + pluginState: PluginState +) => { + httpClient.get.mockResolvedValue({ + pluginState, + }); + apiService.setup(httpClient, true); + return await setupGuidePanelComponent(apiService); }; -const updateComponentWithState = async ( - component: TestBed['component'], - guideState: GuideState, - isPanelOpen: boolean -) => { +const setupGuidePanelComponent = async (api: GuidedOnboardingApi) => { + let testBed: TestBed; + const GuidePanelComponent = () => ; await act(async () => { - await apiService.updateGuideState(guideState, isPanelOpen); + testBed = registerTestBed(GuidePanelComponent)(); }); - component.update(); -}; - -const getGuidePanel = () => () => { - return ; + testBed!.component.update(); + return testBed!; }; describe('Guided setup', () => { @@ -90,18 +59,8 @@ describe('Guided setup', () => { beforeEach(async () => { httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' }); - // Set default state on initial request (no active guides) - httpClient.get.mockResolvedValue({ - state: [], - }); - apiService.setup(httpClient); - - await act(async () => { - const GuidePanelComponent = getGuidePanel(); - testBed = registerTestBed(GuidePanelComponent)(); - }); - - testBed.component.update(); + // Default state is not started + testBed = await setupComponentWithPluginStateMock(httpClient, mockPluginStateNotStarted); }); afterEach(() => { @@ -109,124 +68,190 @@ describe('Guided setup', () => { }); describe('Button component', () => { - test('should be hidden in there is no guide state', async () => { - const { exists } = testBed; - expect(exists('guideButton')).toBe(false); - expect(exists('guidePanel')).toBe(false); - }); + describe('when a guide is active', () => { + it('button is enabled', async () => { + const { exists, find } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1ActiveState, + }); + expect(exists('guideButton')).toBe(true); + expect(find('guideButton').text()).toEqual('Setup guide'); + expect(exists('guideButtonRedirect')).toBe(false); + }); - test('should be hidden if the guide is not active', async () => { - const { component, exists } = testBed; + test('button shows the step number in the button label if a step is active', async () => { + const { exists, find } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1InProgressState, + }); - await updateComponentWithState( - component, - { ...mockActiveTestGuideState, isActive: false }, - true - ); + expect(exists('guideButton')).toBe(true); + expect(find('guideButton').text()).toEqual('Setup guide: step 1'); + expect(exists('guideButtonRedirect')).toBe(false); + }); - expect(exists('guideButton')).toBe(false); - expect(exists('guidePanel')).toBe(false); - }); + test('shows the step number in the button label if a step is ready to complete', async () => { + const { exists, find } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep2ReadyToCompleteState, + }); - test('should be enabled if there is an active guide', async () => { - const { exists, component, find } = testBed; + expect(exists('guideButton')).toBe(true); + expect(find('guideButton').text()).toEqual('Setup guide: step 2'); + expect(exists('guideButtonRedirect')).toBe(false); + }); - // Enable the "test" guide - await updateComponentWithState(component, mockActiveTestGuideState, true); + test('shows the manual completion popover if a step is ready to complete', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep2ReadyToCompleteState, + }); - expect(exists('guideButton')).toBe(true); - expect(find('guideButton').text()).toEqual('Setup guide'); - }); + expect(exists('manualCompletionPopover')).toBe(true); + }); - test('should show the step number in the button label if a step is active', async () => { - const { component, find } = testBed; + test('shows no manual completion popover if a step is in progress', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1InProgressState, + }); - await updateComponentWithState(component, mockInProgressTestGuideState, true); + expect(exists('manualCompletionPopoverPanel')).toBe(false); + }); - expect(find('guideButton').text()).toEqual('Setup guide: step 1'); + it('shows the button if after the active period', async () => { + const { exists, find } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: false, + activeGuide: testGuideStep1ActiveState, + }); + expect(exists('guideButton')).toBe(true); + expect(find('guideButton').text()).toEqual('Setup guide'); + expect(exists('guideButtonRedirect')).toBe(false); + }); }); - test('shows the step number in the button label if a step is ready to complete', async () => { - const { component, find } = testBed; + describe('when no guide is active', () => { + describe('when in active period', () => { + // mock state is by default { status: 'not_started', isActivePeriod: true } + test('shows redirect button when no guide has been started yet', () => { + const { exists } = testBed; + expect(exists('guideButtonRedirect')).toBe(true); + expect(exists('guideButton')).toBe(false); + }); - await updateComponentWithState(component, mockReadyToCompleteTestGuideState, true); + test('shows redirect button when a user skipped on the landing page', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'skipped', + isActivePeriod: true, + }); - expect(find('guideButton').text()).toEqual('Setup guide: step 2'); - }); + expect(exists('guideButtonRedirect')).toBe(true); + expect(exists('guideButton')).toBe(false); + }); - test('shows the manual completion popover if a step is ready to complete', async () => { - const { component, exists } = testBed; + test('hides redirect button when a user quit the guide', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'quit', + isActivePeriod: true, + }); - await updateComponentWithState(component, mockReadyToCompleteTestGuideState, false); + expect(exists('guideButtonRedirect')).toBe(false); + expect(exists('guideButton')).toBe(false); + }); - expect(exists('manualCompletionPopover')).toBe(true); - }); + test('hides the button if the user completed a guide', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'complete', + isActivePeriod: true, + }); - test('shows no manual completion popover if a step is in progress', async () => { - const { component, exists } = testBed; + expect(exists('guideButtonRedirect')).toBe(false); + expect(exists('guideButton')).toBe(false); + }); + }); + + describe('when not in active period', () => { + test('hides the button if no guide has been started yet', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'not_started', + isActivePeriod: false, + }); + expect(exists('guideButtonRedirect')).toBe(false); + expect(exists('guideButton')).toBe(false); + }); + + test('hides the button if a user quit the guide', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'quit', + isActivePeriod: false, + }); + expect(exists('guideButtonRedirect')).toBe(false); + expect(exists('guideButton')).toBe(false); + }); - await updateComponentWithState(component, mockInProgressTestGuideState, false); + test('hides the button when a user skipped on the landing page', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'skipped', + isActivePeriod: false, + }); + expect(exists('guideButtonRedirect')).toBe(false); + expect(exists('guideButton')).toBe(false); + }); - expect(exists('manualCompletionPopoverPanel')).toBe(false); + test('hides the button if the user completed a guide', async () => { + const { exists } = await setupComponentWithPluginStateMock(httpClient, { + status: 'complete', + isActivePeriod: false, + }); + expect(exists('guideButtonRedirect')).toBe(false); + expect(exists('guideButton')).toBe(false); + }); + }); }); }); describe('Panel component', () => { - test('should be enabled if a guide is activated', async () => { - const { exists, component, find } = testBed; - - await updateComponentWithState(component, mockActiveTestGuideState, true); + test('if a guide is active, the button click opens the panel', async () => { + const { exists, find, component } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1ActiveState, + }); + find('guideButton').simulate('click'); + component.update(); expect(exists('guidePanel')).toBe(true); expect(exists('guideProgress')).toBe(false); expect(find('guidePanelStep').length).toEqual(guidesConfig.testGuide.steps.length); }); - test('should show the progress bar if the first step has been completed', async () => { - const { component, exists } = testBed; - - const mockCompleteTestGuideState: GuideState = { - ...mockActiveTestGuideState, - steps: [ - { - ...mockActiveTestGuideState.steps[0], - status: 'complete', - }, - mockActiveTestGuideState.steps[1], - mockActiveTestGuideState.steps[2], - ], - }; - - await updateComponentWithState(component, mockCompleteTestGuideState, true); + test('shows the progress bar if the first step has been completed', async () => { + const { exists, find, component } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep2InProgressState, + }); + find('guideButton').simulate('click'); + component.update(); expect(exists('guidePanel')).toBe(true); expect(exists('guideProgress')).toBe(true); }); - test('should show the completed state when all steps has been completed', async () => { - const { component, exists, find } = testBed; - - const readyToCompleteGuideState: GuideState = { - guideId: 'testGuide', - status: 'ready_to_complete', - isActive: true, - steps: [ - { - id: 'step1', - status: 'complete', - }, - { - id: 'step2', - status: 'complete', - }, - { - id: 'step3', - status: 'complete', - }, - ], - }; - - await updateComponentWithState(component, readyToCompleteGuideState, true); + test('shows the completed state when all steps has been completed', async () => { + const { exists, find, component } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: { ...readyToCompleteGuideState, status: 'ready_to_complete' }, + }); + find('guideButton').simulate('click'); + component.update(); expect(find('guideTitle').text()).toContain('Well done'); expect(find('guideDescription').text()).toContain( @@ -235,28 +260,30 @@ describe('Guided setup', () => { expect(exists('onboarding--completeGuideButton--testGuide')).toBe(true); }); - test('should not show the completed state when the last step is not marked as complete', async () => { - const { component, exists, find } = testBed; - - const mockCompleteTestGuideState: GuideState = { - ...mockActiveTestGuideState, - steps: [ - { - id: mockActiveTestGuideState.steps[0].id, - status: 'complete', - }, - { - id: mockActiveTestGuideState.steps[1].id, - status: 'complete', - }, - { - id: mockActiveTestGuideState.steps[2].id, - status: 'complete', - }, - ], - }; - - await updateComponentWithState(component, mockCompleteTestGuideState, true); + test(`doesn't show the completed state when the last step is not marked as complete`, async () => { + const { exists, find, component } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: { + ...testGuideStep1ActiveState, + steps: [ + { + ...testGuideStep1ActiveState.steps[0], + status: 'complete', + }, + { + ...testGuideStep1ActiveState.steps[1], + status: 'complete', + }, + { + ...testGuideStep1ActiveState.steps[2], + status: 'ready_to_complete', + }, + ], + }, + }); + find('guideButton').simulate('click'); + component.update(); expect(find('guideTitle').text()).not.toContain('Well done'); expect(find('guideDescription').text()).not.toContain( @@ -283,9 +310,21 @@ describe('Guided setup', () => { }; test('can start a step if step has not been started', async () => { - const { component, find, exists } = testBed; - - await updateComponentWithState(component, mockActiveTestGuideState, true); + httpClient.put.mockResolvedValueOnce({ + pluginState: { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1InProgressState, + }, + }); + testBed = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1ActiveState, + }); + const { exists, find, component } = testBed; + find('guideButton').simulate('click'); + component.update(); expect(find('onboarding--stepButton--testGuide--step1').text()).toEqual('Start'); @@ -295,9 +334,21 @@ describe('Guided setup', () => { }); test('can continue a step if step is in progress', async () => { - const { component, find, exists } = testBed; - - await updateComponentWithState(component, mockInProgressTestGuideState, true); + httpClient.put.mockResolvedValueOnce({ + pluginState: { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1InProgressState, + }, + }); + testBed = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1InProgressState, + }); + const { exists, find, component } = testBed; + find('guideButton').simulate('click'); + component.update(); expect(find('onboarding--stepButton--testGuide--step1').text()).toEqual('Continue'); @@ -307,9 +358,21 @@ describe('Guided setup', () => { }); test('can mark a step "done" if step is ready to complete', async () => { - const { component, find, exists } = testBed; - - await updateComponentWithState(component, mockReadyToCompleteTestGuideState, true); + httpClient.put.mockResolvedValueOnce({ + pluginState: { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep3ActiveState, + }, + }); + testBed = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep2ReadyToCompleteState, + }); + const { exists, find, component } = testBed; + find('guideButton').simulate('click'); + component.update(); expect(find('onboarding--stepButton--testGuide--step2').text()).toEqual('Mark done'); @@ -317,38 +380,18 @@ describe('Guided setup', () => { // The guide panel should remain open after marking a step done expect(exists('guidePanel')).toBe(true); - // Dependent on the Search guide config, which expects step 3 to start + // Dependent on the Test guide config, which expects step 3 to start expect(find('onboarding--stepButton--testGuide--step3').text()).toEqual('Start'); }); - test('should render the step description as a paragraph if it is only one sentence', async () => { - const { component, find } = testBed; - - const mockSingleSentenceStepDescriptionGuideState: GuideState = { - guideId: 'testGuide', - isActive: true, + test('renders the step description as a paragraph', async () => { + const { find, component } = await setupComponentWithPluginStateMock(httpClient, { status: 'in_progress', - steps: [ - { - id: 'step1', - status: 'complete', - }, - { - id: 'step2', - status: 'complete', - }, - { - id: 'step3', - status: 'in_progress', - }, - ], - }; - - await updateComponentWithState( - component, - mockSingleSentenceStepDescriptionGuideState, - true - ); + isActivePeriod: true, + activeGuide: testGuideStep3ActiveState, + }); + find('guideButton').simulate('click'); + component.update(); expect( find('guidePanelStepDescription') @@ -357,10 +400,14 @@ describe('Guided setup', () => { ).toBe(true); }); - test('should render the step description as an unordered list if it is more than one sentence', async () => { - const { component, find } = testBed; - - await updateComponentWithState(component, mockActiveTestGuideState, true); + test('renders the step description list as an unordered list', async () => { + const { find, component } = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1ActiveState, + }); + find('guideButton').simulate('click'); + component.update(); expect( find('guidePanelStepDescription') @@ -378,13 +425,14 @@ describe('Guided setup', () => { describe('Quit guide modal', () => { beforeEach(async () => { - const { component, find, exists } = testBed; - - await act(async () => { - // Enable the "test" guide - await apiService.updateGuideState(mockActiveTestGuideState, true); + testBed = await setupComponentWithPluginStateMock(httpClient, { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1ActiveState, }); + const { find, component, exists } = testBed; + find('guideButton').simulate('click'); component.update(); await act(async () => { @@ -406,11 +454,15 @@ describe('Guided setup', () => { component.update(); expect(exists('onboarding--quitGuideModal')).toBe(false); - - // TODO check for the correct button behavior once https://github.com/elastic/kibana/issues/141129 is implemented }); test('cancels out of the quit guide confirmation modal', async () => { + httpClient.put.mockResolvedValueOnce({ + pluginState: { + status: 'quit', + isActivePeriod: true, + }, + }); const { component, find, exists } = testBed; await act(async () => { diff --git a/src/plugins/guided_onboarding/public/components/guide_panel.tsx b/src/plugins/guided_onboarding/public/components/guide_panel.tsx index ce64909b36aef..2f62b80fca852 100644 --- a/src/plugins/guided_onboarding/public/components/guide_panel.tsx +++ b/src/plugins/guided_onboarding/public/components/guide_panel.tsx @@ -32,9 +32,9 @@ import { ApplicationStart } from '@kbn/core/public'; import type { GuideState, GuideStep as GuideStepStatus } from '@kbn/guided-onboarding'; import { GuideId } from '@kbn/guided-onboarding'; -import type { GuideConfig, StepConfig } from '../types'; +import type { GuideConfig, GuidedOnboardingApi, StepConfig } from '../types'; -import type { ApiService } from '../services/api'; +import type { PluginState } from '../../common/types'; import { getGuideConfig } from '../services/helpers'; import { GuideStep } from './guide_panel_step'; @@ -43,7 +43,7 @@ import { getGuidePanelStyles } from './guide_panel.styles'; import { GuideButton } from './guide_button'; interface GuidePanelProps { - api: ApiService; + api: GuidedOnboardingApi; application: ApplicationStart; } @@ -61,7 +61,7 @@ const getProgress = (state?: GuideState): number => { // Temporarily provide a different guide ID for telemetry purposes // Should not be necessary once https://github.com/elastic/kibana/issues/144452 is addressed -const getTelemetryGuideId = (guideId: GuideId) => { +const getTelemetryGuideId = (guideId?: GuideId) => { switch (guideId) { case 'security': return 'siem'; @@ -77,7 +77,7 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { const { euiTheme } = useEuiTheme(); const [isGuideOpen, setIsGuideOpen] = useState(false); const [isQuitGuideModalOpen, setIsQuitGuideModalOpen] = useState(false); - const [guideState, setGuideState] = useState(undefined); + const [pluginState, setPluginState] = useState(undefined); const styles = getGuidePanelStyles(euiTheme); @@ -86,15 +86,16 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { }; const handleStepButtonClick = async (step: GuideStepStatus, stepConfig: StepConfig) => { - if (guideState) { + if (pluginState) { const { id, status } = step; + const guideId: GuideId = pluginState!.activeGuide!.guideId!; if (status === 'ready_to_complete') { - return await api.completeGuideStep(guideState?.guideId, id); + return await api.completeGuideStep(guideId, id); } if (status === 'active' || status === 'in_progress') { - await api.startGuideStep(guideState!.guideId, id); + await api.startGuideStep(guideId, id); if (stepConfig.location) { await application.navigateToApp(stepConfig.location.appID, { @@ -102,7 +103,7 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { }); if (stepConfig.manualCompletion?.readyToCompleteOnNavigation) { - await api.completeGuideStep(guideState.guideId, id); + await api.completeGuideStep(guideId, id); } } } @@ -117,7 +118,7 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { const completeGuide = async ( completedGuideRedirectLocation: GuideConfig['completedGuideRedirectLocation'] ) => { - await api.completeGuide(guideState!.guideId); + await api.completeGuide(pluginState!.activeGuide!.guideId!); if (completedGuideRedirectLocation) { const { appID, path } = completedGuideRedirectLocation; @@ -137,8 +138,8 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { }; useEffect(() => { - const subscription = api.fetchActiveGuideState$().subscribe((newGuideState) => { - setGuideState(newGuideState); + const subscription = api.fetchPluginState$().subscribe((newPluginState) => { + setPluginState(newPluginState); }); return () => subscription.unsubscribe(); }, [api]); @@ -150,25 +151,22 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { return () => subscription.unsubscribe(); }, [api]); - const guideConfig = getGuideConfig(guideState?.guideId); + const guideConfig = getGuideConfig(pluginState?.activeGuide?.guideId)!; // TODO handle loading, error state // https://github.com/elastic/kibana/issues/139799, https://github.com/elastic/kibana/issues/139798 - if (!guideConfig || !guideState || !guideState.isActive) { - // TODO button show/hide logic https://github.com/elastic/kibana/issues/141129 - return null; - } - const stepsCompleted = getProgress(guideState); - const isGuideReadyToComplete = guideState?.status === 'ready_to_complete'; - const telemetryGuideId = getTelemetryGuideId(guideState.guideId); + const stepsCompleted = getProgress(pluginState?.activeGuide); + const isGuideReadyToComplete = pluginState?.activeGuide?.status === 'ready_to_complete'; + const telemetryGuideId = getTelemetryGuideId(pluginState?.activeGuide?.guideId); return ( <> {isGuideOpen && ( @@ -270,7 +268,7 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { {guideConfig?.steps.map((step, index) => { const accordionId = htmlIdGenerator(`accordion${index}`)(); - const stepState = guideState?.steps[index]; + const stepState = pluginState?.activeGuide?.steps[index]; if (stepState) { return ( @@ -281,7 +279,7 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { stepNumber={index + 1} handleButtonClick={() => handleStepButtonClick(stepState, step)} key={accordionId} - telemetryGuideId={telemetryGuideId} + telemetryGuideId={telemetryGuideId!} /> ); } @@ -374,8 +372,8 @@ export const GuidePanel = ({ api, application }: GuidePanelProps) => { {isQuitGuideModalOpen && ( )} diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts b/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts index fead791d02ed1..8a0e13cb367ac 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/test_guide.ts @@ -12,6 +12,10 @@ export const testGuideConfig: GuideConfig = { title: 'Test guide for development', description: `This guide is used to test the guided onboarding UI while in development and to run automated tests for the API and UI components.`, guideName: 'Testing example', + completedGuideRedirectLocation: { + appID: 'guidedOnboardingExample', + path: '/', + }, docs: { text: 'Testing example docs', url: 'example.com', diff --git a/src/plugins/guided_onboarding/public/mocks.tsx b/src/plugins/guided_onboarding/public/mocks.ts similarity index 86% rename from src/plugins/guided_onboarding/public/mocks.tsx rename to src/plugins/guided_onboarding/public/mocks.ts index dcac2cfc1c0fb..99eca4d12cd23 100644 --- a/src/plugins/guided_onboarding/public/mocks.tsx +++ b/src/plugins/guided_onboarding/public/mocks.ts @@ -12,16 +12,18 @@ import { GuidedOnboardingPluginStart } from '.'; const apiServiceMock: jest.Mocked = { guidedOnboardingApi: { setup: jest.fn(), - fetchActiveGuideState$: () => new BehaviorSubject(undefined), + fetchPluginState$: () => new BehaviorSubject(undefined), fetchAllGuidesState: jest.fn(), - updateGuideState: jest.fn(), + updatePluginState: jest.fn(), activateGuide: jest.fn(), + deactivateGuide: jest.fn(), completeGuide: jest.fn(), isGuideStepActive$: () => new BehaviorSubject(false), startGuideStep: jest.fn(), completeGuideStep: jest.fn(), isGuidedOnboardingActiveForIntegration$: () => new BehaviorSubject(false), completeGuidedOnboardingForIntegration: jest.fn(), + skipGuidedOnboarding: jest.fn(), isGuidePanelOpen$: new BehaviorSubject(false), }, }; diff --git a/src/plugins/guided_onboarding/public/plugin.tsx b/src/plugins/guided_onboarding/public/plugin.tsx index 4cf5fa9749a07..d4c6e6892c432 100755 --- a/src/plugins/guided_onboarding/public/plugin.tsx +++ b/src/plugins/guided_onboarding/public/plugin.tsx @@ -36,7 +36,7 @@ export class GuidedOnboardingPlugin const { chrome, http, theme, application } = core; // Initialize services - apiService.setup(http); + apiService.setup(http, !!cloud?.isCloudEnabled); // Guided onboarding UI is only available on cloud if (cloud?.isCloudEnabled) { diff --git a/src/plugins/guided_onboarding/public/services/api.mocks.ts b/src/plugins/guided_onboarding/public/services/api.mocks.ts index 47b7f6c9900e0..19cb489a3fbdb 100644 --- a/src/plugins/guided_onboarding/public/services/api.mocks.ts +++ b/src/plugins/guided_onboarding/public/services/api.mocks.ts @@ -8,6 +8,8 @@ import type { GuideState, GuideId, GuideStepIds } from '@kbn/guided-onboarding'; +import { PluginState } from '../../common/types'; + export const testGuide: GuideId = 'testGuide'; export const testGuideFirstStep: GuideStepIds = 'step1'; export const testGuideManualCompletionStep = 'step2'; @@ -77,6 +79,39 @@ export const testGuideStep2InProgressState: GuideState = { ], }; +export const testGuideStep2ReadyToCompleteState: GuideState = { + ...testGuideStep1ActiveState, + steps: [ + { + ...testGuideStep1ActiveState.steps[0], + status: 'complete', + }, + { + id: testGuideStep1ActiveState.steps[1].id, + status: 'ready_to_complete', + }, + testGuideStep1ActiveState.steps[2], + ], +}; + +export const testGuideStep3ActiveState: GuideState = { + ...testGuideStep1ActiveState, + steps: [ + { + ...testGuideStep1ActiveState.steps[0], + status: 'complete', + }, + { + id: testGuideStep1ActiveState.steps[1].id, + status: 'complete', + }, + { + id: testGuideStep1ActiveState.steps[2].id, + status: 'active', + }, + ], +}; + export const readyToCompleteGuideState: GuideState = { ...testGuideStep1ActiveState, steps: [ @@ -99,3 +134,14 @@ export const testGuideNotActiveState: GuideState = { ...testGuideStep1ActiveState, isActive: false, }; + +export const mockPluginStateNotStarted: PluginState = { + status: 'not_started', + isActivePeriod: true, +}; + +export const mockPluginStateInProgress: PluginState = { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1ActiveState, +}; diff --git a/src/plugins/guided_onboarding/public/services/api.test.ts b/src/plugins/guided_onboarding/public/services/api.test.ts index b6efc346fc7d5..81f874926ecda 100644 --- a/src/plugins/guided_onboarding/public/services/api.test.ts +++ b/src/plugins/guided_onboarding/public/services/api.test.ts @@ -11,7 +11,6 @@ import { httpServiceMock } from '@kbn/core/public/mocks'; import type { GuideState } from '@kbn/guided-onboarding'; import { firstValueFrom, Subscription } from 'rxjs'; -import { GuideStatus } from '@kbn/guided-onboarding'; import { API_BASE_PATH } from '../../common/constants'; import { ApiService } from './api'; import { @@ -27,6 +26,9 @@ import { wrongIntegration, testGuideStep2InProgressState, readyToCompleteGuideState, + mockPluginStateInProgress, + mockPluginStateNotStarted, + testGuideStep3ActiveState, } from './api.mocks'; describe('GuidedOnboarding ApiService', () => { @@ -38,10 +40,13 @@ describe('GuidedOnboarding ApiService', () => { beforeEach(() => { httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' }); httpClient.get.mockResolvedValue({ - state: [testGuideStep1ActiveState], + pluginState: mockPluginStateInProgress, + }); + httpClient.put.mockResolvedValue({ + pluginState: mockPluginStateInProgress, }); apiService = new ApiService(); - apiService.setup(httpClient); + apiService.setup(httpClient, true); }); afterEach(() => { @@ -50,181 +55,94 @@ describe('GuidedOnboarding ApiService', () => { jest.restoreAllMocks(); }); - describe('fetchActiveGuideState$', () => { - it('sends a request to the get API', () => { - subscription = apiService.fetchActiveGuideState$().subscribe(); + describe('fetchPluginState$', () => { + it('sends a request to the get state API', () => { + subscription = apiService.fetchPluginState$().subscribe(); expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - query: { active: true }, signal: new AbortController().signal, }); }); it(`doesn't send multiple requests when there are several subscriptions`, () => { - subscription = apiService.fetchActiveGuideState$().subscribe(); - anotherSubscription = apiService.fetchActiveGuideState$().subscribe(); + subscription = apiService.fetchPluginState$().subscribe(); + anotherSubscription = apiService.fetchPluginState$().subscribe(); expect(httpClient.get).toHaveBeenCalledTimes(1); }); it(`re-sends the request if the previous one failed`, async () => { httpClient.get.mockRejectedValueOnce(new Error('request failed')); - subscription = apiService.fetchActiveGuideState$().subscribe(); + subscription = apiService.fetchPluginState$().subscribe(); // wait until the request fails await new Promise((resolve) => process.nextTick(resolve)); - anotherSubscription = apiService.fetchActiveGuideState$().subscribe(); + anotherSubscription = apiService.fetchPluginState$().subscribe(); expect(httpClient.get).toHaveBeenCalledTimes(2); }); - it(`doesn't re-send the request if there is no guide state and there is another subscription`, async () => { - httpClient.get.mockResolvedValueOnce({ - state: [], - }); - subscription = apiService.fetchActiveGuideState$().subscribe(); - // wait until the request completes - await new Promise((resolve) => process.nextTick(resolve)); - anotherSubscription = apiService.fetchActiveGuideState$().subscribe(); - expect(httpClient.get).toHaveBeenCalledTimes(1); - }); - - it(`doesn't send multiple requests in a loop when there is no state`, async () => { - httpClient.get.mockResolvedValueOnce({ - state: [], - }); - subscription = apiService.fetchActiveGuideState$().subscribe(); - // wait until the request completes - await new Promise((resolve) => process.nextTick(resolve)); - expect(httpClient.get).toHaveBeenCalledTimes(1); - }); - it(`re-sends the request if the subscription was unsubscribed before the request completed`, async () => { httpClient.get.mockImplementationOnce(() => { return new Promise((resolve) => setTimeout(resolve)); }); // subscribe and immediately unsubscribe - apiService.fetchActiveGuideState$().subscribe().unsubscribe(); - anotherSubscription = apiService.fetchActiveGuideState$().subscribe(); + apiService.fetchPluginState$().subscribe().unsubscribe(); + anotherSubscription = apiService.fetchPluginState$().subscribe(); expect(httpClient.get).toHaveBeenCalledTimes(2); }); it(`the second subscription gets the state broadcast to it`, (done) => { // first subscription - apiService.fetchActiveGuideState$().subscribe(); + apiService.fetchPluginState$().subscribe(); // second subscription - anotherSubscription = apiService.fetchActiveGuideState$().subscribe((state) => { + anotherSubscription = apiService.fetchPluginState$().subscribe((state) => { if (state) { done(); } }); }); - - it('broadcasts the updated state', async () => { - await apiService.activateGuide(testGuide, testGuideStep1ActiveState); - - const state = await firstValueFrom(apiService.fetchActiveGuideState$()); - expect(state).toEqual(testGuideStep1ActiveState); - }); }); describe('fetchAllGuidesState', () => { - it('sends a request to the get API', async () => { + it('sends a request to the get guide API', async () => { await apiService.fetchAllGuidesState(); expect(httpClient.get).toHaveBeenCalledTimes(1); - expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/state`); + expect(httpClient.get).toHaveBeenCalledWith(`${API_BASE_PATH}/guides`); }); }); - describe('deactivateGuide', () => { - it('deactivates an existing guide', async () => { - await apiService.deactivateGuide(testGuideStep1ActiveState); - + describe('updatePluginState', () => { + it('sends a request to the put state API when updating the guide', async () => { + await apiService.updatePluginState({ guide: testGuideStep1InProgressState }, false); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify({ - ...testGuideStep1ActiveState, - isActive: false, - }), + body: JSON.stringify({ guide: testGuideStep1InProgressState }), }); }); - }); - describe('updateGuideState', () => { - it('sends a request to the put API', async () => { - const updatedState: GuideState = testGuideStep1InProgressState; - await apiService.updateGuideState(updatedState, false); + it('sends a request to the put state API when updating the status', async () => { + await apiService.updatePluginState({ status: 'quit' }, false); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify(updatedState), + body: JSON.stringify({ status: 'quit' }), }); }); - - it('the completed state is being broadcast after the update', async () => { - const completedState = { - ...readyToCompleteGuideState, - isActive: false, - status: 'complete' as GuideStatus, - }; - await apiService.updateGuideState(completedState, false); - const state = await firstValueFrom(apiService.fetchActiveGuideState$()); - expect(state).toMatchObject(completedState); - }); - }); - - describe('isGuideStepActive$', () => { - it('returns true if the step has been started', (done) => { - httpClient.get.mockResolvedValueOnce({ - state: [testGuideStep1InProgressState], - }); - - subscription = apiService - .isGuideStepActive$(testGuide, testGuideFirstStep) - .subscribe((isStepActive) => { - if (isStepActive) { - done(); - } - }); - }); - - it('returns false if the step is not been started', (done) => { - subscription = apiService - .isGuideStepActive$(testGuide, testGuideFirstStep) - .subscribe((isStepActive) => { - if (!isStepActive) { - done(); - } - }); - }); - - it(`doesn't duplicate requests when there are several subscriptions and no guide state`, async () => { - httpClient.get.mockResolvedValue({ - state: [], - }); - apiService.setup(httpClient); - - subscription = apiService.isGuideStepActive$(testGuide, testGuideFirstStep).subscribe(); - - // wait for the get request to resolve - await new Promise((resolve) => process.nextTick(resolve)); - anotherSubscription = apiService - .isGuideStepActive$(testGuide, testGuideFirstStep) - .subscribe(); - - expect(httpClient.get).toHaveBeenCalledTimes(1); - }); }); describe('activateGuide', () => { it('activates a new guide', async () => { // update the mock to no active guides httpClient.get.mockResolvedValue({ - state: [], + pluginState: mockPluginStateNotStarted, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); await apiService.activateGuide(testGuide); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify({ ...testGuideStep1ActiveState, status: 'not_started' }), + body: JSON.stringify({ + status: 'in_progress', + guide: { ...testGuideStep1ActiveState, status: 'not_started' }, + }), }); }); @@ -233,7 +151,27 @@ describe('GuidedOnboarding ApiService', () => { expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify(testGuideStep1ActiveState), + body: JSON.stringify({ + status: 'in_progress', + guide: testGuideStep1ActiveState, + }), + }); + }); + }); + + describe('deactivateGuide', () => { + it('deactivates an existing guide', async () => { + await apiService.deactivateGuide(testGuideStep1ActiveState); + + expect(httpClient.put).toHaveBeenCalledTimes(1); + expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { + body: JSON.stringify({ + status: 'quit', + guide: { + ...testGuideStep1ActiveState, + isActive: false, + }, + }), }); }); }); @@ -241,23 +179,40 @@ describe('GuidedOnboarding ApiService', () => { describe('completeGuide', () => { beforeEach(async () => { httpClient.get.mockResolvedValue({ - state: [readyToCompleteGuideState], + pluginState: { + ...mockPluginStateInProgress, + activeGuide: readyToCompleteGuideState, + }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); }); it('updates the selected guide and marks it as complete', async () => { await apiService.completeGuide(testGuide); + expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ - ...readyToCompleteGuideState, - isActive: false, status: 'complete', + guide: { + ...readyToCompleteGuideState, + isActive: false, + status: 'complete', + }, }), }); }); + it('the completed state is being broadcast after the update', async () => { + httpClient.put.mockResolvedValueOnce({ + // mock the put api response + pluginState: { status: 'complete', isActivePeriod: true }, + }); + await apiService.completeGuide(testGuide); + const updateState = await firstValueFrom(apiService.fetchPluginState$()); + expect(updateState?.status).toBe('complete'); + }); + it('returns undefined if the selected guide is not active', async () => { const completedState = await apiService.completeGuide('observability'); // not active expect(completedState).not.toBeDefined(); @@ -284,25 +239,44 @@ describe('GuidedOnboarding ApiService', () => { httpClient.get.mockResolvedValue({ state: [incompleteGuideState], }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); const completedState = await apiService.completeGuide(testGuide); expect(completedState).not.toBeDefined(); }); }); - describe('startGuideStep', () => { - beforeEach(async () => { - httpClient.get.mockResolvedValue({ - state: [testGuideStep1ActiveState], + describe('isGuideStepActive$', () => { + it('returns true if the step has been started', (done) => { + httpClient.get.mockResolvedValueOnce({ + pluginState: { ...mockPluginStateInProgress, activeGuide: testGuideStep1InProgressState }, }); - apiService.setup(httpClient); + + subscription = apiService + .isGuideStepActive$(testGuide, testGuideFirstStep) + .subscribe((isStepActive) => { + if (isStepActive) { + done(); + } + }); }); + it('returns false if the step is not been started', (done) => { + subscription = apiService + .isGuideStepActive$(testGuide, testGuideFirstStep) + .subscribe((isStepActive) => { + if (!isStepActive) { + done(); + } + }); + }); + }); + + describe('startGuideStep', () => { it('updates the selected step and marks it as in_progress', async () => { await apiService.startGuideStep(testGuide, testGuideFirstStep); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify(testGuideStep1InProgressState), + body: JSON.stringify({ guide: testGuideStep1InProgressState }), }); }); @@ -315,24 +289,24 @@ describe('GuidedOnboarding ApiService', () => { describe('completeGuideStep', () => { it(`completes the step when it's in progress`, async () => { httpClient.get.mockResolvedValue({ - state: [testGuideStep1InProgressState], + pluginState: { ...mockPluginStateInProgress, activeGuide: testGuideStep1InProgressState }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); await apiService.completeGuideStep(testGuide, testGuideFirstStep); expect(httpClient.put).toHaveBeenCalledTimes(1); // Verify the completed step now has a "complete" status, and the subsequent step is "active" expect(httpClient.put).toHaveBeenLastCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify({ ...testGuideStep2ActiveState }), + body: JSON.stringify({ guide: { ...testGuideStep2ActiveState } }), }); }); it(`marks the step as 'ready_to_complete' if it's configured for manual completion`, async () => { httpClient.get.mockResolvedValue({ - state: [testGuideStep2InProgressState], + pluginState: { ...mockPluginStateInProgress, activeGuide: testGuideStep2InProgressState }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); await apiService.completeGuideStep(testGuide, testGuideManualCompletionStep); @@ -340,40 +314,32 @@ describe('GuidedOnboarding ApiService', () => { // Verify the completed step now has a "ready_to_complete" status, and the subsequent step is "inactive" expect(httpClient.put).toHaveBeenLastCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ - ...testGuideStep2InProgressState, - steps: [ - testGuideStep2InProgressState.steps[0], - { ...testGuideStep2InProgressState.steps[1], status: 'ready_to_complete' }, - testGuideStep2InProgressState.steps[2], - ], + guide: { + ...testGuideStep2InProgressState, + steps: [ + testGuideStep2InProgressState.steps[0], + { ...testGuideStep2InProgressState.steps[1], status: 'ready_to_complete' }, + testGuideStep2InProgressState.steps[2], + ], + }, }), }); }); - it('returns undefined if the selected guide is not active', async () => { - const startState = await apiService.completeGuideStep('observability', 'add_data'); // not active - expect(startState).not.toBeDefined(); - }); - - it('does nothing if the step is not in progress', async () => { - // by default the state set in beforeEach is test guide, step 1 active - await apiService.completeGuideStep(testGuide, testGuideFirstStep); - expect(httpClient.put).toHaveBeenCalledTimes(0); - }); - it('marks the guide as "ready_to_complete" if the current step is the last step in the guide and configured for manual completion', async () => { - const testGuideStep3InProgressState: GuideState = { - ...testGuideStep2ActiveState, - steps: [ - testGuideStep2ActiveState.steps[0], - { ...testGuideStep2ActiveState.steps[1], status: 'complete' }, - { ...testGuideStep2ActiveState.steps[2], status: 'ready_to_complete' }, - ], - }; httpClient.get.mockResolvedValue({ - state: [testGuideStep3InProgressState], + pluginState: { + ...mockPluginStateInProgress, + activeGuide: { + ...testGuideStep3ActiveState, + steps: [ + ...testGuideStep3ActiveState.steps.slice(0, 2), + { ...testGuideStep3ActiveState.steps[2], status: 'ready_to_complete' }, + ], + }, + }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); await apiService.completeGuideStep(testGuide, testGuideLastStep); @@ -381,21 +347,26 @@ describe('GuidedOnboarding ApiService', () => { // Verify the guide now has a "ready_to_complete" status and the last step is "complete" expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ - ...testGuideStep3InProgressState, - steps: [ - ...testGuideStep3InProgressState.steps.slice(0, 2), - { ...testGuideStep3InProgressState.steps[2], status: 'complete' }, - ], - status: 'ready_to_complete', + guide: { + ...testGuideStep3ActiveState, + status: 'ready_to_complete', + steps: [ + ...testGuideStep3ActiveState.steps.slice(0, 2), + { ...testGuideStep3ActiveState.steps[2], status: 'complete' }, + ], + }, }), }); }); it('marks the guide as "in_progress" if the current step is not the last step in the guide', async () => { httpClient.get.mockResolvedValue({ - state: [testGuideStep1InProgressState], + pluginState: { + ...mockPluginStateInProgress, + activeGuide: testGuideStep1InProgressState, + }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); await apiService.completeGuideStep(testGuide, testGuideFirstStep); @@ -403,24 +374,36 @@ describe('GuidedOnboarding ApiService', () => { // Verify the guide now has a "in_progress" status and the second step is "active" expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ - ...testGuideStep2ActiveState, - steps: [ - testGuideStep2ActiveState.steps[0], - { ...testGuideStep2ActiveState.steps[1], status: 'active' }, - testGuideStep2ActiveState.steps[2], - ], - status: 'in_progress', + guide: { + ...testGuideStep3ActiveState, + steps: [ + testGuideStep2ActiveState.steps[0], + { ...testGuideStep2ActiveState.steps[1], status: 'active' }, + testGuideStep2ActiveState.steps[2], + ], + }, }), }); }); + + it('does nothing if the step is not in progress', async () => { + // by default the state set in beforeEach is test guide, step 1 active + await apiService.completeGuideStep(testGuide, testGuideFirstStep); + expect(httpClient.put).toHaveBeenCalledTimes(0); + }); + + it('returns undefined if the selected guide is not active', async () => { + const startState = await apiService.completeGuideStep('observability', 'add_data'); // not active + expect(startState).not.toBeDefined(); + }); }); describe('isGuidedOnboardingActiveForIntegration$', () => { it('returns true if the integration is part of the active step', (done) => { httpClient.get.mockResolvedValue({ - state: [testGuideStep1InProgressState], + pluginState: { ...mockPluginStateInProgress, activeGuide: testGuideStep1InProgressState }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); subscription = apiService .isGuidedOnboardingActiveForIntegration$(testIntegration) .subscribe((isIntegrationInGuideStep) => { @@ -432,9 +415,9 @@ describe('GuidedOnboarding ApiService', () => { it('returns false if the current step has a different integration', (done) => { httpClient.get.mockResolvedValue({ - state: [testGuideStep1InProgressState], + pluginState: { ...mockPluginStateInProgress, activeGuide: testGuideStep1InProgressState }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); subscription = apiService .isGuidedOnboardingActiveForIntegration$(wrongIntegration) .subscribe((isIntegrationInGuideStep) => { @@ -446,9 +429,9 @@ describe('GuidedOnboarding ApiService', () => { it('returns false if no guide is active', (done) => { httpClient.get.mockResolvedValue({ - state: [testGuideNotActiveState], + pluginState: { ...mockPluginStateNotStarted, activeGuide: testGuideNotActiveState }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); subscription = apiService .isGuidedOnboardingActiveForIntegration$(testIntegration) .subscribe((isIntegrationInGuideStep) => { @@ -462,23 +445,23 @@ describe('GuidedOnboarding ApiService', () => { describe('completeGuidedOnboardingForIntegration', () => { it(`completes the step if it's active for the integration`, async () => { httpClient.get.mockResolvedValue({ - state: [testGuideStep1InProgressState], + pluginState: { ...mockPluginStateInProgress, activeGuide: testGuideStep1InProgressState }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); await apiService.completeGuidedOnboardingForIntegration(testIntegration); expect(httpClient.put).toHaveBeenCalledTimes(1); // this assertion depends on the guides config expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify(testGuideStep2ActiveState), + body: JSON.stringify({ guide: testGuideStep2ActiveState }), }); }); it(`does nothing if the step has a different integration`, async () => { httpClient.get.mockResolvedValue({ - state: [testGuideStep1InProgressState], + pluginState: { ...mockPluginStateInProgress, activeGuide: testGuideStep1InProgressState }, }); - apiService.setup(httpClient); + apiService.setup(httpClient, true); await apiService.completeGuidedOnboardingForIntegration(wrongIntegration); expect(httpClient.put).not.toHaveBeenCalled(); @@ -486,12 +469,32 @@ describe('GuidedOnboarding ApiService', () => { it(`does nothing if no guide is active`, async () => { httpClient.get.mockResolvedValue({ - state: [testGuideNotActiveState], + pluginState: { ...mockPluginStateNotStarted, activeGuide: testGuideNotActiveState }, }); - apiService.setup(httpClient); await apiService.completeGuidedOnboardingForIntegration(testIntegration); expect(httpClient.put).not.toHaveBeenCalled(); }); }); + + describe('no API requests are sent on self-managed deployments', () => { + beforeEach(() => { + apiService.setup(httpClient, false); + }); + + it('fetchPluginState$', () => { + subscription = apiService.fetchPluginState$().subscribe(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('fetchAllGuidesState', async () => { + await apiService.fetchAllGuidesState(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + + it('updatePluginState', async () => { + await apiService.updatePluginState({}, false); + expect(httpClient.put).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/plugins/guided_onboarding/public/services/api.ts b/src/plugins/guided_onboarding/public/services/api.ts index 41815a71840c5..4b5505482592a 100644 --- a/src/plugins/guided_onboarding/public/services/api.ts +++ b/src/plugins/guided_onboarding/public/services/api.ts @@ -7,7 +7,7 @@ */ import { HttpSetup } from '@kbn/core/public'; -import { BehaviorSubject, map, Observable, firstValueFrom, concat } from 'rxjs'; +import { BehaviorSubject, map, Observable, firstValueFrom, concat, of } from 'rxjs'; import type { GuideState, GuideId, GuideStep, GuideStepIds } from '@kbn/guided-onboarding'; import { GuidedOnboardingApi } from '../types'; @@ -20,69 +20,72 @@ import { isIntegrationInGuideStep, isStepInProgress, isStepReadyToComplete, + isGuideActive, } from './helpers'; import { API_BASE_PATH } from '../../common/constants'; +import { PluginState, PluginStatus } from '../../common/types'; export class ApiService implements GuidedOnboardingApi { + private isCloudEnabled: boolean | undefined; private client: HttpSetup | undefined; - private guideState$!: BehaviorSubject; - private isGuideStateLoading: boolean | undefined; - private isGuideStateInitialized: boolean | undefined; + private pluginState$!: BehaviorSubject; + private isPluginStateLoading: boolean | undefined; public isGuidePanelOpen$: BehaviorSubject = new BehaviorSubject(false); - public setup(httpClient: HttpSetup): void { + public setup(httpClient: HttpSetup, isCloudEnabled: boolean) { + this.isCloudEnabled = isCloudEnabled; this.client = httpClient; - this.guideState$ = new BehaviorSubject(undefined); + this.pluginState$ = new BehaviorSubject(undefined); + this.isGuidePanelOpen$ = new BehaviorSubject(false); } - private createGetStateObservable(): Observable { - return new Observable((observer) => { + private createGetPluginStateObservable(): Observable { + return new Observable((observer) => { const controller = new AbortController(); const signal = controller.signal; - this.isGuideStateLoading = true; - this.client!.get<{ state: GuideState[] }>(`${API_BASE_PATH}/state`, { - query: { - active: true, - }, + this.isPluginStateLoading = true; + this.client!.get<{ pluginState: PluginState }>(`${API_BASE_PATH}/state`, { signal, }) - .then((response) => { - this.isGuideStateInitialized = true; - this.isGuideStateLoading = false; - // There should only be 1 active guide - const hasState = response.state.length === 1; - if (hasState) { - observer.next(response.state[0]); - this.guideState$.next(response.state[0]); - } + .then(({ pluginState }) => { + this.isPluginStateLoading = false; + observer.next(pluginState); + this.pluginState$.next(pluginState); observer.complete(); }) .catch((error) => { - this.isGuideStateLoading = false; + this.isPluginStateLoading = false; observer.error(error); }); return () => { - this.isGuideStateLoading = false; + this.isPluginStateLoading = false; controller.abort(); }; }); } /** - * An Observable with the active guide state. + * An Observable with the plugin state. * Initially the state is fetched from the backend. * Subsequently, the observable is updated automatically, when the state changes. */ - public fetchActiveGuideState$(): Observable { - const currentState = this.guideState$.value; - // if currentState is undefined, it can be because there is no active guide or we haven't fetched the data from the backend - // check if there is no request in flight - // also check if we have fetched the data from the backend already once, if yes no request is sent - if (!currentState && !this.isGuideStateLoading && !this.isGuideStateInitialized) { - this.isGuideStateLoading = true; - return concat(this.createGetStateObservable(), this.guideState$); + public fetchPluginState$(): Observable { + if (!this.isCloudEnabled) { + return of(undefined); + } + if (!this.client) { + throw new Error('ApiService has not be initialized.'); + } + + const currentState = this.pluginState$.value; + // if currentState is undefined, it was not fetched from the backend yet + // or the request was cancelled or failed + // also check if we don't have a request in flight already + if (!currentState && !this.isPluginStateLoading) { + this.isPluginStateLoading = true; + return concat(this.createGetPluginStateObservable(), this.pluginState$); } - return this.guideState$; + return this.pluginState$; } /** @@ -91,12 +94,15 @@ export class ApiService implements GuidedOnboardingApi { * where all guides are displayed with their corresponding status */ public async fetchAllGuidesState(): Promise<{ state: GuideState[] } | undefined> { + if (!this.isCloudEnabled) { + return undefined; + } if (!this.client) { throw new Error('ApiService has not be initialized.'); } try { - return await this.client.get<{ state: GuideState[] }>(`${API_BASE_PATH}/state`); + return await this.client.get<{ state: GuideState[] }>(`${API_BASE_PATH}/guides`); } catch (error) { // TODO handle error // eslint-disable-next-line no-console @@ -111,20 +117,26 @@ export class ApiService implements GuidedOnboardingApi { * @param {boolean} panelState boolean to determine whether the dropdown panel should open or not * @return {Promise} a promise with the updated guide state */ - public async updateGuideState( - newState: GuideState, + public async updatePluginState( + state: { status?: PluginStatus; guide?: GuideState }, panelState: boolean - ): Promise<{ state: GuideState } | undefined> { + ): Promise<{ pluginState: PluginState } | undefined> { + if (!this.isCloudEnabled) { + return undefined; + } if (!this.client) { throw new Error('ApiService has not be initialized.'); } try { - const response = await this.client.put<{ state: GuideState }>(`${API_BASE_PATH}/state`, { - body: JSON.stringify(newState), - }); - // broadcast the newState - this.guideState$.next(newState); + const response = await this.client.put<{ pluginState: PluginState }>( + `${API_BASE_PATH}/state`, + { + body: JSON.stringify(state), + } + ); + // update the guide state in the plugin state observable + this.pluginState$.next(response.pluginState); this.isGuidePanelOpen$.next(panelState); return response; } catch (error) { @@ -144,14 +156,17 @@ export class ApiService implements GuidedOnboardingApi { public async activateGuide( guideId: GuideId, guide?: GuideState - ): Promise<{ state: GuideState } | undefined> { + ): Promise<{ pluginState: PluginState } | undefined> { // If we already have the guide state (i.e., user has already started the guide at some point), // simply pass it through so they can continue where they left off, and update the guide to active if (guide) { - return await this.updateGuideState( + return await this.updatePluginState( { - ...guide, - isActive: true, + status: 'in_progress', + guide: { + ...guide, + isActive: true, + }, }, true ); @@ -177,7 +192,13 @@ export class ApiService implements GuidedOnboardingApi { steps: updatedSteps, }; - return await this.updateGuideState(updatedGuide, true); + return await this.updatePluginState( + { + status: 'in_progress', + guide: updatedGuide, + }, + true + ); } } @@ -187,11 +208,16 @@ export class ApiService implements GuidedOnboardingApi { * @param {GuideState} guide the selected guide state * @return {Promise} a promise with the updated guide state */ - public async deactivateGuide(guide: GuideState): Promise<{ state: GuideState } | undefined> { - return await this.updateGuideState( + public async deactivateGuide( + guide: GuideState + ): Promise<{ pluginState: PluginState } | undefined> { + return await this.updatePluginState( { - ...guide, - isActive: false, + status: 'quit', + guide: { + ...guide, + isActive: false, + }, }, false ); @@ -204,27 +230,27 @@ export class ApiService implements GuidedOnboardingApi { * @param {GuideId} guideId the id of the guide (one of search, observability, security) * @return {Promise} a promise with the updated guide state */ - public async completeGuide(guideId: GuideId): Promise<{ state: GuideState } | undefined> { - const guideState = await firstValueFrom(this.fetchActiveGuideState$()); + public async completeGuide(guideId: GuideId): Promise<{ pluginState: PluginState } | undefined> { + const pluginState = await firstValueFrom(this.fetchPluginState$()); // For now, returning undefined if consumer attempts to complete a guide that is not active - if (guideState?.guideId !== guideId) { - return undefined; - } + if (!isGuideActive(pluginState, guideId)) return undefined; + + const { activeGuide } = pluginState!; // All steps should be complete at this point // However, we do a final check here as a safeguard const allStepsComplete = - Boolean(guideState.steps.find((step) => step.status !== 'complete')) === false; + Boolean(activeGuide!.steps.find((step) => step.status !== 'complete')) === false; if (allStepsComplete) { const updatedGuide: GuideState = { - ...guideState, + ...activeGuide!, isActive: false, status: 'complete', }; - return await this.updateGuideState(updatedGuide, false); + return await this.updatePluginState({ status: 'complete', guide: updatedGuide }, false); } } @@ -237,8 +263,11 @@ export class ApiService implements GuidedOnboardingApi { * @return {Observable} an observable with the boolean value */ public isGuideStepActive$(guideId: GuideId, stepId: GuideStepIds): Observable { - return this.fetchActiveGuideState$().pipe( - map((activeGuideState) => isStepInProgress(activeGuideState, guideId, stepId)) + return this.fetchPluginState$().pipe( + map((pluginState) => { + if (!isGuideActive(pluginState, guideId)) return false; + return isStepInProgress(pluginState!.activeGuide, guideId, stepId); + }) ); } @@ -252,15 +281,16 @@ export class ApiService implements GuidedOnboardingApi { public async startGuideStep( guideId: GuideId, stepId: GuideStepIds - ): Promise<{ state: GuideState } | undefined> { - const guideState = await firstValueFrom(this.fetchActiveGuideState$()); + ): Promise<{ pluginState: PluginState } | undefined> { + const pluginState = await firstValueFrom(this.fetchPluginState$()); // For now, returning undefined if consumer attempts to start a step for a guide that isn't active - if (guideState?.guideId !== guideId) { + if (!isGuideActive(pluginState, guideId)) { return undefined; } + const { activeGuide } = pluginState!; - const updatedSteps: GuideStep[] = guideState.steps.map((step) => { + const updatedSteps: GuideStep[] = activeGuide!.steps.map((step) => { // Mark the current step as in_progress if (step.id === stepId) { return { @@ -280,7 +310,7 @@ export class ApiService implements GuidedOnboardingApi { steps: updatedSteps, }; - return await this.updateGuideState(currentGuide, false); + return await this.updatePluginState({ guide: currentGuide }, false); } /** @@ -293,23 +323,22 @@ export class ApiService implements GuidedOnboardingApi { public async completeGuideStep( guideId: GuideId, stepId: GuideStepIds - ): Promise<{ state: GuideState } | undefined> { - const guideState = await firstValueFrom(this.fetchActiveGuideState$()); - + ): Promise<{ pluginState: PluginState } | undefined> { + const pluginState = await firstValueFrom(this.fetchPluginState$()); // For now, returning undefined if consumer attempts to complete a step for a guide that isn't active - if (guideState?.guideId !== guideId) { + if (!isGuideActive(pluginState, guideId)) { return undefined; } + const { activeGuide } = pluginState!; + const isCurrentStepInProgress = isStepInProgress(activeGuide, guideId, stepId); + const isCurrentStepReadyToComplete = isStepReadyToComplete(activeGuide, guideId, stepId); - const isCurrentStepInProgress = isStepInProgress(guideState, guideId, stepId); - const isCurrentStepReadyToComplete = isStepReadyToComplete(guideState, guideId, stepId); - - const stepConfig = getStepConfig(guideState.guideId, stepId); + const stepConfig = getStepConfig(activeGuide!.guideId, stepId); const isManualCompletion = stepConfig ? !!stepConfig.manualCompletion : false; if (isCurrentStepInProgress || isCurrentStepReadyToComplete) { const updatedSteps = getUpdatedSteps( - guideState, + activeGuide!, stepId, // if current step is in progress and configured for manual completion, // set the status to ready_to_complete @@ -319,12 +348,14 @@ export class ApiService implements GuidedOnboardingApi { const currentGuide: GuideState = { guideId, isActive: true, - status: getGuideStatusOnStepCompletion(guideState, guideId, stepId), + status: getGuideStatusOnStepCompletion(activeGuide, guideId, stepId), steps: updatedSteps, }; - return await this.updateGuideState( - currentGuide, + return await this.updatePluginState( + { + guide: currentGuide, + }, // the panel is opened when the step is being set to complete. // that happens when the step is not configured for manual completion // or it's already ready_to_complete @@ -343,29 +374,30 @@ export class ApiService implements GuidedOnboardingApi { * @return {Observable} an observable with the boolean value */ public isGuidedOnboardingActiveForIntegration$(integration?: string): Observable { - return this.fetchActiveGuideState$().pipe( - map((state) => { - return state ? isIntegrationInGuideStep(state, integration) : false; - }) + return this.fetchPluginState$().pipe( + map((state) => isIntegrationInGuideStep(state?.activeGuide, integration)) ); } public async completeGuidedOnboardingForIntegration( integration?: string - ): Promise<{ state: GuideState } | undefined> { - if (integration) { - const currentState = await firstValueFrom(this.fetchActiveGuideState$()); - if (currentState) { - const inProgressStepId = getInProgressStepId(currentState); - if (inProgressStepId) { - const isIntegrationStepActive = isIntegrationInGuideStep(currentState, integration); - if (isIntegrationStepActive) { - return await this.completeGuideStep(currentState?.guideId, inProgressStepId); - } - } - } + ): Promise<{ pluginState: PluginState } | undefined> { + if (!integration) return undefined; + const pluginState = await firstValueFrom(this.fetchPluginState$()); + if (!isGuideActive(pluginState)) return undefined; + const { activeGuide } = pluginState!; + const inProgressStepId = getInProgressStepId(activeGuide!); + if (!inProgressStepId) return undefined; + const isIntegrationStepActive = isIntegrationInGuideStep(activeGuide!, integration); + if (isIntegrationStepActive) { + return await this.completeGuideStep(activeGuide!.guideId, inProgressStepId); } } + + public async skipGuidedOnboarding(): Promise<{ pluginState: PluginState } | undefined> { + // TODO error handling and loading state + return await this.updatePluginState({ status: 'skipped' }, false); + } } export const apiService = new ApiService(); diff --git a/src/plugins/guided_onboarding/public/services/helpers.ts b/src/plugins/guided_onboarding/public/services/helpers.ts index 039dbf07a1e4f..5dfba15f3e2d0 100644 --- a/src/plugins/guided_onboarding/public/services/helpers.ts +++ b/src/plugins/guided_onboarding/public/services/helpers.ts @@ -15,6 +15,7 @@ import type { } from '@kbn/guided-onboarding'; import { guidesConfig } from '../constants/guides_config'; import { GuideConfig, StepConfig } from '../types'; +import type { PluginState } from '../../common/types'; export const getGuideConfig = (guideId?: GuideId): GuideConfig | undefined => { if (guideId && Object.keys(guidesConfig).includes(guideId)) { @@ -60,17 +61,28 @@ const getInProgressStepConfig = (state: GuideState): StepConfig | undefined => { } }; -export const isIntegrationInGuideStep = (state: GuideState, integration?: string): boolean => { - if (state.isActive) { - const stepConfig = getInProgressStepConfig(state); - return stepConfig ? stepConfig.integration === integration : false; - } - return false; +export const isIntegrationInGuideStep = ( + guideState?: GuideState, + integration?: string +): boolean => { + if (!guideState || !guideState.isActive) return false; + + const stepConfig = getInProgressStepConfig(guideState); + return stepConfig ? stepConfig.integration === integration : false; }; -const isGuideActive = (guideState: GuideState | undefined, guideId: GuideId): boolean => { - // false if guideState is undefined or the guide is not active - return !!(guideState && guideState.isActive && guideState.guideId === guideId); +export const isGuideActive = (pluginState?: PluginState, guideId?: GuideId): boolean => { + // false if pluginState is undefined or plugin state is not in progress + // or active guide is undefined + if (!pluginState || pluginState.status !== 'in_progress' || !pluginState.activeGuide) { + return false; + } + // guideId is passed, check that it's the id of the active guide + if (guideId) { + const { activeGuide } = pluginState; + return !!(activeGuide.isActive && activeGuide.guideId === guideId); + } + return true; }; export const isStepInProgress = ( @@ -78,12 +90,10 @@ export const isStepInProgress = ( guideId: GuideId, stepId: GuideStepIds ): boolean => { - if (!isGuideActive(guideState, guideId)) { - return false; - } + if (!guideState || !guideState.isActive) return false; // false if the step is not 'in_progress' - const selectedStep = guideState!.steps.find((step) => step.id === stepId); + const selectedStep = guideState.steps.find((step) => step.id === stepId); return selectedStep ? selectedStep.status === 'in_progress' : false; }; @@ -92,10 +102,7 @@ export const isStepReadyToComplete = ( guideId: GuideId, stepId: GuideStepIds ): boolean => { - if (!isGuideActive(guideState, guideId)) { - return false; - } - + if (!guideState || !guideState.isActive) return false; // false if the step is not 'ready_to_complete' const selectedStep = guideState!.steps.find((step) => step.id === stepId); return selectedStep ? selectedStep.status === 'ready_to_complete' : false; @@ -136,7 +143,7 @@ export const getUpdatedSteps = ( }; export const getGuideStatusOnStepCompletion = ( - guideState: GuideState, + guideState: GuideState | undefined, guideId: GuideId, stepId: GuideStepIds ): GuideStatus => { diff --git a/src/plugins/guided_onboarding/public/types.ts b/src/plugins/guided_onboarding/public/types.ts index 92da97e7e7b96..aeacb79d79a27 100755 --- a/src/plugins/guided_onboarding/public/types.ts +++ b/src/plugins/guided_onboarding/public/types.ts @@ -11,6 +11,7 @@ import { Observable } from 'rxjs'; import { HttpSetup } from '@kbn/core/public'; import type { GuideState, GuideId, GuideStepIds, StepStatus } from '@kbn/guided-onboarding'; import type { CloudStart } from '@kbn/cloud-plugin/public'; +import type { PluginStatus, PluginState } from '../common/types'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface GuidedOnboardingPluginSetup {} @@ -24,31 +25,33 @@ export interface AppPluginStartDependencies { } export interface GuidedOnboardingApi { - setup: (httpClient: HttpSetup) => void; - fetchActiveGuideState$: () => Observable; + setup: (httpClient: HttpSetup, isCloudEnabled: boolean) => void; + fetchPluginState$: () => Observable; fetchAllGuidesState: () => Promise<{ state: GuideState[] } | undefined>; - updateGuideState: ( - newState: GuideState, + updatePluginState: ( + state: { status?: PluginStatus; guide?: GuideState }, panelState: boolean - ) => Promise<{ state: GuideState } | undefined>; + ) => Promise<{ pluginState: PluginState } | undefined>; activateGuide: ( guideId: GuideId, guide?: GuideState - ) => Promise<{ state: GuideState } | undefined>; - completeGuide: (guideId: GuideId) => Promise<{ state: GuideState } | undefined>; + ) => Promise<{ pluginState: PluginState } | undefined>; + deactivateGuide: (guide: GuideState) => Promise<{ pluginState: PluginState } | undefined>; + completeGuide: (guideId: GuideId) => Promise<{ pluginState: PluginState } | undefined>; isGuideStepActive$: (guideId: GuideId, stepId: GuideStepIds) => Observable; startGuideStep: ( guideId: GuideId, stepId: GuideStepIds - ) => Promise<{ state: GuideState } | undefined>; + ) => Promise<{ pluginState: PluginState } | undefined>; completeGuideStep: ( guideId: GuideId, stepId: GuideStepIds - ) => Promise<{ state: GuideState } | undefined>; + ) => Promise<{ pluginState: PluginState } | undefined>; isGuidedOnboardingActiveForIntegration$: (integration?: string) => Observable; completeGuidedOnboardingForIntegration: ( integration?: string - ) => Promise<{ state: GuideState } | undefined>; + ) => Promise<{ pluginState: PluginState } | undefined>; + skipGuidedOnboarding: () => Promise<{ pluginState: PluginState } | undefined>; isGuidePanelOpen$: Observable; } diff --git a/src/plugins/guided_onboarding/server/helpers/guide_state_utils.ts b/src/plugins/guided_onboarding/server/helpers/guide_state_utils.ts new file mode 100644 index 0000000000000..54edafde24f25 --- /dev/null +++ b/src/plugins/guided_onboarding/server/helpers/guide_state_utils.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { SavedObjectsClient } from '@kbn/core/server'; +import { GuideState } from '@kbn/guided-onboarding'; +import { guideStateSavedObjectsType } from '../saved_objects'; + +export const findGuideById = async (savedObjectsClient: SavedObjectsClient, guideId: string) => { + return savedObjectsClient.find({ + type: guideStateSavedObjectsType, + search: `"${guideId}"`, + searchFields: ['guideId'], + }); +}; + +export const findActiveGuide = async (savedObjectsClient: SavedObjectsClient) => { + return savedObjectsClient.find({ + type: guideStateSavedObjectsType, + search: 'true', + searchFields: ['isActive'], + }); +}; + +export const findAllGuides = async (savedObjectsClient: SavedObjectsClient) => { + return savedObjectsClient.find({ type: guideStateSavedObjectsType }); +}; + +export const updateGuideState = async ( + savedObjectsClient: SavedObjectsClient, + updatedGuideState: GuideState +) => { + const selectedGuideSO = await findGuideById(savedObjectsClient, updatedGuideState.guideId); + + // If the SO already exists, update it, else create a new SO + if (selectedGuideSO.total > 0) { + const updatedGuides = []; + const selectedGuide = selectedGuideSO.saved_objects[0]; + + updatedGuides.push({ + type: guideStateSavedObjectsType, + id: selectedGuide.id, + attributes: { + ...updatedGuideState, + }, + }); + + // If we are activating a new guide, we need to check if there is a different, existing active guide + // If yes, we need to mark it as inactive (only 1 guide can be active at a time) + if (updatedGuideState.isActive) { + const activeGuideSO = await findActiveGuide(savedObjectsClient); + + if (activeGuideSO.total > 0) { + const activeGuide = activeGuideSO.saved_objects[0]; + if (activeGuide.attributes.guideId !== updatedGuideState.guideId) { + updatedGuides.push({ + type: guideStateSavedObjectsType, + id: activeGuide.id, + attributes: { + ...activeGuide.attributes, + isActive: false, + }, + }); + } + } + } + + const updatedGuidesResponse = await savedObjectsClient.bulkUpdate(updatedGuides); + + return updatedGuidesResponse; + } else { + // If we are activating a new guide, we need to check if there is an existing active guide + // If yes, we need to mark it as inactive (only 1 guide can be active at a time) + if (updatedGuideState.isActive) { + const activeGuideSO = await findActiveGuide(savedObjectsClient); + + if (activeGuideSO.total > 0) { + const activeGuide = activeGuideSO.saved_objects[0]; + await savedObjectsClient.update(guideStateSavedObjectsType, activeGuide.id, { + ...activeGuide.attributes, + isActive: false, + }); + } + } + + const createdGuideResponse = await savedObjectsClient.create( + guideStateSavedObjectsType, + updatedGuideState, + { + id: updatedGuideState.guideId, + } + ); + + return createdGuideResponse; + } +}; diff --git a/src/plugins/guided_onboarding/server/helpers/index.ts b/src/plugins/guided_onboarding/server/helpers/index.ts new file mode 100644 index 0000000000000..4fb5503ac84b0 --- /dev/null +++ b/src/plugins/guided_onboarding/server/helpers/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + findActiveGuide, + findAllGuides, + findGuideById, + updateGuideState, +} from './guide_state_utils'; +export { updatePluginStatus, calculateIsActivePeriod, getPluginState } from './plugin_state_utils'; diff --git a/src/plugins/guided_onboarding/server/helpers/plugin_state_utils.test.ts b/src/plugins/guided_onboarding/server/helpers/plugin_state_utils.test.ts new file mode 100644 index 0000000000000..f0297036c4a85 --- /dev/null +++ b/src/plugins/guided_onboarding/server/helpers/plugin_state_utils.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { calculateIsActivePeriod } from './plugin_state_utils'; + +describe('calculateIsActivePeriod', () => { + let result: boolean; + it('returns false if creationDate is undefined', () => { + result = calculateIsActivePeriod(undefined); + expect(result).toBe(false); + }); + + it('returns false if after the active period (35d from creation date)', () => { + // currently active period is 30 days long after the creation date + const duration35DaysInMilliseconds = 35 * 24 * 60 * 60 * 1000; + const now = new Date(); + const creationDate35DaysAgo = new Date(now.getTime() - duration35DaysInMilliseconds); + result = calculateIsActivePeriod(creationDate35DaysAgo.toISOString()); + expect(result).toBe(false); + }); + + it('returns true if in the active period (15d from creation date)', () => { + // currently active period is 30 days long after the creation date + const duration15DaysInMilliseconds = 15 * 24 * 60 * 60 * 1000; + const now = new Date(); + const creationDate15DaysAgo = new Date(now.getTime() - duration15DaysInMilliseconds); + result = calculateIsActivePeriod(creationDate15DaysAgo.toISOString()); + expect(result).toBe(true); + }); +}); diff --git a/src/plugins/guided_onboarding/server/helpers/plugin_state_utils.ts b/src/plugins/guided_onboarding/server/helpers/plugin_state_utils.ts new file mode 100644 index 0000000000000..f24fdf814f83b --- /dev/null +++ b/src/plugins/guided_onboarding/server/helpers/plugin_state_utils.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 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 { SavedObjectsClient } from '@kbn/core/server'; +import { findActiveGuide } from './guide_state_utils'; +import { PluginState, PluginStatus } from '../../common/types'; +import { + pluginStateSavedObjectsId, + pluginStateSavedObjectsType, + PluginStateSO, +} from '../saved_objects'; + +// hard code the duration to 30 days for now https://github.com/elastic/kibana/issues/144997 +const activePeriodDurationInMilliseconds = 30 * 24 * 60 * 60 * 1000; +export const calculateIsActivePeriod = (creationDate?: string): boolean => { + if (!creationDate) return false; + const parsedCreationDate = Date.parse(creationDate); + const endOfActivePeriodDate = new Date(parsedCreationDate + activePeriodDurationInMilliseconds); + const now = new Date(); + return now < endOfActivePeriodDate; +}; + +export const getPluginState = async (savedObjectsClient: SavedObjectsClient) => { + const pluginStateSO = await savedObjectsClient.find({ + type: pluginStateSavedObjectsType, + }); + if (pluginStateSO.saved_objects.length === 1) { + const { status, creationDate } = pluginStateSO.saved_objects[0].attributes; + const isActivePeriod = calculateIsActivePeriod(creationDate); + const activeGuideSO = await findActiveGuide(savedObjectsClient); + const pluginState: PluginState = { status: status as PluginStatus, isActivePeriod }; + if (activeGuideSO.saved_objects.length === 1) { + pluginState.activeGuide = activeGuideSO.saved_objects[0].attributes; + } + return pluginState; + } else { + // create a SO to keep track of the correct creation date + await updatePluginStatus(savedObjectsClient, 'not_started'); + return { + status: 'not_started', + isActivePeriod: true, + }; + } +}; + +export const updatePluginStatus = async ( + savedObjectsClient: SavedObjectsClient, + status: string +) => { + return await savedObjectsClient.update( + pluginStateSavedObjectsType, + pluginStateSavedObjectsId, + { + status, + }, + { + // if there is no saved object yet, insert a new SO with the creation date + upsert: { status, creationDate: new Date().toISOString() }, + } + ); +}; diff --git a/src/plugins/guided_onboarding/server/plugin.ts b/src/plugins/guided_onboarding/server/plugin.ts index c9b79629246cd..047a8978edaab 100755 --- a/src/plugins/guided_onboarding/server/plugin.ts +++ b/src/plugins/guided_onboarding/server/plugin.ts @@ -10,7 +10,7 @@ import { PluginInitializerContext, CoreSetup, Plugin, Logger } from '@kbn/core/s import { GuidedOnboardingPluginSetup, GuidedOnboardingPluginStart } from './types'; import { defineRoutes } from './routes'; -import { guidedSetupSavedObjects } from './saved_objects'; +import { guideStateSavedObjects, pluginStateSavedObjects } from './saved_objects'; export class GuidedOnboardingPlugin implements Plugin @@ -29,7 +29,8 @@ export class GuidedOnboardingPlugin defineRoutes(router); // register saved objects - core.savedObjects.registerType(guidedSetupSavedObjects); + core.savedObjects.registerType(guideStateSavedObjects); + core.savedObjects.registerType(pluginStateSavedObjects); return {}; } diff --git a/src/plugins/guided_onboarding/server/routes/guide_state_routes.ts b/src/plugins/guided_onboarding/server/routes/guide_state_routes.ts new file mode 100644 index 0000000000000..daf4461773032 --- /dev/null +++ b/src/plugins/guided_onboarding/server/routes/guide_state_routes.ts @@ -0,0 +1,39 @@ +/* + * 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 { IRouter, SavedObjectsClient } from '@kbn/core/server'; +import { API_BASE_PATH } from '../../common/constants'; +import { findAllGuides } from '../helpers'; + +export const registerGetGuideStateRoute = (router: IRouter) => { + // Fetch all guides state + router.get( + { + path: `${API_BASE_PATH}/guides`, + validate: false, + }, + async (context, request, response) => { + const coreContext = await context.core; + const soClient = coreContext.savedObjects.client as SavedObjectsClient; + + const existingGuides = await findAllGuides(soClient); + + if (existingGuides.total > 0) { + const guidesState = existingGuides.saved_objects.map((guide) => guide.attributes); + return response.ok({ + body: { state: guidesState }, + }); + } else { + // If no SO exists, we assume state hasn't been stored yet and return an empty array + return response.ok({ + body: { state: [] }, + }); + } + } + ); +}; diff --git a/src/plugins/guided_onboarding/server/routes/index.ts b/src/plugins/guided_onboarding/server/routes/index.ts index 1145715389488..361fd6ec797a0 100755 --- a/src/plugins/guided_onboarding/server/routes/index.ts +++ b/src/plugins/guided_onboarding/server/routes/index.ts @@ -6,159 +6,13 @@ * Side Public License, v 1. */ -import { schema } from '@kbn/config-schema'; -import type { IRouter, SavedObjectsClient } from '@kbn/core/server'; -import type { GuideState } from '@kbn/guided-onboarding'; -import { API_BASE_PATH } from '../../common/constants'; -import { guidedSetupSavedObjectsType } from '../saved_objects'; - -const findGuideById = async (savedObjectsClient: SavedObjectsClient, guideId: string) => { - return savedObjectsClient.find({ - type: guidedSetupSavedObjectsType, - search: `"${guideId}"`, - searchFields: ['guideId'], - }); -}; - -const findActiveGuide = async (savedObjectsClient: SavedObjectsClient) => { - return savedObjectsClient.find({ - type: guidedSetupSavedObjectsType, - search: 'true', - searchFields: ['isActive'], - }); -}; - -const findAllGuides = async (savedObjectsClient: SavedObjectsClient) => { - return savedObjectsClient.find({ type: guidedSetupSavedObjectsType }); -}; +import type { IRouter } from '@kbn/core/server'; +import { registerGetGuideStateRoute } from './guide_state_routes'; +import { registerGetPluginStateRoute, registerPutPluginStateRoute } from './plugin_state_routes'; export function defineRoutes(router: IRouter) { - // Fetch all guides state; optionally pass the query param ?active=true to only return the active guide - router.get( - { - path: `${API_BASE_PATH}/state`, - validate: { - query: schema.object({ - active: schema.maybe(schema.boolean()), - }), - }, - }, - async (context, request, response) => { - const coreContext = await context.core; - const soClient = coreContext.savedObjects.client as SavedObjectsClient; - - const existingGuides = - request.query.active === true - ? await findActiveGuide(soClient) - : await findAllGuides(soClient); - - if (existingGuides.total > 0) { - const guidesState = existingGuides.saved_objects.map((guide) => guide.attributes); - return response.ok({ - body: { state: guidesState }, - }); - } else { - // If no SO exists, we assume state hasn't been stored yet and return an empty array - return response.ok({ - body: { state: [] }, - }); - } - } - ); - - // Update the guide state for the passed guideId; - // will also check any existing active guides and update them to an "inactive" state - router.put( - { - path: `${API_BASE_PATH}/state`, - validate: { - body: schema.object({ - status: schema.string(), - guideId: schema.string(), - isActive: schema.boolean(), - steps: schema.arrayOf( - schema.object({ - status: schema.string(), - id: schema.string(), - }) - ), - }), - }, - }, - async (context, request, response) => { - const updatedGuideState = request.body; - - const coreContext = await context.core; - const savedObjectsClient = coreContext.savedObjects.client as SavedObjectsClient; - - const selectedGuideSO = await findGuideById(savedObjectsClient, updatedGuideState.guideId); - - // If the SO already exists, update it, else create a new SO - if (selectedGuideSO.total > 0) { - const updatedGuides = []; - const selectedGuide = selectedGuideSO.saved_objects[0]; - - updatedGuides.push({ - type: guidedSetupSavedObjectsType, - id: selectedGuide.id, - attributes: { - ...updatedGuideState, - }, - }); - - // If we are activating a new guide, we need to check if there is a different, existing active guide - // If yes, we need to mark it as inactive (only 1 guide can be active at a time) - if (updatedGuideState.isActive) { - const activeGuideSO = await findActiveGuide(savedObjectsClient); - - if (activeGuideSO.total > 0) { - const activeGuide = activeGuideSO.saved_objects[0]; - if (activeGuide.attributes.guideId !== updatedGuideState.guideId) { - updatedGuides.push({ - type: guidedSetupSavedObjectsType, - id: activeGuide.id, - attributes: { - ...activeGuide.attributes, - isActive: false, - }, - }); - } - } - } - - const updatedGuidesResponse = await savedObjectsClient.bulkUpdate(updatedGuides); - - return response.ok({ - body: { - state: updatedGuidesResponse, - }, - }); - } else { - // If we are activating a new guide, we need to check if there is an existing active guide - // If yes, we need to mark it as inactive (only 1 guide can be active at a time) - if (updatedGuideState.isActive) { - const activeGuideSO = await findActiveGuide(savedObjectsClient); - - if (activeGuideSO.total > 0) { - const activeGuide = activeGuideSO.saved_objects[0]; - await savedObjectsClient.update(guidedSetupSavedObjectsType, activeGuide.id, { - ...activeGuide.attributes, - isActive: false, - }); - } - } - - const createdGuideResponse = await savedObjectsClient.create( - guidedSetupSavedObjectsType, - updatedGuideState - ); + registerGetGuideStateRoute(router); - return response.ok({ - body: { - state: createdGuideResponse, - }, - }); - } - } - ); + registerGetPluginStateRoute(router); + registerPutPluginStateRoute(router); } diff --git a/src/plugins/guided_onboarding/server/routes/plugin_state_routes.ts b/src/plugins/guided_onboarding/server/routes/plugin_state_routes.ts new file mode 100644 index 0000000000000..169333f790912 --- /dev/null +++ b/src/plugins/guided_onboarding/server/routes/plugin_state_routes.ts @@ -0,0 +1,79 @@ +/* + * 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 { IRouter, SavedObjectsClient } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import { GuideState } from '@kbn/guided-onboarding'; +import { getPluginState, updatePluginStatus } from '../helpers/plugin_state_utils'; +import { API_BASE_PATH } from '../../common/constants'; +import { updateGuideState } from '../helpers'; + +export const registerGetPluginStateRoute = (router: IRouter) => { + router.get( + { + path: `${API_BASE_PATH}/state`, + validate: false, + }, + async (context, request, response) => { + const coreContext = await context.core; + const savedObjectsClient = coreContext.savedObjects.client as SavedObjectsClient; + const pluginState = await getPluginState(savedObjectsClient); + return response.ok({ + body: { + pluginState, + }, + }); + } + ); +}; + +export const registerPutPluginStateRoute = (router: IRouter) => { + router.put( + { + path: `${API_BASE_PATH}/state`, + validate: { + body: schema.object({ + status: schema.maybe(schema.string()), + guide: schema.maybe( + schema.object({ + status: schema.string(), + guideId: schema.string(), + isActive: schema.boolean(), + steps: schema.arrayOf( + schema.object({ + status: schema.string(), + id: schema.string(), + }) + ), + }) + ), + }), + }, + }, + async (context, request, response) => { + const { status, guide } = request.body as { status?: string; guide?: GuideState }; + + const coreContext = await context.core; + const savedObjectsClient = coreContext.savedObjects.client as SavedObjectsClient; + + if (status) { + await updatePluginStatus(savedObjectsClient, status); + } + if (guide) { + await updateGuideState(savedObjectsClient, guide); + } + + const pluginState = await getPluginState(savedObjectsClient); + return response.ok({ + body: { + pluginState, + }, + }); + } + ); +}; diff --git a/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts b/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts index 6fe0a90339f69..206388eb64b6f 100644 --- a/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts +++ b/src/plugins/guided_onboarding/server/saved_objects/guided_setup.ts @@ -8,12 +8,12 @@ import { SavedObjectsType } from '@kbn/core/server'; -export const guidedSetupSavedObjectsType = 'guided-onboarding-guide-state'; +export const guideStateSavedObjectsType = 'guided-onboarding-guide-state'; -export const guidedSetupSavedObjects: SavedObjectsType = { - name: guidedSetupSavedObjectsType, +export const guideStateSavedObjects: SavedObjectsType = { + name: guideStateSavedObjectsType, hidden: false, - // make it available in all spaces for now + // make it available in all spaces for now https://github.com/elastic/kibana/issues/144227 namespaceType: 'agnostic', mappings: { dynamic: false, @@ -27,3 +27,24 @@ export const guidedSetupSavedObjects: SavedObjectsType = { }, }, }; + +export const pluginStateSavedObjectsType = 'guided-onboarding-plugin-state'; +export const pluginStateSavedObjectsId = 'guided-onboarding-plugin-state-id'; + +export const pluginStateSavedObjects: SavedObjectsType = { + name: pluginStateSavedObjectsType, + hidden: false, + // make it available in all spaces for now https://github.com/elastic/kibana/issues/144227 + namespaceType: 'agnostic', + mappings: { + dynamic: false, + // we don't query this SO so no need for mapping properties, see PluginState intefrace + properties: {}, + }, +}; + +// plugin state SO interface +export interface PluginStateSO { + status: string; + creationDate: string; +} diff --git a/src/plugins/guided_onboarding/server/saved_objects/index.ts b/src/plugins/guided_onboarding/server/saved_objects/index.ts index 58195618a0ec4..04440bfd5e105 100644 --- a/src/plugins/guided_onboarding/server/saved_objects/index.ts +++ b/src/plugins/guided_onboarding/server/saved_objects/index.ts @@ -6,4 +6,11 @@ * Side Public License, v 1. */ -export { guidedSetupSavedObjects, guidedSetupSavedObjectsType } from './guided_setup'; +export { + guideStateSavedObjects, + guideStateSavedObjectsType, + pluginStateSavedObjects, + pluginStateSavedObjectsId, + pluginStateSavedObjectsType, +} from './guided_setup'; +export type { PluginStateSO } from './guided_setup'; diff --git a/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx b/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx index 624e56b85538e..e9802b243638e 100644 --- a/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx +++ b/src/plugins/home/public/application/components/guided_onboarding/getting_started.test.tsx @@ -12,6 +12,7 @@ import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { GettingStarted } from './getting_started'; import { KEY_ENABLE_WELCOME } from '../home'; +import { act } from 'react-dom/test-utils'; jest.mock('../../kibana_services', () => { const { chromeServiceMock, applicationServiceMock } = @@ -35,6 +36,7 @@ jest.mock('../../kibana_services', () => { }, guidedOnboardingService: { fetchAllGuidesState: jest.fn(), + skipGuidedOnboarding: jest.fn(), }, }), }; @@ -59,7 +61,12 @@ describe('getting started', () => { test('skip button should disable home welcome screen', async () => { const component = mountWithIntl(); const skipButton = findTestSubject(component, 'onboarding--skipGuideLink'); - skipButton.simulate('click'); + + await act(async () => { + await skipButton.simulate('click'); + }); + + component.update(); expect(localStorage.getItem(KEY_ENABLE_WELCOME)).toBe('false'); }); diff --git a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx index eb7f05ef7161a..7a6992da521b6 100644 --- a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx +++ b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx @@ -84,7 +84,8 @@ export const GettingStarted = () => { } }, [cloud, history]); - const onSkip = () => { + const onSkip = async () => { + await guidedOnboardingService?.skipGuidedOnboarding(); trackUiMetric(METRIC_TYPE.CLICK, 'guided_onboarding__skipped'); // disable welcome screen on the home page localStorage.setItem(KEY_ENABLE_WELCOME, JSON.stringify(false)); diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index 35afe6fc4bc9b..e96aa2277b0a5 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -63,6 +63,7 @@ const createApiUiMock = () => { getTagIdFromName: jest.fn(), updateTagsReferences: jest.fn(), getTag: jest.fn(), + getTagList: jest.fn(), }; return mock; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 0cc475b127a45..d7ea41c225f20 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -66,6 +66,10 @@ export interface SavedObjectsTaggingApiUi { * @param tagId */ getTag(tagId: string): Tag | undefined; + /** + * Return a list of available tags + */ + getTagList(): Tag[]; /** * Type-guard to safely manipulate tag-enhanced `SavedObject` from the `savedObject` plugin. @@ -222,6 +226,10 @@ export interface TagListComponentProps { * Handler to execute when clicking on a tag */ onClick?: (tag: TagWithOptionalId) => void; + /** + * Handler to render the tag + */ + tagRender?: (tag: TagWithOptionalId) => JSX.Element; } /** @@ -321,6 +329,7 @@ export interface GetSearchBarFilterOptions { export interface ParsedSearchQuery { searchTerm: string; tagReferences: SavedObjectsFindOptionsReference[]; + tagReferencesToExclude: SavedObjectsFindOptionsReference[]; valid: boolean; } diff --git a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap index b9dcb3f6bff6a..253057cd8c2f5 100644 --- a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap @@ -67,7 +67,7 @@ Object { "legendSize": Array [ "large", ], - "metric": Array [ + "metrics": Array [ Object { "chain": Array [ Object { diff --git a/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.test.ts b/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.test.ts index 87ec0d3b57b3f..1f558da376e0f 100644 --- a/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.test.ts +++ b/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.test.ts @@ -32,7 +32,7 @@ describe('getConfiguration', () => { legendMaxLines: 1, legendPosition: 'right', legendSize: 'large', - metric: 'metric-1', + metrics: ['metric-1'], nestedLegend: true, numberDisplay: 'percent', percentDecimals: 2, diff --git a/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.ts b/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.ts index d1d1daf9fe009..1ff3148315fac 100644 --- a/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.ts +++ b/src/plugins/vis_types/pie/public/convert_to_lens/configurations/index.ts @@ -35,7 +35,7 @@ const getLayers = ( layerType: 'data' as const, primaryGroups: buckets, secondaryGroups: [], - metric: metrics[0], + metrics: metrics.length ? [metrics[0]] : [], numberDisplay: showValuesInLegend === false ? NumberDisplayTypes.HIDDEN diff --git a/src/plugins/vis_types/pie/public/to_ast.ts b/src/plugins/vis_types/pie/public/to_ast.ts index 91ff6b0b6c17d..853e354cf4d06 100644 --- a/src/plugins/vis_types/pie/public/to_ast.ts +++ b/src/plugins/vis_types/pie/public/to_ast.ts @@ -70,7 +70,7 @@ export const toExpressionAst: VisToExpressionAst = async (vi emptySizeRatio: vis.params.emptySizeRatio, palette: preparePalette(vis.params.palette), labels: prepareLabels(vis.params.labels), - metric: schemas.metric.map(prepareDimension), + metrics: prepareDimension(schemas.metric[schemas.metric.length - 1]), buckets: schemas.segment?.map(prepareDimension), splitColumn: schemas.split_column?.map(prepareDimension), splitRow: schemas.split_row?.map(prepareDimension), diff --git a/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts b/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts index 8a6e70669dcf4..d19f3cd8318bd 100644 --- a/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts +++ b/src/plugins/visualizations/common/convert_to_lens/types/configurations.ts @@ -223,9 +223,9 @@ export interface MetricVisConfiguration { export interface PartitionLayerState { layerId: string; layerType: LayerType; + metrics: string[]; primaryGroups: string[]; secondaryGroups?: string[]; - metric?: string; collapseFns?: Record; numberDisplay: NumberDisplayType; categoryDisplay: CategoryDisplayType; diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 2238ff7cf054a..eada1c8beadd7 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -158,7 +158,8 @@ export async function findListItems( visTypes: Pick, search: string, size: number, - references?: SavedObjectsFindOptionsReference[] + references?: SavedObjectsFindOptionsReference[], + referencesToExclude?: SavedObjectsFindOptionsReference[] ) { const visAliases = visTypes.getAliases(); const extensions = visAliases @@ -180,6 +181,7 @@ export async function findListItems( page: 1, defaultSearchOperator: 'AND' as 'AND', hasReference: references, + hasNoReference: referencesToExclude, }; const { total, savedObjects } = await savedObjectsClient.find( diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx index 048de833df802..bf7b25269c85b 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -148,14 +148,24 @@ export const VisualizeListing = () => { const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); const fetchItems = useCallback( - (searchTerm: string, references?: SavedObjectsFindOptionsReference[]) => { + ( + searchTerm: string, + { + references, + referencesToExclude, + }: { + references?: SavedObjectsFindOptionsReference[]; + referencesToExclude?: SavedObjectsFindOptionsReference[]; + } = {} + ) => { const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); return findListItems( savedObjects.client, getTypes(), searchTerm, listingLimit, - references + references, + referencesToExclude ).then(({ total, hits }: { total: number; hits: Array> }) => ({ total, hits: hits diff --git a/test/api_integration/apis/guided_onboarding/get_guides.ts b/test/api_integration/apis/guided_onboarding/get_guides.ts new file mode 100644 index 0000000000000..29fe11e82e998 --- /dev/null +++ b/test/api_integration/apis/guided_onboarding/get_guides.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { testGuideStep1ActiveState } from '@kbn/guided-onboarding-plugin/public/services/api.mocks'; +import { + guideStateSavedObjectsType, + pluginStateSavedObjectsType, +} from '@kbn/guided-onboarding-plugin/server/saved_objects/guided_setup'; +import type { FtrProviderContext } from '../../ftr_provider_context'; +import { createGuides } from './helpers'; + +const getGuidesPath = '/api/guided_onboarding/guides'; +export default function testGetGuidesState({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('GET /api/guided_onboarding/guides', () => { + afterEach(async () => { + // Clean up saved objects + await kibanaServer.savedObjects.clean({ + types: [guideStateSavedObjectsType, pluginStateSavedObjectsType], + }); + }); + + it('returns an empty array if no guides', async () => { + const response = await supertest.get(getGuidesPath).expect(200); + expect(response.body).not.to.be.empty(); + expect(response.body.state).to.be.empty(); + }); + + it('returns all created guides (active and inactive)', async () => { + await createGuides(kibanaServer, [ + testGuideStep1ActiveState, + { ...testGuideStep1ActiveState, guideId: 'search' }, + ]); + const response = await supertest.get(getGuidesPath).expect(200); + expect(response.body).not.to.be.empty(); + expect(response.body.state).to.eql([ + testGuideStep1ActiveState, + { ...testGuideStep1ActiveState, guideId: 'search' }, + ]); + }); + }); +} diff --git a/test/api_integration/apis/guided_onboarding/get_state.ts b/test/api_integration/apis/guided_onboarding/get_state.ts new file mode 100644 index 0000000000000..489bf26830585 --- /dev/null +++ b/test/api_integration/apis/guided_onboarding/get_state.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { + testGuideStep1ActiveState, + testGuideNotActiveState, + mockPluginStateNotStarted, +} from '@kbn/guided-onboarding-plugin/public/services/api.mocks'; +import { + guideStateSavedObjectsType, + pluginStateSavedObjectsType, +} from '@kbn/guided-onboarding-plugin/server/saved_objects/guided_setup'; +import type { FtrProviderContext } from '../../ftr_provider_context'; +import { createPluginState, createGuides } from './helpers'; + +const getDateXDaysAgo = (daysAgo: number): string => { + const date = new Date(); + date.setDate(new Date().getDate() - daysAgo); + return date.toISOString(); +}; + +const getStatePath = '/api/guided_onboarding/state'; +export default function testGetState({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('GET /api/guided_onboarding/state', () => { + afterEach(async () => { + // Clean up saved objects + await kibanaServer.savedObjects.clean({ + types: [guideStateSavedObjectsType, pluginStateSavedObjectsType], + }); + }); + + it('returns the default plugin state if no saved objects', async () => { + const response = await supertest.get(getStatePath).expect(200); + expect(response.body.pluginState).not.to.be.empty(); + expect(response.body).to.eql({ + pluginState: mockPluginStateNotStarted, + }); + }); + + it('returns the plugin state with an active guide', async () => { + // Create an active guide + await createGuides(kibanaServer, [testGuideStep1ActiveState]); + + // Create a plugin state + await createPluginState(kibanaServer, { + status: 'in_progress', + creationDate: new Date().toISOString(), + }); + + const response = await supertest.get(getStatePath).expect(200); + expect(response.body.pluginState).not.to.be.empty(); + expect(response.body).to.eql({ + pluginState: { + status: 'in_progress', + isActivePeriod: true, + activeGuide: testGuideStep1ActiveState, + }, + }); + }); + + it('returns only the plugin state when no guide is active', async () => { + // Create an active guide + await createGuides(kibanaServer, [testGuideNotActiveState]); + + // Create a plugin state + await createPluginState(kibanaServer, { + status: 'in_progress', + creationDate: new Date().toISOString(), + }); + + const response = await supertest.get(getStatePath).expect(200); + expect(response.body.pluginState).not.to.be.empty(); + expect(response.body).to.eql({ + pluginState: { + status: 'in_progress', + isActivePeriod: true, + }, + }); + }); + + it('returns isActivePeriod=false if creationDate is 40 days ago', async () => { + // Create a plugin state + await createPluginState(kibanaServer, { + status: 'not_started', + creationDate: getDateXDaysAgo(40), + }); + + const response = await supertest.get(getStatePath).expect(200); + expect(response.body.pluginState).not.to.be.empty(); + expect(response.body.pluginState.isActivePeriod).to.eql(false); + }); + + it('returns isActivePeriod=true if creationDate is 20 days ago', async () => { + // Create a plugin state + await createPluginState(kibanaServer, { + status: 'not_started', + creationDate: getDateXDaysAgo(20), + }); + + const response = await supertest.get(getStatePath).expect(200); + expect(response.body.pluginState).not.to.be.empty(); + expect(response.body.pluginState.isActivePeriod).to.eql(true); + }); + }); +} diff --git a/test/api_integration/apis/guided_onboarding/helpers.ts b/test/api_integration/apis/guided_onboarding/helpers.ts new file mode 100644 index 0000000000000..beeb381969372 --- /dev/null +++ b/test/api_integration/apis/guided_onboarding/helpers.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KbnClient } from '@kbn/test'; +import { + guideStateSavedObjectsType, + pluginStateSavedObjectsId, + pluginStateSavedObjectsType, + PluginStateSO, +} from '@kbn/guided-onboarding-plugin/server/saved_objects'; +import { GuideState } from '@kbn/guided-onboarding'; + +export const createPluginState = async (client: KbnClient, state: PluginStateSO) => { + await client.savedObjects.create({ + type: pluginStateSavedObjectsType, + id: pluginStateSavedObjectsId, + overwrite: true, + attributes: state, + }); +}; + +export const createGuides = async (client: KbnClient, guides: GuideState[]) => { + for (const guide of guides) { + await client.savedObjects.create({ + type: guideStateSavedObjectsType, + id: guide.guideId, + overwrite: true, + attributes: guide, + }); + } +}; diff --git a/x-pack/test/api_integration/apis/guided_onboarding/index.ts b/test/api_integration/apis/guided_onboarding/index.ts similarity index 66% rename from x-pack/test/api_integration/apis/guided_onboarding/index.ts rename to test/api_integration/apis/guided_onboarding/index.ts index cc5e3e5d5e5e1..c924eafe6bdb1 100644 --- a/x-pack/test/api_integration/apis/guided_onboarding/index.ts +++ b/test/api_integration/apis/guided_onboarding/index.ts @@ -1,8 +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. + * 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 { FtrProviderContext } from '../../ftr_provider_context'; @@ -11,5 +12,6 @@ export default function apiIntegrationTests({ loadTestFile }: FtrProviderContext describe('guided onboarding', () => { loadTestFile(require.resolve('./get_state')); loadTestFile(require.resolve('./put_state')); + loadTestFile(require.resolve('./get_guides')); }); } diff --git a/test/api_integration/apis/guided_onboarding/put_state.ts b/test/api_integration/apis/guided_onboarding/put_state.ts new file mode 100644 index 0000000000000..332142af47cf0 --- /dev/null +++ b/test/api_integration/apis/guided_onboarding/put_state.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { + testGuideStep1ActiveState, + testGuideNotActiveState, + testGuide, +} from '@kbn/guided-onboarding-plugin/public/services/api.mocks'; +import { + pluginStateSavedObjectsType, + pluginStateSavedObjectsId, + guideStateSavedObjectsType, +} from '@kbn/guided-onboarding-plugin/server/saved_objects/guided_setup'; +import type { FtrProviderContext } from '../../ftr_provider_context'; +import { createGuides, createPluginState } from './helpers'; + +const putStatePath = `/api/guided_onboarding/state`; +export default function testPutState({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('PUT /api/guided_onboarding/state', () => { + afterEach(async () => { + // Clean up saved objects + await kibanaServer.savedObjects.clean({ + types: [pluginStateSavedObjectsType, guideStateSavedObjectsType], + }); + }); + + it('creates a plugin saved object when updating the status and there is no state yet', async () => { + const response = await supertest + .put(putStatePath) + .set('kbn-xsrf', 'true') + .send({ + status: 'in_progress', + }) + .expect(200); + + expect(response.body).to.eql({ + pluginState: { + status: 'in_progress', + isActivePeriod: true, + }, + }); + + const createdSO = await kibanaServer.savedObjects.get({ + type: pluginStateSavedObjectsType, + id: pluginStateSavedObjectsId, + }); + + expect(createdSO.attributes.status).to.eql('in_progress'); + }); + + it('updates the plugin saved object when updating the status and there is already state', async () => { + await createPluginState(kibanaServer, { + status: 'not_started', + creationDate: new Date().toISOString(), + }); + + const response = await supertest + .put(putStatePath) + .set('kbn-xsrf', 'true') + .send({ + status: 'in_progress', + }) + .expect(200); + + expect(response.body).to.eql({ + pluginState: { + status: 'in_progress', + isActivePeriod: true, + }, + }); + + const createdSO = await kibanaServer.savedObjects.get({ + type: pluginStateSavedObjectsType, + id: pluginStateSavedObjectsId, + }); + + expect(createdSO.attributes.status).to.eql('in_progress'); + }); + + it('creates a guide saved object when updating the guide and there is no guide SO yet', async () => { + await supertest + .put(putStatePath) + .set('kbn-xsrf', 'true') + .send({ + guide: testGuideStep1ActiveState, + }) + .expect(200); + + const createdSO = await kibanaServer.savedObjects.get({ + type: guideStateSavedObjectsType, + id: testGuide, + }); + + expect(createdSO.attributes).to.eql(testGuideStep1ActiveState); + }); + + it('updates the guide saved object when updating the guide and there is already guide SO', async () => { + await createGuides(kibanaServer, [testGuideStep1ActiveState]); + + await supertest + .put(putStatePath) + .set('kbn-xsrf', 'true') + .send({ + guide: testGuideNotActiveState, + }) + .expect(200); + + const createdSO = await kibanaServer.savedObjects.get({ + type: guideStateSavedObjectsType, + id: testGuide, + }); + + expect(createdSO.attributes).to.eql(testGuideNotActiveState); + }); + + it('updates any existing active guides to inactive', async () => { + // create an active guide and an inactive guide + await createGuides(kibanaServer, [ + testGuideStep1ActiveState, + { ...testGuideNotActiveState, guideId: 'search' }, + ]); + + // Create a new guide with isActive: true + await supertest + .put(putStatePath) + .set('kbn-xsrf', 'true') + .send({ + guide: { + ...testGuideStep1ActiveState, + guideId: 'observability', + }, + }) + .expect(200); + + // Check that all guides except observability are inactive + const testGuideSO = await kibanaServer.savedObjects.get({ + type: guideStateSavedObjectsType, + id: testGuide, + }); + expect(testGuideSO.attributes.isActive).to.eql(false); + + const searchGuideSO = await kibanaServer.savedObjects.get({ + type: guideStateSavedObjectsType, + id: 'search', + }); + expect(searchGuideSO.attributes.isActive).to.eql(false); + + const observabilityGuide = await kibanaServer.savedObjects.get({ + type: guideStateSavedObjectsType, + id: 'observability', + }); + expect(observabilityGuide.attributes.isActive).to.eql(true); + }); + }); +} diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index b3a2f6e8d6eaf..4364b887f93eb 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -30,5 +30,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ui_counters')); loadTestFile(require.resolve('./unified_field_list')); loadTestFile(require.resolve('./telemetry')); + loadTestFile(require.resolve('./guided_onboarding')); }); } diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 2a16d33775397..e7ec21d616482 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -22,47 +22,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardPanelActions = getService('dashboardPanelActions'); - const { dashboardControls, timePicker, console, common, dashboard, header } = getPageObjects([ - 'dashboardControls', - 'timePicker', - 'dashboard', - 'console', - 'common', - 'header', - ]); + const { dashboardControls, timePicker, console, common, dashboard, header, settings } = + getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'settings', + 'console', + 'common', + 'header', + ]); const DASHBOARD_NAME = 'Test Options List Control'; describe('Dashboard options list integration', () => { - const newDocuments: Array<{ index: string; id: string }> = []; - - const addDocument = async (index: string, document: string) => { - await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document); - await console.clickPlay(); + const returnToDashboard = async () => { + await common.navigateToApp('dashboard'); await header.waitUntilLoadingHasFinished(); - const response = JSON.parse(await console.getResponse()); - newDocuments.push({ index, id: response._id }); + await elasticChart.setNewChartUiDebugFlag(); + await dashboard.loadSavedDashboard(DASHBOARD_NAME); + if (await dashboard.getIsInViewMode()) { + await dashboard.switchToEditMode(); + } + await dashboard.waitForRenderComplete(); }; before(async () => { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); - /* start by adding some incomplete data so that we can test `exists` query */ - await common.navigateToApp('console'); - await console.collapseHelp(); - await console.clearTextArea(); - await addDocument( - 'animals-cats-2018-01-01', - '"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Rosie", \n"sound": "hiss"' - ); - - /* then, create our testing dashboard */ await common.navigateToApp('dashboard'); await dashboard.gotoDashboardLandingPage(); await dashboard.clickNewDashboard(); await timePicker.setDefaultDataRange(); await elasticChart.setNewChartUiDebugFlag(); - await dashboard.saveDashboard(DASHBOARD_NAME, { exitFromEditMode: false }); + await dashboard.saveDashboard(DASHBOARD_NAME, { + exitFromEditMode: false, + storeTimeWithDashboard: true, + }); }); describe('Options List Control Editor selects relevant data views', async () => { @@ -392,56 +388,139 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await pieChart.getPieSliceCount()).to.be(2); await dashboard.clearUnsavedChanges(); }); + }); - describe('test exists query', async () => { - before(async () => { - await dashboardControls.deleteAllControls(); - await dashboardControls.createControl({ - controlType: OPTIONS_LIST_CONTROL, - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - title: 'Animal', - }); - controlId = (await dashboardControls.getAllControlIds())[0]; - }); + describe('test data view runtime field', async () => { + const FIELD_NAME = 'testRuntimeField'; + const FIELD_VALUES = ['G', 'H', 'B', 'R', 'M']; - it('creating exists query has expected results', async () => { - expect((await pieChart.getPieChartValues())[0]).to.be(6); - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('exists'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - await dashboard.waitForRenderComplete(); + before(async () => { + await common.navigateToApp('settings'); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternByName('animals-*'); + await settings.addRuntimeField( + FIELD_NAME, + 'keyword', + `emit(doc['sound.keyword'].value.substring(0, 1).toUpperCase())` + ); + await header.waitUntilLoadingHasFinished(); - expect(await pieChart.getPieSliceCount()).to.be(5); - expect((await pieChart.getPieChartValues())[0]).to.be(5); + await returnToDashboard(); + await dashboardControls.deleteAllControls(); + }); + + it('can create options list control on runtime field', async () => { + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + fieldName: FIELD_NAME, + dataViewTitle: 'animals-*', }); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); - it('negating exists query has expected results', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSetIncludeSelections(false); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - await dashboard.waitForRenderComplete(); + it('new control has expected suggestions', async () => { + controlId = (await dashboardControls.getAllControlIds())[0]; + await ensureAvailableOptionsEql(FIELD_VALUES); + }); - expect(await pieChart.getPieSliceCount()).to.be(1); - expect((await pieChart.getPieChartValues())[0]).to.be(1); - }); + it('making selection has expected results', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('B'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + expect(await pieChart.getPieChartLabels()).to.eql(['bark', 'bow ow ow']); }); after(async () => { await dashboardControls.deleteAllControls(); + await dashboard.clickQuickSave(); + await header.waitUntilLoadingHasFinished(); + + await common.navigateToApp('settings'); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternByName('animals-*'); + await settings.filterField('testRuntimeField'); + await testSubjects.click('deleteField'); + await settings.confirmDelete(); + }); + }); + + describe('test exists query', async () => { + const newDocuments: Array<{ index: string; id: string }> = []; + + const addDocument = async (index: string, document: string) => { + await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document); + await console.clickPlay(); + await header.waitUntilLoadingHasFinished(); + const response = JSON.parse(await console.getResponse()); + newDocuments.push({ index, id: response._id }); + }; + + before(async () => { + await common.navigateToApp('console'); + await console.collapseHelp(); + await console.clearTextArea(); + await addDocument( + 'animals-cats-2018-01-01', + '"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Rosie", \n"sound": "hiss"' + ); + await returnToDashboard(); await dashboardControls.createControl({ controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sounds', + fieldName: 'animal.keyword', + title: 'Animal', }); controlId = (await dashboardControls.getAllControlIds())[0]; + await header.waitUntilLoadingHasFinished(); + await dashboard.waitForRenderComplete(); + }); + + it('creating exists query has expected results', async () => { + expect((await pieChart.getPieChartValues())[0]).to.be(6); + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('exists'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboard.waitForRenderComplete(); + + expect(await pieChart.getPieSliceCount()).to.be(5); + expect((await pieChart.getPieChartValues())[0]).to.be(5); + }); + + it('negating exists query has expected results', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSetIncludeSelections(false); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboard.waitForRenderComplete(); + + expect(await pieChart.getPieSliceCount()).to.be(1); + expect((await pieChart.getPieChartValues())[0]).to.be(1); + }); + + after(async () => { + await common.navigateToApp('console'); + await console.clearTextArea(); + for (const { index, id } of newDocuments) { + await console.enterRequest(`\nDELETE /${index}/_doc/${id}`); + await console.clickPlay(); + await header.waitUntilLoadingHasFinished(); + } + + await returnToDashboard(); + await dashboardControls.deleteAllControls(); }); }); describe('Options List dashboard validation', async () => { before(async () => { + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sounds', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverSelectOption('meow'); await dashboardControls.optionsListPopoverSelectOption('bark'); @@ -528,14 +607,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - await common.navigateToApp('console'); - await console.collapseHelp(); - await console.clearTextArea(); - for (const { index, id } of newDocuments) { - await console.enterRequest(`\nDELETE /${index}/_doc/${id}`); - await console.clickPlay(); - await header.waitUntilLoadingHasFinished(); - } await security.testUser.restoreDefaults(); }); }); diff --git a/x-pack/examples/testing_embedded_lens/public/app.tsx b/x-pack/examples/testing_embedded_lens/public/app.tsx index 2b8799c1951a3..ab717da3b6268 100644 --- a/x-pack/examples/testing_embedded_lens/public/app.tsx +++ b/x-pack/examples/testing_embedded_lens/public/app.tsx @@ -312,7 +312,7 @@ function getLensAttributesPartition( layers: [ { primaryGroups: ['col1'], - metric: 'col2', + metrics: ['col2'], layerId: 'layer1', layerType: 'data', numberDisplay: 'percent', diff --git a/x-pack/performance/kbn_archives/flights_no_map_dashboard.json b/x-pack/performance/kbn_archives/flights_no_map_dashboard.json index f0afa9052ddae..cac545ba1d836 100644 --- a/x-pack/performance/kbn_archives/flights_no_map_dashboard.json +++ b/x-pack/performance/kbn_archives/flights_no_map_dashboard.json @@ -173,7 +173,7 @@ "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" }, "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", - "panelsJSON": "[{\"version\":\"8.6.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":47,\"w\":48,\"h\":15,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":16,\"w\":24,\"h\":9,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":36,\"w\":24,\"h\":11,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false},\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":36,\"y\":36,\"w\":12,\"h\":11,\"i\":\"21\"},\"panelIndex\":\"21\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_21\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":8,\"i\":\"6afc61f7-e2d5-45a3-9e7a-281160ad3eb9\"},\"panelIndex\":\"6afc61f7-e2d5-45a3-9e7a-281160ad3eb9\",\"embeddableConfig\":{\"savedVis\":{\"title\":\"[Flights] Markdown Instructions\",\"description\":\"\",\"type\":\"markdown\",\"params\":{\"fontSize\":10,\"openLinksInNewTab\":true,\"markdown\":\"## Sample Flight data\\nThis dashboard contains sample data for you to play with. You can view it, search it, and interact with the visualizations. For more information about Kibana, check our [docs](https://www.elastic.co/guide/en/kibana/current/index.html).\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{}}},\"hidePanelTitles\":true,\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":0,\"w\":8,\"h\":8,\"i\":\"392b4936-f753-47bc-a98d-a4e41a0a4cd4\"},\"panelIndex\":\"392b4936-f753-47bc-a98d-a4e41a0a4cd4\",\"embeddableConfig\":{\"enhancements\":{},\"attributes\":{\"title\":\"[Flights] Total Flights\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"8fa993db-c147-4954-adf7-4ff264d42576\":{\"columns\":{\"81124c45-6ab6-42f4-8859-495d55eb8065\":{\"label\":\"Total flights\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true}},\"columnOrder\":[\"81124c45-6ab6-42f4-8859-495d55eb8065\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"8fa993db-c147-4954-adf7-4ff264d42576\",\"accessor\":\"81124c45-6ab6-42f4-8859-495d55eb8065\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-8fa993db-c147-4954-adf7-4ff264d42576\",\"type\":\"index-pattern\"}]},\"hidePanelTitles\":true}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":32,\"y\":0,\"w\":8,\"h\":4,\"i\":\"9271deff-5a61-4665-83fc-f9fdc6bf0c0b\"},\"panelIndex\":\"9271deff-5a61-4665-83fc-f9fdc6bf0c0b\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\":{\"label\":\"Part of count(kql='FlightDelay : true') / count()\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"FlightDelay : true\",\"language\":\"kuery\"},\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\":{\"label\":\"Part of count(kql='FlightDelay : true') / count()\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\":{\"label\":\"Part of count(kql='FlightDelay : true') / count()\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"location\":{\"min\":0,\"max\":41},\"text\":\"count(kql='FlightDelay : true') / count()\"}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\":{\"label\":\"Delayed\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='FlightDelay : true') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"customLabel\":true}},\"columnOrder\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":40,\"y\":0,\"w\":8,\"h\":4,\"i\":\"aa591c29-1a31-4ee1-a71d-b829c06fd162\"},\"panelIndex\":\"aa591c29-1a31-4ee1-a71d-b829c06fd162\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"c7851241-5526-499a-960b-357af8c2ce5bX0\":{\"label\":\"Part of Delayed\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX1\":{\"label\":\"Part of Delayed\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"timeShift\":\"1w\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX2\":{\"label\":\"Part of Delayed\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"subtract\",\"args\":[{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"location\":{\"min\":0,\"max\":28},\"text\":\"count() / count(shift='1w') \"},1],\"location\":{\"min\":0,\"max\":31},\"text\":\"count() / count(shift='1w') - 1\"}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5b\":{\"label\":\"Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count() / count(shift='1w') - 1\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX2\"],\"customLabel\":true}},\"columnOrder\":[\"c7851241-5526-499a-960b-357af8c2ce5b\",\"c7851241-5526-499a-960b-357af8c2ce5bX2\",\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"c7851241-5526-499a-960b-357af8c2ce5b\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"alias\":null,\"negate\":false,\"disabled\":false,\"type\":\"phrase\",\"key\":\"FlightDelay\",\"params\":{\"query\":true},\"index\":\"filter-index-pattern-0\"},\"query\":{\"match_phrase\":{\"FlightDelay\":true}},\"$state\":{\"store\":\"appState\"}}]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"filter-index-pattern-0\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":32,\"y\":4,\"w\":8,\"h\":4,\"i\":\"b766e3b8-4544-46ed-99e6-9ecc4847e2a2\"},\"panelIndex\":\"b766e3b8-4544-46ed-99e6-9ecc4847e2a2\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\":{\"label\":\"Part of Cancelled\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"Cancelled : true\",\"language\":\"kuery\"},\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\":{\"label\":\"Part of Cancelled\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\":{\"label\":\"Part of Cancelled\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"location\":{\"min\":0,\"max\":39},\"text\":\"count(kql='Cancelled : true') / count()\"}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\":{\"label\":\"Cancelled\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='Cancelled : true') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"customLabel\":true}},\"columnOrder\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":40,\"y\":4,\"w\":8,\"h\":4,\"i\":\"2e33ade5-96e5-40b4-b460-493e5d4fa834\"},\"panelIndex\":\"2e33ade5-96e5-40b4-b460-493e5d4fa834\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"c7851241-5526-499a-960b-357af8c2ce5bX0\":{\"label\":\"Part of Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX1\":{\"label\":\"Part of Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"timeShift\":\"1w\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX2\":{\"label\":\"Part of Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"subtract\",\"args\":[{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"location\":{\"min\":0,\"max\":28},\"text\":\"count() / count(shift='1w') \"},1],\"location\":{\"min\":0,\"max\":31},\"text\":\"count() / count(shift='1w') - 1\"}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5b\":{\"label\":\"Cancelled vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count() / count(shift='1w') - 1\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX2\"],\"customLabel\":true}},\"columnOrder\":[\"c7851241-5526-499a-960b-357af8c2ce5b\",\"c7851241-5526-499a-960b-357af8c2ce5bX2\",\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"c7851241-5526-499a-960b-357af8c2ce5b\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"alias\":null,\"negate\":false,\"disabled\":false,\"type\":\"phrase\",\"key\":\"Cancelled\",\"params\":{\"query\":true},\"index\":\"filter-index-pattern-0\"},\"query\":{\"match_phrase\":{\"Cancelled\":true}},\"$state\":{\"store\":\"appState\"}}]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"filter-index-pattern-0\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":8,\"w\":24,\"h\":8,\"i\":\"086ac2e9-dd16-4b45-92b8-1e43ff7e3f65\"},\"panelIndex\":\"086ac2e9-dd16-4b45-92b8-1e43ff7e3f65\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsXY\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"03c34665-471c-49c7-acf1-5a11f517421c\":{\"columns\":{\"a5b94e30-4e77-4b0a-9187-1d8b13de1456\":{\"label\":\"timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true}},\"3e267327-7317-4310-aee3-320e0f7c1e70\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\"}},\"columnOrder\":[\"a5b94e30-4e77-4b0a-9187-1d8b13de1456\",\"3e267327-7317-4310-aee3-320e0f7c1e70\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"legendSize\":\"auto\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"yLeftExtent\":{\"mode\":\"full\"},\"yRightExtent\":{\"mode\":\"custom\",\"lowerBound\":0,\"upperBound\":1},\"axisTitlesVisibilitySettings\":{\"x\":false,\"yLeft\":false,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"03c34665-471c-49c7-acf1-5a11f517421c\",\"accessors\":[\"3e267327-7317-4310-aee3-320e0f7c1e70\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"xAccessor\":\"a5b94e30-4e77-4b0a-9187-1d8b13de1456\",\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-03c34665-471c-49c7-acf1-5a11f517421c\",\"type\":\"index-pattern\"}]},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"[Flights] Flight count\"},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":8,\"w\":24,\"h\":28,\"i\":\"fb86b32f-fb7a-45cf-9511-f366fef51bbd\"},\"panelIndex\":\"fb86b32f-fb7a-45cf-9511-f366fef51bbd\",\"embeddableConfig\":{\"attributes\":{\"title\":\"Cities by delay, cancellation\",\"type\":\"lens\",\"visualizationType\":\"lnsDatatable\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"f26e8f7a-4118-4227-bea0-5c02d8b270f7\":{\"columns\":{\"3dd24cb4-45ef-4dd8-b22a-d7b802cb6da0\":{\"label\":\"Top values of OriginCityName\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"OriginCityName\",\"isBucketed\":true,\"params\":{\"size\":1000,\"orderBy\":{\"type\":\"alphabetical\",\"fallback\":true},\"orderDirection\":\"asc\",\"otherBucket\":true,\"missingBucket\":false}},\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\":{\"label\":\"Part of Delay %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"FlightDelay : true \",\"language\":\"kuery\"},\"customLabel\":true},\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\":{\"label\":\"Part of Delay %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"52f6f2e9-6242-4c44-be63-b799150e7e60X2\":{\"label\":\"Part of Delay %\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\"],\"location\":{\"min\":0,\"max\":42},\"text\":\"count(kql='FlightDelay : true ') / count()\"}},\"references\":[\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\"],\"customLabel\":true},\"52f6f2e9-6242-4c44-be63-b799150e7e60\":{\"label\":\"Delay %\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='FlightDelay : true ') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":0}}},\"references\":[\"52f6f2e9-6242-4c44-be63-b799150e7e60X2\"],\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\":{\"label\":\"Part of Cancel %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"Cancelled: true\",\"language\":\"kuery\"},\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\":{\"label\":\"Part of Cancel %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X2\":{\"label\":\"Part of Cancel %\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\"],\"location\":{\"min\":0,\"max\":38},\"text\":\"count(kql='Cancelled: true') / count()\"}},\"references\":[\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\"],\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6\":{\"label\":\"Cancel %\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='Cancelled: true') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":0}}},\"references\":[\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X2\"],\"customLabel\":true}},\"columnOrder\":[\"3dd24cb4-45ef-4dd8-b22a-d7b802cb6da0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X2\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X2\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"columns\":[{\"isTransposed\":false,\"columnId\":\"3dd24cb4-45ef-4dd8-b22a-d7b802cb6da0\",\"width\":262.75},{\"columnId\":\"52f6f2e9-6242-4c44-be63-b799150e7e60\",\"isTransposed\":false,\"width\":302.5,\"colorMode\":\"cell\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":5,\"stops\":[{\"color\":\"#f7e0b8\",\"stop\":0.6},{\"color\":\"#e7664c\",\"stop\":1}],\"name\":\"custom\",\"colorStops\":[{\"color\":\"#f7e0b8\",\"stop\":0.2},{\"color\":\"#e7664c\",\"stop\":0.6}],\"rangeType\":\"number\",\"rangeMin\":0.2,\"rangeMax\":0.6}},\"alignment\":\"center\"},{\"columnId\":\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6\",\"isTransposed\":false,\"alignment\":\"center\",\"colorMode\":\"cell\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":5,\"stops\":[{\"color\":\"#f7e0b8\",\"stop\":0.6},{\"color\":\"#e7664c\",\"stop\":0.6666666666666666}],\"rangeType\":\"number\",\"name\":\"custom\",\"colorStops\":[{\"color\":\"#f7e0b8\",\"stop\":0.2},{\"color\":\"#e7664c\",\"stop\":0.6}],\"rangeMin\":0.2,\"rangeMax\":0.6}}}],\"layerId\":\"f26e8f7a-4118-4227-bea0-5c02d8b270f7\",\"sorting\":{\"columnId\":\"52f6f2e9-6242-4c44-be63-b799150e7e60\",\"direction\":\"desc\"},\"layerType\":\"data\",\"rowHeight\":\"single\",\"rowHeightLines\":1},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-f26e8f7a-4118-4227-bea0-5c02d8b270f7\",\"type\":\"index-pattern\"}]},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"[Flights] Most delayed cities\"},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":25,\"w\":24,\"h\":11,\"i\":\"0cc42484-16f7-42ec-b38c-9bf8be69cde7\"},\"panelIndex\":\"0cc42484-16f7-42ec-b38c-9bf8be69cde7\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsXY\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"e80cc05e-c52a-4e5f-ac71-4b37274867f5\":{\"columns\":{\"caf7421e-93a3-439e-ab0a-fbdead93c21c\":{\"label\":\"Top values of FlightDelayType\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"FlightDelayType\",\"isBucketed\":true,\"params\":{\"size\":10,\"orderBy\":{\"type\":\"column\",\"columnId\":\"0233d302-ec81-4fbe-96cb-7fac84cf035c\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false}},\"13ec79e3-9d73-4536-9056-3d92802bb30a\":{\"label\":\"timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true}},\"0233d302-ec81-4fbe-96cb-7fac84cf035c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\"}},\"columnOrder\":[\"caf7421e-93a3-439e-ab0a-fbdead93c21c\",\"13ec79e3-9d73-4536-9056-3d92802bb30a\",\"0233d302-ec81-4fbe-96cb-7fac84cf035c\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"bottom\",\"legendSize\":\"auto\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"yLeftExtent\":{\"mode\":\"full\"},\"yRightExtent\":{\"mode\":\"full\"},\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":false,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_percentage_stacked\",\"layers\":[{\"layerId\":\"e80cc05e-c52a-4e5f-ac71-4b37274867f5\",\"accessors\":[\"0233d302-ec81-4fbe-96cb-7fac84cf035c\"],\"position\":\"top\",\"seriesType\":\"bar_percentage_stacked\",\"showGridlines\":false,\"palette\":{\"type\":\"palette\",\"name\":\"cool\"},\"xAccessor\":\"13ec79e3-9d73-4536-9056-3d92802bb30a\",\"splitAccessor\":\"caf7421e-93a3-439e-ab0a-fbdead93c21c\",\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-e80cc05e-c52a-4e5f-ac71-4b37274867f5\",\"type\":\"index-pattern\"}]},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"[Flights] Delay Type\"},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":36,\"w\":12,\"h\":11,\"i\":\"5d53db36-2d5a-4adc-af7b-cec4c1a294e0\"},\"panelIndex\":\"5d53db36-2d5a-4adc-af7b-cec4c1a294e0\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsPie\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"0c8e136b-a822-4fb3-836d-e06cbea4eea4\":{\"columns\":{\"d1cee8bf-34cf-4141-99d7-ff043ee77b56\":{\"label\":\"Top values of FlightDelayType\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"FlightDelayType\",\"isBucketed\":true,\"params\":{\"size\":10,\"orderBy\":{\"type\":\"column\",\"columnId\":\"aa152ace-ee2d-447b-b86d-459bef4d7880\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false}},\"aa152ace-ee2d-447b-b86d-459bef4d7880\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\"}},\"columnOrder\":[\"d1cee8bf-34cf-4141-99d7-ff043ee77b56\",\"aa152ace-ee2d-447b-b86d-459bef4d7880\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"shape\":\"pie\",\"palette\":{\"type\":\"palette\",\"name\":\"cool\"},\"layers\":[{\"layerId\":\"0c8e136b-a822-4fb3-836d-e06cbea4eea4\",\"metric\":\"aa152ace-ee2d-447b-b86d-459bef4d7880\",\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\",\"legendSize\":\"auto\",\"primaryGroups\":[\"d1cee8bf-34cf-4141-99d7-ff043ee77b56\"]}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"type\":\"phrase\",\"key\":\"FlightDelayType\",\"params\":{\"query\":\"No Delay\"},\"disabled\":false,\"negate\":true,\"alias\":null,\"index\":\"filter-index-pattern-0\"},\"query\":{\"match_phrase\":{\"FlightDelayType\":\"No Delay\"}},\"$state\":{\"store\":\"appState\"}}]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-0c8e136b-a822-4fb3-836d-e06cbea4eea4\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"filter-index-pattern-0\",\"type\":\"index-pattern\"}]},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"[Flights] Delay Type\"}]", + "panelsJSON": "[{\"version\":\"8.6.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":47,\"w\":48,\"h\":15,\"i\":\"4\"},\"panelIndex\":\"4\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":16,\"w\":24,\"h\":9,\"i\":\"7\"},\"panelIndex\":\"7\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_7\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":36,\"w\":24,\"h\":11,\"i\":\"10\"},\"panelIndex\":\"10\",\"embeddableConfig\":{\"vis\":{\"colors\":{\"Count\":\"#1F78C1\"},\"legendOpen\":false},\"enhancements\":{}},\"panelRefName\":\"panel_10\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":36,\"y\":36,\"w\":12,\"h\":11,\"i\":\"21\"},\"panelIndex\":\"21\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_21\"},{\"version\":\"8.6.0\",\"type\":\"visualization\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":8,\"i\":\"6afc61f7-e2d5-45a3-9e7a-281160ad3eb9\"},\"panelIndex\":\"6afc61f7-e2d5-45a3-9e7a-281160ad3eb9\",\"embeddableConfig\":{\"savedVis\":{\"title\":\"[Flights] Markdown Instructions\",\"description\":\"\",\"type\":\"markdown\",\"params\":{\"fontSize\":10,\"openLinksInNewTab\":true,\"markdown\":\"## Sample Flight data\\nThis dashboard contains sample data for you to play with. You can view it, search it, and interact with the visualizations. For more information about Kibana, check our [docs](https://www.elastic.co/guide/en/kibana/current/index.html).\"},\"uiState\":{},\"data\":{\"aggs\":[],\"searchSource\":{}}},\"hidePanelTitles\":true,\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":0,\"w\":8,\"h\":8,\"i\":\"392b4936-f753-47bc-a98d-a4e41a0a4cd4\"},\"panelIndex\":\"392b4936-f753-47bc-a98d-a4e41a0a4cd4\",\"embeddableConfig\":{\"enhancements\":{},\"attributes\":{\"title\":\"[Flights] Total Flights\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"8fa993db-c147-4954-adf7-4ff264d42576\":{\"columns\":{\"81124c45-6ab6-42f4-8859-495d55eb8065\":{\"label\":\"Total flights\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true}},\"columnOrder\":[\"81124c45-6ab6-42f4-8859-495d55eb8065\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"8fa993db-c147-4954-adf7-4ff264d42576\",\"accessor\":\"81124c45-6ab6-42f4-8859-495d55eb8065\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-8fa993db-c147-4954-adf7-4ff264d42576\",\"type\":\"index-pattern\"}]},\"hidePanelTitles\":true}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":32,\"y\":0,\"w\":8,\"h\":4,\"i\":\"9271deff-5a61-4665-83fc-f9fdc6bf0c0b\"},\"panelIndex\":\"9271deff-5a61-4665-83fc-f9fdc6bf0c0b\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\":{\"label\":\"Part of count(kql='FlightDelay : true') / count()\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"FlightDelay : true\",\"language\":\"kuery\"},\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\":{\"label\":\"Part of count(kql='FlightDelay : true') / count()\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\":{\"label\":\"Part of count(kql='FlightDelay : true') / count()\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"location\":{\"min\":0,\"max\":41},\"text\":\"count(kql='FlightDelay : true') / count()\"}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\":{\"label\":\"Delayed\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='FlightDelay : true') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"customLabel\":true}},\"columnOrder\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":40,\"y\":0,\"w\":8,\"h\":4,\"i\":\"aa591c29-1a31-4ee1-a71d-b829c06fd162\"},\"panelIndex\":\"aa591c29-1a31-4ee1-a71d-b829c06fd162\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"c7851241-5526-499a-960b-357af8c2ce5bX0\":{\"label\":\"Part of Delayed\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX1\":{\"label\":\"Part of Delayed\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"timeShift\":\"1w\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX2\":{\"label\":\"Part of Delayed\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"subtract\",\"args\":[{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"location\":{\"min\":0,\"max\":28},\"text\":\"count() / count(shift='1w') \"},1],\"location\":{\"min\":0,\"max\":31},\"text\":\"count() / count(shift='1w') - 1\"}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5b\":{\"label\":\"Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count() / count(shift='1w') - 1\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX2\"],\"customLabel\":true}},\"columnOrder\":[\"c7851241-5526-499a-960b-357af8c2ce5b\",\"c7851241-5526-499a-960b-357af8c2ce5bX2\",\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"c7851241-5526-499a-960b-357af8c2ce5b\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"alias\":null,\"negate\":false,\"disabled\":false,\"type\":\"phrase\",\"key\":\"FlightDelay\",\"params\":{\"query\":true},\"index\":\"filter-index-pattern-0\"},\"query\":{\"match_phrase\":{\"FlightDelay\":true}},\"$state\":{\"store\":\"appState\"}}]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"filter-index-pattern-0\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":32,\"y\":4,\"w\":8,\"h\":4,\"i\":\"b766e3b8-4544-46ed-99e6-9ecc4847e2a2\"},\"panelIndex\":\"b766e3b8-4544-46ed-99e6-9ecc4847e2a2\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\":{\"label\":\"Part of Cancelled\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"Cancelled : true\",\"language\":\"kuery\"},\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\":{\"label\":\"Part of Cancelled\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\":{\"label\":\"Part of Cancelled\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"location\":{\"min\":0,\"max\":39},\"text\":\"count(kql='Cancelled : true') / count()\"}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\"],\"customLabel\":true},\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\":{\"label\":\"Cancelled\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='Cancelled : true') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"customLabel\":true}},\"columnOrder\":[\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX0\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX1\",\"7e8fe9b1-f45c-4f3d-9561-30febcd357ecX2\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"7e8fe9b1-f45c-4f3d-9561-30febcd357ec\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":40,\"y\":4,\"w\":8,\"h\":4,\"i\":\"2e33ade5-96e5-40b4-b460-493e5d4fa834\"},\"panelIndex\":\"2e33ade5-96e5-40b4-b460-493e5d4fa834\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsLegacyMetric\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"b4712d43-1e84-4f5b-878d-8e38ba748317\":{\"columns\":{\"c7851241-5526-499a-960b-357af8c2ce5bX0\":{\"label\":\"Part of Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX1\":{\"label\":\"Part of Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"timeShift\":\"1w\",\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5bX2\":{\"label\":\"Part of Delayed vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"subtract\",\"args\":[{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"location\":{\"min\":0,\"max\":28},\"text\":\"count() / count(shift='1w') \"},1],\"location\":{\"min\":0,\"max\":31},\"text\":\"count() / count(shift='1w') - 1\"}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"customLabel\":true},\"c7851241-5526-499a-960b-357af8c2ce5b\":{\"label\":\"Cancelled vs 1 week earlier\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count() / count(shift='1w') - 1\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":1}}},\"references\":[\"c7851241-5526-499a-960b-357af8c2ce5bX2\"],\"customLabel\":true}},\"columnOrder\":[\"c7851241-5526-499a-960b-357af8c2ce5b\",\"c7851241-5526-499a-960b-357af8c2ce5bX2\",\"c7851241-5526-499a-960b-357af8c2ce5bX0\",\"c7851241-5526-499a-960b-357af8c2ce5bX1\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"layerId\":\"b4712d43-1e84-4f5b-878d-8e38ba748317\",\"accessor\":\"c7851241-5526-499a-960b-357af8c2ce5b\",\"layerType\":\"data\",\"textAlign\":\"center\",\"titlePosition\":\"bottom\",\"size\":\"xl\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"alias\":null,\"negate\":false,\"disabled\":false,\"type\":\"phrase\",\"key\":\"Cancelled\",\"params\":{\"query\":true},\"index\":\"filter-index-pattern-0\"},\"query\":{\"match_phrase\":{\"Cancelled\":true}},\"$state\":{\"store\":\"appState\"}}]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-b4712d43-1e84-4f5b-878d-8e38ba748317\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"filter-index-pattern-0\",\"type\":\"index-pattern\"}]},\"enhancements\":{}}},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":8,\"w\":24,\"h\":8,\"i\":\"086ac2e9-dd16-4b45-92b8-1e43ff7e3f65\"},\"panelIndex\":\"086ac2e9-dd16-4b45-92b8-1e43ff7e3f65\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsXY\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"03c34665-471c-49c7-acf1-5a11f517421c\":{\"columns\":{\"a5b94e30-4e77-4b0a-9187-1d8b13de1456\":{\"label\":\"timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true}},\"3e267327-7317-4310-aee3-320e0f7c1e70\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\"}},\"columnOrder\":[\"a5b94e30-4e77-4b0a-9187-1d8b13de1456\",\"3e267327-7317-4310-aee3-320e0f7c1e70\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"legendSize\":\"auto\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"yLeftExtent\":{\"mode\":\"full\"},\"yRightExtent\":{\"mode\":\"custom\",\"lowerBound\":0,\"upperBound\":1},\"axisTitlesVisibilitySettings\":{\"x\":false,\"yLeft\":false,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"03c34665-471c-49c7-acf1-5a11f517421c\",\"accessors\":[\"3e267327-7317-4310-aee3-320e0f7c1e70\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"xAccessor\":\"a5b94e30-4e77-4b0a-9187-1d8b13de1456\",\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-03c34665-471c-49c7-acf1-5a11f517421c\",\"type\":\"index-pattern\"}]},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"[Flights] Flight count\"},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":8,\"w\":24,\"h\":28,\"i\":\"fb86b32f-fb7a-45cf-9511-f366fef51bbd\"},\"panelIndex\":\"fb86b32f-fb7a-45cf-9511-f366fef51bbd\",\"embeddableConfig\":{\"attributes\":{\"title\":\"Cities by delay, cancellation\",\"type\":\"lens\",\"visualizationType\":\"lnsDatatable\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"f26e8f7a-4118-4227-bea0-5c02d8b270f7\":{\"columns\":{\"3dd24cb4-45ef-4dd8-b22a-d7b802cb6da0\":{\"label\":\"Top values of OriginCityName\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"OriginCityName\",\"isBucketed\":true,\"params\":{\"size\":1000,\"orderBy\":{\"type\":\"alphabetical\",\"fallback\":true},\"orderDirection\":\"asc\",\"otherBucket\":true,\"missingBucket\":false}},\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\":{\"label\":\"Part of Delay %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"FlightDelay : true \",\"language\":\"kuery\"},\"customLabel\":true},\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\":{\"label\":\"Part of Delay %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"52f6f2e9-6242-4c44-be63-b799150e7e60X2\":{\"label\":\"Part of Delay %\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\"],\"location\":{\"min\":0,\"max\":42},\"text\":\"count(kql='FlightDelay : true ') / count()\"}},\"references\":[\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\"],\"customLabel\":true},\"52f6f2e9-6242-4c44-be63-b799150e7e60\":{\"label\":\"Delay %\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='FlightDelay : true ') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":0}}},\"references\":[\"52f6f2e9-6242-4c44-be63-b799150e7e60X2\"],\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\":{\"label\":\"Part of Cancel %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"Cancelled: true\",\"language\":\"kuery\"},\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\":{\"label\":\"Part of Cancel %\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X2\":{\"label\":\"Part of Cancel %\",\"dataType\":\"number\",\"operationType\":\"math\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"tinymathAst\":{\"type\":\"function\",\"name\":\"divide\",\"args\":[\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\"],\"location\":{\"min\":0,\"max\":38},\"text\":\"count(kql='Cancelled: true') / count()\"}},\"references\":[\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\"],\"customLabel\":true},\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6\":{\"label\":\"Cancel %\",\"dataType\":\"number\",\"operationType\":\"formula\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"formula\":\"count(kql='Cancelled: true') / count()\",\"isFormulaBroken\":false,\"format\":{\"id\":\"percent\",\"params\":{\"decimals\":0}}},\"references\":[\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X2\"],\"customLabel\":true}},\"columnOrder\":[\"3dd24cb4-45ef-4dd8-b22a-d7b802cb6da0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X0\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X1\",\"52f6f2e9-6242-4c44-be63-b799150e7e60X2\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X0\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X1\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6X2\",\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"columns\":[{\"isTransposed\":false,\"columnId\":\"3dd24cb4-45ef-4dd8-b22a-d7b802cb6da0\",\"width\":262.75},{\"columnId\":\"52f6f2e9-6242-4c44-be63-b799150e7e60\",\"isTransposed\":false,\"width\":302.5,\"colorMode\":\"cell\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":5,\"stops\":[{\"color\":\"#f7e0b8\",\"stop\":0.6},{\"color\":\"#e7664c\",\"stop\":1}],\"name\":\"custom\",\"colorStops\":[{\"color\":\"#f7e0b8\",\"stop\":0.2},{\"color\":\"#e7664c\",\"stop\":0.6}],\"rangeType\":\"number\",\"rangeMin\":0.2,\"rangeMax\":0.6}},\"alignment\":\"center\"},{\"columnId\":\"7b9f3ece-9da3-4c27-b582-d3f8e8cc31d6\",\"isTransposed\":false,\"alignment\":\"center\",\"colorMode\":\"cell\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":5,\"stops\":[{\"color\":\"#f7e0b8\",\"stop\":0.6},{\"color\":\"#e7664c\",\"stop\":0.6666666666666666}],\"rangeType\":\"number\",\"name\":\"custom\",\"colorStops\":[{\"color\":\"#f7e0b8\",\"stop\":0.2},{\"color\":\"#e7664c\",\"stop\":0.6}],\"rangeMin\":0.2,\"rangeMax\":0.6}}}],\"layerId\":\"f26e8f7a-4118-4227-bea0-5c02d8b270f7\",\"sorting\":{\"columnId\":\"52f6f2e9-6242-4c44-be63-b799150e7e60\",\"direction\":\"desc\"},\"layerType\":\"data\",\"rowHeight\":\"single\",\"rowHeightLines\":1},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-f26e8f7a-4118-4227-bea0-5c02d8b270f7\",\"type\":\"index-pattern\"}]},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"[Flights] Most delayed cities\"},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":25,\"w\":24,\"h\":11,\"i\":\"0cc42484-16f7-42ec-b38c-9bf8be69cde7\"},\"panelIndex\":\"0cc42484-16f7-42ec-b38c-9bf8be69cde7\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsXY\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"e80cc05e-c52a-4e5f-ac71-4b37274867f5\":{\"columns\":{\"caf7421e-93a3-439e-ab0a-fbdead93c21c\":{\"label\":\"Top values of FlightDelayType\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"FlightDelayType\",\"isBucketed\":true,\"params\":{\"size\":10,\"orderBy\":{\"type\":\"column\",\"columnId\":\"0233d302-ec81-4fbe-96cb-7fac84cf035c\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false}},\"13ec79e3-9d73-4536-9056-3d92802bb30a\":{\"label\":\"timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true}},\"0233d302-ec81-4fbe-96cb-7fac84cf035c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\"}},\"columnOrder\":[\"caf7421e-93a3-439e-ab0a-fbdead93c21c\",\"13ec79e3-9d73-4536-9056-3d92802bb30a\",\"0233d302-ec81-4fbe-96cb-7fac84cf035c\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"bottom\",\"legendSize\":\"auto\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"yLeftExtent\":{\"mode\":\"full\"},\"yRightExtent\":{\"mode\":\"full\"},\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":false,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_percentage_stacked\",\"layers\":[{\"layerId\":\"e80cc05e-c52a-4e5f-ac71-4b37274867f5\",\"accessors\":[\"0233d302-ec81-4fbe-96cb-7fac84cf035c\"],\"position\":\"top\",\"seriesType\":\"bar_percentage_stacked\",\"showGridlines\":false,\"palette\":{\"type\":\"palette\",\"name\":\"cool\"},\"xAccessor\":\"13ec79e3-9d73-4536-9056-3d92802bb30a\",\"splitAccessor\":\"caf7421e-93a3-439e-ab0a-fbdead93c21c\",\"layerType\":\"data\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-e80cc05e-c52a-4e5f-ac71-4b37274867f5\",\"type\":\"index-pattern\"}]},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"[Flights] Delay Type\"},{\"version\":\"8.6.0\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":36,\"w\":12,\"h\":11,\"i\":\"5d53db36-2d5a-4adc-af7b-cec4c1a294e0\"},\"panelIndex\":\"5d53db36-2d5a-4adc-af7b-cec4c1a294e0\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"type\":\"lens\",\"visualizationType\":\"lnsPie\",\"state\":{\"datasourceStates\":{\"formBased\":{\"layers\":{\"0c8e136b-a822-4fb3-836d-e06cbea4eea4\":{\"columns\":{\"d1cee8bf-34cf-4141-99d7-ff043ee77b56\":{\"label\":\"Top values of FlightDelayType\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"FlightDelayType\",\"isBucketed\":true,\"params\":{\"size\":10,\"orderBy\":{\"type\":\"column\",\"columnId\":\"aa152ace-ee2d-447b-b86d-459bef4d7880\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false}},\"aa152ace-ee2d-447b-b86d-459bef4d7880\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\"}},\"columnOrder\":[\"d1cee8bf-34cf-4141-99d7-ff043ee77b56\",\"aa152ace-ee2d-447b-b86d-459bef4d7880\"],\"incompleteColumns\":{}}}}},\"visualization\":{\"shape\":\"pie\",\"palette\":{\"type\":\"palette\",\"name\":\"cool\"},\"layers\":[{\"layerId\":\"0c8e136b-a822-4fb3-836d-e06cbea4eea4\",\"metrics\":[\"aa152ace-ee2d-447b-b86d-459bef4d7880\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\",\"legendSize\":\"auto\",\"primaryGroups\":[\"d1cee8bf-34cf-4141-99d7-ff043ee77b56\"]}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"type\":\"phrase\",\"key\":\"FlightDelayType\",\"params\":{\"query\":\"No Delay\"},\"disabled\":false,\"negate\":true,\"alias\":null,\"index\":\"filter-index-pattern-0\"},\"query\":{\"match_phrase\":{\"FlightDelayType\":\"No Delay\"}},\"$state\":{\"store\":\"appState\"}}]},\"references\":[{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-current-indexpattern\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"indexpattern-datasource-layer-0c8e136b-a822-4fb3-836d-e06cbea4eea4\",\"type\":\"index-pattern\"},{\"id\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"name\":\"filter-index-pattern-0\",\"type\":\"index-pattern\"}]},\"enhancements\":{},\"hidePanelTitles\":false},\"title\":\"[Flights] Delay Type\"}]", "refreshInterval": { "pause": true, "value": 0 diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 56fcaa8832792..bd479b96f9b1d 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -32,6 +32,9 @@ export const RuleExecutionStatusValues = [ ] as const; export type RuleExecutionStatuses = typeof RuleExecutionStatusValues[number]; +export const RuleLastRunOutcomeValues = ['succeeded', 'warning', 'failed'] as const; +export type RuleLastRunOutcomes = typeof RuleLastRunOutcomeValues[number]; + export enum RuleExecutionStatusErrorReasons { Read = 'read', Decrypt = 'decrypt', @@ -76,12 +79,25 @@ export interface RuleAction { export interface RuleAggregations { alertExecutionStatus: { [status: string]: number }; + ruleLastRunOutcome: { [status: string]: number }; ruleEnabledStatus: { enabled: number; disabled: number }; ruleMutedStatus: { muted: number; unmuted: number }; ruleSnoozedStatus: { snoozed: number }; ruleTags: string[]; } +export interface RuleLastRun { + outcome: RuleLastRunOutcomes; + warning?: RuleExecutionStatusErrorReasons | RuleExecutionStatusWarningReasons | null; + outcomeMsg?: string | null; + alertsCount: { + active?: number | null; + new?: number | null; + recovered?: number | null; + ignored?: number | null; + }; +} + export interface MappedParamsProperties { risk_score?: number; severity?: string; @@ -116,6 +132,8 @@ export interface Rule { snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API activeSnoozes?: string[]; isSnoozedUntil?: Date | null; + lastRun?: RuleLastRun | null; + nextRun?: Date | null; } export type SanitizedRule = Omit, 'apiKey'>; @@ -175,16 +193,34 @@ export interface RuleMonitoringHistory extends SavedObjectAttributes { success: boolean; timestamp: number; duration?: number; + outcome?: RuleLastRunOutcomes; +} + +export interface RuleMonitoringCalculatedMetrics extends SavedObjectAttributes { + p50?: number; + p95?: number; + p99?: number; + success_ratio: number; +} + +export interface RuleMonitoringLastRunMetrics extends SavedObjectAttributes { + duration?: number; + total_search_duration_ms?: number | null; + total_indexing_duration_ms?: number | null; + total_alerts_detected?: number | null; + total_alerts_created?: number | null; + gap_duration_s?: number | null; +} + +export interface RuleMonitoringLastRun extends SavedObjectAttributes { + timestamp: string; + metrics: RuleMonitoringLastRunMetrics; } -export interface RuleMonitoring extends SavedObjectAttributes { - execution: { +export interface RuleMonitoring { + run: { history: RuleMonitoringHistory[]; - calculated_metrics: { - p50?: number; - p95?: number; - p99?: number; - success_ratio: number; - }; + calculated_metrics: RuleMonitoringCalculatedMetrics; + last_run: RuleMonitoringLastRun; }; } diff --git a/x-pack/plugins/alerting/public/lib/common_transformations.test.ts b/x-pack/plugins/alerting/public/lib/common_transformations.test.ts index 51d24538b449e..6b7026f1ea593 100644 --- a/x-pack/plugins/alerting/public/lib/common_transformations.test.ts +++ b/x-pack/plugins/alerting/public/lib/common_transformations.test.ts @@ -6,7 +6,7 @@ */ import { ApiRule, transformRule } from './common_transformations'; -import { RuleExecutionStatusErrorReasons } from '../../common'; +import { RuleExecutionStatusErrorReasons, RuleLastRunOutcomeValues } from '../../common'; beforeEach(() => jest.resetAllMocks()); @@ -54,6 +54,43 @@ describe('common_transformations', () => { message: 'this is just a test', }, }, + monitoring: { + run: { + history: [ + { + timestamp: dateExecuted.getTime(), + duration: 42, + success: false, + outcome: RuleLastRunOutcomeValues[2], + }, + ], + calculated_metrics: { + success_ratio: 0, + p50: 0, + p95: 42, + p99: 42, + }, + last_run: { + timestamp: dateExecuted.toISOString(), + metrics: { + duration: 42, + total_search_duration_ms: 100, + }, + }, + }, + }, + last_run: { + outcome: RuleLastRunOutcomeValues[2], + outcome_msg: 'this is just a test', + warning: RuleExecutionStatusErrorReasons.Unknown, + alerts_count: { + new: 1, + active: 2, + recovered: 3, + ignored: 4, + }, + }, + next_run: dateUpdated.toISOString(), }; expect(transformRule(apiRule)).toMatchInlineSnapshot(` Object { @@ -89,12 +126,49 @@ describe('common_transformations', () => { "status": "error", }, "id": "some-id", + "lastRun": Object { + "alertsCount": Object { + "active": 2, + "ignored": 4, + "new": 1, + "recovered": 3, + }, + "outcome": "failed", + "outcomeMsg": "this is just a test", + "warning": "unknown", + }, + "monitoring": Object { + "run": Object { + "calculated_metrics": Object { + "p50": 0, + "p95": 42, + "p99": 42, + "success_ratio": 0, + }, + "history": Array [ + Object { + "duration": 42, + "outcome": "failed", + "success": false, + "timestamp": 1639571696789, + }, + ], + "last_run": Object { + "metrics": Object { + "duration": 42, + "total_search_duration_ms": 100, + }, + "timestamp": "2021-12-15T12:34:56.789Z", + }, + }, + }, "muteAll": false, "mutedInstanceIds": Array [ "bob", "jim", ], "name": "some-name", + "nextRun": 2021-12-15T12:34:55.789Z, "notifyWhen": "onActiveAlert", "params": Object { "bar": "foo", @@ -152,6 +226,43 @@ describe('common_transformations', () => { last_execution_date: dateExecuted.toISOString(), status: 'error', }, + monitoring: { + run: { + history: [ + { + timestamp: dateExecuted.getTime(), + duration: 42, + success: false, + outcome: 'failed', + }, + ], + calculated_metrics: { + success_ratio: 0, + p50: 0, + p95: 42, + p99: 42, + }, + last_run: { + timestamp: dateExecuted.toISOString(), + metrics: { + duration: 42, + total_search_duration_ms: 100, + }, + }, + }, + }, + last_run: { + outcome: 'failed', + outcome_msg: 'this is just a test', + warning: RuleExecutionStatusErrorReasons.Unknown, + alerts_count: { + new: 1, + active: 2, + recovered: 3, + ignored: 4, + }, + }, + next_run: dateUpdated.toISOString(), }; expect(transformRule(apiRule)).toMatchInlineSnapshot(` Object { @@ -176,12 +287,49 @@ describe('common_transformations', () => { "status": "error", }, "id": "some-id", + "lastRun": Object { + "alertsCount": Object { + "active": 2, + "ignored": 4, + "new": 1, + "recovered": 3, + }, + "outcome": "failed", + "outcomeMsg": "this is just a test", + "warning": "unknown", + }, + "monitoring": Object { + "run": Object { + "calculated_metrics": Object { + "p50": 0, + "p95": 42, + "p99": 42, + "success_ratio": 0, + }, + "history": Array [ + Object { + "duration": 42, + "outcome": "failed", + "success": false, + "timestamp": 1639571696789, + }, + ], + "last_run": Object { + "metrics": Object { + "duration": 42, + "total_search_duration_ms": 100, + }, + "timestamp": "2021-12-15T12:34:56.789Z", + }, + }, + }, "muteAll": false, "mutedInstanceIds": Array [ "bob", "jim", ], "name": "some-name", + "nextRun": 2021-12-15T12:34:55.789Z, "notifyWhen": "onActiveAlert", "params": Object {}, "schedule": Object { diff --git a/x-pack/plugins/alerting/public/lib/common_transformations.ts b/x-pack/plugins/alerting/public/lib/common_transformations.ts index 1b306aae0ae2f..c48c1f882eaed 100644 --- a/x-pack/plugins/alerting/public/lib/common_transformations.ts +++ b/x-pack/plugins/alerting/public/lib/common_transformations.ts @@ -5,7 +5,14 @@ * 2.0. */ import { AsApiContract } from '@kbn/actions-plugin/common'; -import { RuleExecutionStatus, Rule, RuleAction, RuleType } from '../../common'; +import { + RuleExecutionStatus, + RuleMonitoring, + Rule, + RuleLastRun, + RuleAction, + RuleType, +} from '../../common'; function transformAction(input: AsApiContract): RuleAction { const { connector_type_id: actionTypeId, ...rest } = input; @@ -27,6 +34,31 @@ function transformExecutionStatus(input: ApiRuleExecutionStatus): RuleExecutionS }; } +function transformMonitoring(input: RuleMonitoring): RuleMonitoring { + const { run } = input; + const { last_run: lastRun, ...rest } = run; + const { timestamp, ...restLastRun } = lastRun; + + return { + run: { + last_run: { + timestamp: input.run.last_run.timestamp, + ...restLastRun, + }, + ...rest, + }, + }; +} + +function transformLastRun(input: AsApiContract): RuleLastRun { + const { outcome_msg: outcomeMsg, alerts_count: alertsCount, ...rest } = input; + return { + outcomeMsg, + alertsCount, + ...rest, + }; +} + // AsApiContract does not deal with object properties that also // need snake -> camel conversion, Dates, are renamed, etc, so we do by hand export type ApiRule = Omit< @@ -37,6 +69,8 @@ export type ApiRule = Omit< | 'updated_at' | 'alert_type_id' | 'muted_instance_ids' + | 'last_run' + | 'next_run' > & { execution_status: ApiRuleExecutionStatus; actions: Array>; @@ -44,6 +78,8 @@ export type ApiRule = Omit< updated_at: string; rule_type_id: string; muted_alert_ids: string[]; + last_run?: AsApiContract; + next_run?: string; }; export function transformRule(input: ApiRule): Rule { @@ -61,6 +97,9 @@ export function transformRule(input: ApiRule): Rule { scheduled_task_id: scheduledTaskId, execution_status: executionStatusAPI, actions: actionsAPI, + next_run: nextRun, + last_run: lastRun, + monitoring: monitoring, ...rest } = input; @@ -78,6 +117,9 @@ export function transformRule(input: ApiRule): Rule { executionStatus: transformExecutionStatus(executionStatusAPI), actions: actionsAPI ? actionsAPI.map((action) => transformAction(action)) : [], scheduledTaskId, + ...(nextRun ? { nextRun: new Date(nextRun) } : {}), + ...(monitoring ? { monitoring: transformMonitoring(monitoring) } : {}), + ...(lastRun ? { lastRun: transformLastRun(lastRun) } : {}), ...rest, }; } diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index 825a9863badbb..de4c89e06f33d 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -25,6 +25,13 @@ export { ruleExecutionStatusToRaw, ruleExecutionStatusFromRaw, } from './rule_execution_status'; +export { lastRunFromState, lastRunFromError, lastRunToRaw } from './last_run_status'; +export { + updateMonitoring, + getDefaultMonitoring, + convertMonitoringFromRawAndVerify, +} from './monitoring'; +export { getNextRun } from './next_run'; export { processAlerts } from './process_alerts'; export { createWrappedScopedClusterClientFactory } from './wrap_scoped_cluster_client'; export { isRuleSnoozed, getRuleSnoozeEndTime } from './is_rule_snoozed'; diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.test.ts b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts new file mode 100644 index 0000000000000..da44325ba3cbd --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lastRunFromState } from './last_run_status'; +import { ActionsCompletion } from '../../common'; +import { RuleRunMetrics } from './rule_run_metrics_store'; +const getMetrics = (): RuleRunMetrics => { + return { + triggeredActionsStatus: ActionsCompletion.COMPLETE, + esSearchDurationMs: 3, + numSearches: 1, + numberOfActiveAlerts: 10, + numberOfGeneratedActions: 15, + numberOfNewAlerts: 12, + numberOfRecoveredAlerts: 11, + numberOfTriggeredActions: 5, + totalSearchDurationMs: 2, + hasReachedAlertLimit: false, + }; +}; + +describe('lastRunFromState', () => { + it('successfuly outcome', () => { + const result = lastRunFromState({ metrics: getMetrics() }); + + expect(result.lastRun.outcome).toEqual('succeeded'); + expect(result.lastRun.outcomeMsg).toEqual(null); + expect(result.lastRun.warning).toEqual(null); + + expect(result.lastRun.alertsCount).toEqual({ + active: 10, + new: 12, + recovered: 11, + ignored: 0, + }); + }); + + it('limited reached outcome', () => { + const result = lastRunFromState({ + metrics: { + ...getMetrics(), + hasReachedAlertLimit: true, + }, + }); + + expect(result.lastRun.outcome).toEqual('warning'); + expect(result.lastRun.outcomeMsg).toEqual( + 'Rule reported more than the maximum number of alerts in a single run. Alerts may be missed and recovery notifications may be delayed' + ); + expect(result.lastRun.warning).toEqual('maxAlerts'); + + expect(result.lastRun.alertsCount).toEqual({ + active: 10, + new: 12, + recovered: 11, + ignored: 0, + }); + }); + + it('partial triggered actions status outcome', () => { + const result = lastRunFromState({ + metrics: { + ...getMetrics(), + triggeredActionsStatus: ActionsCompletion.PARTIAL, + }, + }); + + expect(result.lastRun.outcome).toEqual('warning'); + expect(result.lastRun.outcomeMsg).toEqual( + 'The maximum number of actions for this rule type was reached; excess actions were not triggered.' + ); + expect(result.lastRun.warning).toEqual('maxExecutableActions'); + + expect(result.lastRun.alertsCount).toEqual({ + active: 10, + new: 12, + recovered: 11, + ignored: 0, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.ts b/x-pack/plugins/alerting/server/lib/last_run_status.ts new file mode 100644 index 0000000000000..d7027fc7c2770 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/last_run_status.ts @@ -0,0 +1,87 @@ +/* + * 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 { RuleTaskStateAndMetrics } from '../task_runner/types'; +import { getReasonFromError } from './error_with_reason'; +import { getEsErrorMessage } from './errors'; +import { ActionsCompletion } from '../../common'; +import { + RuleLastRunOutcomeValues, + RuleLastRunOutcomes, + RuleExecutionStatusWarningReasons, + RawRuleLastRun, + RuleLastRun, +} from '../types'; +import { translations } from '../constants/translations'; +import { RuleRunMetrics } from './rule_run_metrics_store'; + +export interface ILastRun { + lastRun: RuleLastRun; + metrics: RuleRunMetrics | null; +} + +export const lastRunFromState = (stateWithMetrics: RuleTaskStateAndMetrics): ILastRun => { + const { metrics } = stateWithMetrics; + let outcome: RuleLastRunOutcomes = RuleLastRunOutcomeValues[0]; + // Check for warning states + let warning = null; + let outcomeMsg = null; + + // We only have a single warning field so prioritizing the alert circuit breaker over the actions circuit breaker + if (metrics.hasReachedAlertLimit) { + outcome = RuleLastRunOutcomeValues[1]; + warning = RuleExecutionStatusWarningReasons.MAX_ALERTS; + outcomeMsg = translations.taskRunner.warning.maxAlerts; + } else if (metrics.triggeredActionsStatus === ActionsCompletion.PARTIAL) { + outcome = RuleLastRunOutcomeValues[1]; + warning = RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS; + outcomeMsg = translations.taskRunner.warning.maxExecutableActions; + } + + return { + lastRun: { + outcome, + outcomeMsg: outcomeMsg || null, + warning: warning || null, + alertsCount: { + active: metrics.numberOfActiveAlerts, + new: metrics.numberOfNewAlerts, + recovered: metrics.numberOfRecoveredAlerts, + ignored: 0, + }, + }, + metrics, + }; +}; + +export const lastRunFromError = (error: Error): ILastRun => { + return { + lastRun: { + outcome: RuleLastRunOutcomeValues[2], + warning: getReasonFromError(error), + outcomeMsg: getEsErrorMessage(error), + alertsCount: {}, + }, + metrics: null, + }; +}; + +export const lastRunToRaw = (lastRun: ILastRun['lastRun']): RawRuleLastRun => { + const { warning, alertsCount, outcomeMsg } = lastRun; + + return { + ...lastRun, + alertsCount: { + active: alertsCount.active || 0, + new: alertsCount.new || 0, + recovered: alertsCount.recovered || 0, + ignored: alertsCount.ignored || 0, + }, + warning: warning ?? null, + outcomeMsg: outcomeMsg ?? null, + }; +}; diff --git a/x-pack/plugins/alerting/server/lib/monitoring.test.ts b/x-pack/plugins/alerting/server/lib/monitoring.test.ts index eeafdfcff1cbe..492e205a99508 100644 --- a/x-pack/plugins/alerting/server/lib/monitoring.test.ts +++ b/x-pack/plugins/alerting/server/lib/monitoring.test.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { getExecutionDurationPercentiles } from './monitoring'; +import { + getExecutionDurationPercentiles, + updateMonitoring, + convertMonitoringFromRawAndVerify, +} from './monitoring'; import { RuleMonitoring } from '../types'; const mockHistory = [ @@ -42,17 +46,23 @@ const mockHistory = [ ]; const mockRuleMonitoring = { - execution: { + run: { history: mockHistory, calculated_metrics: { success_ratio: 0, }, + last_run: { + timestamp: '2022-06-18T01:00:00.000Z', + metrics: { + duration: 123, + }, + }, }, } as RuleMonitoring; describe('getExecutionDurationPercentiles', () => { it('Calculates the percentile given partly undefined durations', () => { - const percentiles = getExecutionDurationPercentiles(mockRuleMonitoring); + const percentiles = getExecutionDurationPercentiles(mockRuleMonitoring.run.history); expect(percentiles.p50).toEqual(250); expect(percentiles.p95).toEqual(500); expect(percentiles.p99).toEqual(500); @@ -66,13 +76,53 @@ describe('getExecutionDurationPercentiles', () => { const newMockRuleMonitoring = { ...mockRuleMonitoring, - execution: { - ...mockRuleMonitoring.execution, + run: { + ...mockRuleMonitoring.run, history: nullDurationHistory, }, } as RuleMonitoring; - const percentiles = getExecutionDurationPercentiles(newMockRuleMonitoring); + const percentiles = getExecutionDurationPercentiles(newMockRuleMonitoring.run.history); expect(Object.keys(percentiles).length).toEqual(0); }); }); + +describe('updateMonitoring', () => { + it('can update monitoring', () => { + const result = updateMonitoring({ + monitoring: mockRuleMonitoring, + timestamp: '2022-07-18T01:00:00.000Z', + duration: 1000, + }); + + expect(result.run.history).toEqual(mockRuleMonitoring.run.history); + expect(result.run.calculated_metrics).toEqual(mockRuleMonitoring.run.calculated_metrics); + expect(result.run.last_run.timestamp).toEqual('2022-07-18T01:00:00.000Z'); + expect(result.run.last_run.metrics.duration).toEqual(1000); + }); +}); + +describe('convertMonitoringFromRawAndVerify', () => { + it('can convert monitoring to raw and verify the duration', () => { + const monitoring = { + run: { + ...mockRuleMonitoring.run, + last_run: { + ...mockRuleMonitoring.run.last_run, + timestamp: 'invalid', + }, + }, + }; + + const mockLoggerDebug = jest.fn(); + const mockLogger = { + debug: mockLoggerDebug, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = convertMonitoringFromRawAndVerify(mockLogger as any, '123', monitoring); + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'invalid monitoring last_run.timestamp "invalid" in raw rule 123' + ); + expect(Date.parse(result!.run.last_run.timestamp)).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/monitoring.ts b/x-pack/plugins/alerting/server/lib/monitoring.ts index d817b10225ee0..93da6e2283152 100644 --- a/x-pack/plugins/alerting/server/lib/monitoring.ts +++ b/x-pack/plugins/alerting/server/lib/monitoring.ts @@ -4,19 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { Logger } from '@kbn/core/server'; import stats from 'stats-lite'; -import { RuleMonitoring } from '../types'; +import { RuleMonitoring, RawRuleMonitoring, RuleMonitoringHistory } from '../types'; + +export const INITIAL_METRICS = { + total_search_duration_ms: null, + total_indexing_duration_ms: null, + total_alerts_detected: null, + total_alerts_created: null, + gap_duration_s: null, +}; -export const getExecutionSuccessRatio = (ruleMonitoring: RuleMonitoring) => { - const { history } = ruleMonitoring.execution; - return history.filter(({ success }) => success).length / history.length; +export const getDefaultMonitoring = (timestamp: string): RawRuleMonitoring => { + return { + run: { + history: [], + calculated_metrics: { + success_ratio: 0, + }, + last_run: { + timestamp, + metrics: INITIAL_METRICS, + }, + }, + }; }; -export const getExecutionDurationPercentiles = (ruleMonitoring: RuleMonitoring) => { - const durationSamples = ruleMonitoring.execution.history.reduce((duration, history) => { - if (typeof history.duration === 'number') { - return [...duration, history.duration]; +export const getExecutionDurationPercentiles = (history: RuleMonitoringHistory[]) => { + const durationSamples = history.reduce((duration, historyItem) => { + if (typeof historyItem.duration === 'number') { + return [...duration, historyItem.duration]; } return duration; }, []); @@ -31,3 +49,56 @@ export const getExecutionDurationPercentiles = (ruleMonitoring: RuleMonitoring) return {}; }; + +// Immutably updates the monitoring object with timestamp and duration. +// Used when converting from and between raw monitoring object +export const updateMonitoring = ({ + monitoring, + timestamp, + duration, +}: { + monitoring: RuleMonitoring; + timestamp: string; + duration?: number; +}) => { + const { run } = monitoring; + const { last_run: lastRun, ...rest } = run; + const { metrics = INITIAL_METRICS } = lastRun; + + return { + run: { + last_run: { + timestamp, + metrics: { + ...metrics, + duration, + }, + }, + ...rest, + }, + }; +}; + +export const convertMonitoringFromRawAndVerify = ( + logger: Logger, + ruleId: string, + monitoring: RawRuleMonitoring +): RuleMonitoring | undefined => { + if (!monitoring) { + return undefined; + } + + const lastRunDate = monitoring.run.last_run.timestamp; + + let parsedDateMillis = lastRunDate ? Date.parse(lastRunDate) : Date.now(); + if (isNaN(parsedDateMillis)) { + logger.debug(`invalid monitoring last_run.timestamp "${lastRunDate}" in raw rule ${ruleId}`); + parsedDateMillis = Date.now(); + } + + return updateMonitoring({ + monitoring, + timestamp: new Date(parsedDateMillis).toISOString(), + duration: monitoring.run.last_run.metrics.duration, + }); +}; diff --git a/x-pack/plugins/alerting/server/lib/next_run.ts b/x-pack/plugins/alerting/server/lib/next_run.ts new file mode 100644 index 0000000000000..8ce87e9db6883 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/next_run.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { parseDuration } from '../../common'; + +export const getNextRun = ({ + startDate, + interval, +}: { + startDate?: Date | null; + interval: string; +}) => { + return moment(startDate || new Date()) + .add(parseDuration(interval), 'ms') + .toISOString(); +}; diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index 0a99489822d38..18f0a66bb1657 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -14,7 +14,7 @@ import { searchSourceCommonMock } from '@kbn/data-plugin/common/search/search_so import { rulesClientMock } from './rules_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { Alert, AlertFactoryDoneUtils } from './alert'; -import { AlertInstanceContext, AlertInstanceState } from './types'; +import { AlertInstanceContext, AlertInstanceState, PublicRuleMonitoringService } from './types'; export { rulesClientMock }; @@ -96,6 +96,18 @@ const createAbortableSearchServiceMock = () => { }; }; +const createRuleMonitoringServiceMock = () => { + const mock = { + setLastRunMetricsTotalSearchDurationMs: jest.fn(), + setLastRunMetricsTotalIndexingDurationMs: jest.fn(), + setLastRunMetricsTotalAlertsDetected: jest.fn(), + setLastRunMetricsTotalAlertsCreated: jest.fn(), + setLastRunMetricsGapDurationS: jest.fn(), + } as unknown as jest.Mocked; + + return mock; +}; + const createRuleExecutorServicesMock = < InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext @@ -118,6 +130,7 @@ const createRuleExecutorServicesMock = < shouldStopExecution: () => true, search: createAbortableSearchServiceMock(), searchSourceClient: searchSourceCommonMock, + ruleMonitoringService: createRuleMonitoringServiceMock(), }; }; export type RuleExecutorServicesMock = ReturnType; @@ -128,3 +141,5 @@ export const alertsMock = { createStart: createStartMock, createRuleExecutorServices: createRuleExecutorServicesMock, }; + +export const ruleMonitoringServiceMock = { create: createRuleMonitoringServiceMock }; diff --git a/x-pack/plugins/alerting/server/monitoring/rule_monitoring_service.test.ts b/x-pack/plugins/alerting/server/monitoring/rule_monitoring_service.test.ts new file mode 100644 index 0000000000000..54fc6552308b5 --- /dev/null +++ b/x-pack/plugins/alerting/server/monitoring/rule_monitoring_service.test.ts @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleMonitoringService } from './rule_monitoring_service'; +import { getDefaultMonitoring } from '../lib/monitoring'; + +const mockNow = '2020-01-01T02:00:00.000Z'; + +const ONE_MINUTE = 60 * 1000; +const ONE_HOUR = 60 * ONE_MINUTE; + +describe('RuleMonitoringService', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date(mockNow).getTime()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should initialize with default monitoring', () => { + const ruleMonitoringService = new RuleMonitoringService(); + expect(ruleMonitoringService.getMonitoring()).toEqual( + getDefaultMonitoring(new Date(mockNow).toISOString()) + ); + }); + + it('should add history', () => { + const ruleMonitoringService = new RuleMonitoringService(); + + jest.advanceTimersByTime(ONE_HOUR); + const firstRunDate = new Date(); + + ruleMonitoringService.addHistory({ + duration: ONE_MINUTE, + hasError: false, + runDate: firstRunDate, + }); + + jest.advanceTimersByTime(ONE_HOUR); + const secondRunDate = new Date(); + + ruleMonitoringService.addHistory({ + duration: 2 * ONE_MINUTE, + hasError: true, + runDate: secondRunDate, + }); + + const { run } = ruleMonitoringService.getMonitoring(); + const { history, last_run: lastRun, calculated_metrics: calculatedMetrics } = run; + const { timestamp, metrics } = lastRun; + + expect(history.length).toEqual(2); + expect(history[0]).toEqual({ + success: true, + timestamp: firstRunDate.getTime(), + duration: ONE_MINUTE, + }); + expect(history[1]).toEqual({ + success: false, + timestamp: secondRunDate.getTime(), + duration: 2 * ONE_MINUTE, + }); + + expect(timestamp).toEqual(secondRunDate.toISOString()); + expect(metrics.duration).toEqual(2 * ONE_MINUTE); + + expect(calculatedMetrics).toEqual({ success_ratio: 0.5, p50: 90000, p95: 120000, p99: 120000 }); + }); + + describe('setters', () => { + it('should set monitoring', () => { + const ruleMonitoringService = new RuleMonitoringService(); + const customMonitoring = { + run: { + history: [ + { + success: true, + duration: 100000, + timestamp: 0, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 100, + p95: 1000, + p99: 10000, + }, + last_run: { + timestamp: mockNow, + metrics: { + duration: 100000, + }, + }, + }, + }; + ruleMonitoringService.setMonitoring(customMonitoring); + expect(ruleMonitoringService.getMonitoring()).toEqual(customMonitoring); + }); + + it('should set totalSearchDurationMs', () => { + const ruleMonitoringService = new RuleMonitoringService(); + const { setLastRunMetricsTotalSearchDurationMs } = + ruleMonitoringService.getLastRunMetricsSetters(); + setLastRunMetricsTotalSearchDurationMs(123); + + const { + run: { + last_run: { metrics }, + }, + } = ruleMonitoringService.getMonitoring(); + expect(metrics.total_search_duration_ms).toEqual(123); + }); + + it('should set totalIndexDurationMs', () => { + const ruleMonitoringService = new RuleMonitoringService(); + const { setLastRunMetricsTotalIndexingDurationMs } = + ruleMonitoringService.getLastRunMetricsSetters(); + setLastRunMetricsTotalIndexingDurationMs(234); + + const { + run: { + last_run: { metrics }, + }, + } = ruleMonitoringService.getMonitoring(); + expect(metrics.total_indexing_duration_ms).toEqual(234); + }); + + it('should set totalAlertsDetected', () => { + const ruleMonitoringService = new RuleMonitoringService(); + const { setLastRunMetricsTotalAlertsDetected } = + ruleMonitoringService.getLastRunMetricsSetters(); + setLastRunMetricsTotalAlertsDetected(345); + + const { + run: { + last_run: { metrics }, + }, + } = ruleMonitoringService.getMonitoring(); + expect(metrics.total_alerts_detected).toEqual(345); + }); + + it('should set totalAlertsCreated', () => { + const ruleMonitoringService = new RuleMonitoringService(); + const { setLastRunMetricsTotalAlertsCreated } = + ruleMonitoringService.getLastRunMetricsSetters(); + setLastRunMetricsTotalAlertsCreated(456); + + const { + run: { + last_run: { metrics }, + }, + } = ruleMonitoringService.getMonitoring(); + expect(metrics.total_alerts_created).toEqual(456); + }); + + it('should set gapDurationS', () => { + const ruleMonitoringService = new RuleMonitoringService(); + const { setLastRunMetricsGapDurationS } = ruleMonitoringService.getLastRunMetricsSetters(); + setLastRunMetricsGapDurationS(567); + + const { + run: { + last_run: { metrics }, + }, + } = ruleMonitoringService.getMonitoring(); + expect(metrics.gap_duration_s).toEqual(567); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/monitoring/rule_monitoring_service.ts b/x-pack/plugins/alerting/server/monitoring/rule_monitoring_service.ts new file mode 100644 index 0000000000000..0043f47c51633 --- /dev/null +++ b/x-pack/plugins/alerting/server/monitoring/rule_monitoring_service.ts @@ -0,0 +1,98 @@ +/* + * 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 { getDefaultMonitoring, getExecutionDurationPercentiles } from '../lib/monitoring'; +import { RuleMonitoring, RuleMonitoringHistory, PublicRuleMonitoringService } from '../types'; + +export class RuleMonitoringService { + private monitoring: RuleMonitoring = getDefaultMonitoring(new Date().toISOString()); + + public setLastRunMetricsDuration(duration: number) { + this.monitoring.run.last_run.metrics.duration = duration; + } + + public setMonitoring(monitoringFromSO: RuleMonitoring | undefined) { + if (monitoringFromSO) { + this.monitoring = monitoringFromSO; + } + } + + public getMonitoring(): RuleMonitoring { + return this.monitoring; + } + + public addHistory({ + duration, + hasError = true, + runDate, + }: { + duration: number | undefined; + hasError: boolean; + runDate: Date; + }) { + const date = runDate ?? new Date(); + const monitoringHistory: RuleMonitoringHistory = { + success: true, + timestamp: date.getTime(), + }; + if (null != duration) { + monitoringHistory.duration = duration; + this.setLastRunMetricsDuration(duration); + } + if (hasError) { + monitoringHistory.success = false; + } + this.monitoring.run.last_run.timestamp = date.toISOString(); + this.monitoring.run.history.push(monitoringHistory); + this.monitoring.run.calculated_metrics = { + success_ratio: this.buildExecutionSuccessRatio(), + ...this.buildExecutionDurationPercentiles(), + }; + } + + public getLastRunMetricsSetters(): PublicRuleMonitoringService { + return { + setLastRunMetricsTotalSearchDurationMs: + this.setLastRunMetricsTotalSearchDurationMs.bind(this), + setLastRunMetricsTotalIndexingDurationMs: + this.setLastRunMetricsTotalIndexingDurationMs.bind(this), + setLastRunMetricsTotalAlertsDetected: this.setLastRunMetricsTotalAlertsDetected.bind(this), + setLastRunMetricsTotalAlertsCreated: this.setLastRunMetricsTotalAlertsCreated.bind(this), + setLastRunMetricsGapDurationS: this.setLastRunMetricsGapDurationS.bind(this), + }; + } + + private setLastRunMetricsTotalSearchDurationMs(totalSearchDurationMs: number) { + this.monitoring.run.last_run.metrics.total_search_duration_ms = totalSearchDurationMs; + } + + private setLastRunMetricsTotalIndexingDurationMs(totalIndexingDurationMs: number) { + this.monitoring.run.last_run.metrics.total_indexing_duration_ms = totalIndexingDurationMs; + } + + private setLastRunMetricsTotalAlertsDetected(totalAlertDetected: number) { + this.monitoring.run.last_run.metrics.total_alerts_detected = totalAlertDetected; + } + + private setLastRunMetricsTotalAlertsCreated(totalAlertCreated: number) { + this.monitoring.run.last_run.metrics.total_alerts_created = totalAlertCreated; + } + + private setLastRunMetricsGapDurationS(gapDurationS: number) { + this.monitoring.run.last_run.metrics.gap_duration_s = gapDurationS; + } + + private buildExecutionSuccessRatio() { + const { history } = this.monitoring.run; + return history.filter(({ success }) => success).length / history.length; + } + + private buildExecutionDurationPercentiles = () => { + const { history } = this.monitoring.run; + return getExecutionDurationPercentiles(history); + }; +} diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts index 8c24b457df565..26210e9bed285 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts @@ -49,6 +49,11 @@ describe('aggregateRulesRoute', () => { pending: 1, unknown: 0, }, + ruleLastRunOutcome: { + succeeded: 1, + failed: 2, + warning: 3, + }, ruleEnabledStatus: { disabled: 1, enabled: 40, @@ -88,6 +93,11 @@ describe('aggregateRulesRoute', () => { "pending": 1, "unknown": 0, }, + "rule_last_run_outcome": Object { + "failed": 2, + "succeeded": 1, + "warning": 3, + }, "rule_muted_status": Object { "muted": 2, "unmuted": 39, @@ -128,6 +138,11 @@ describe('aggregateRulesRoute', () => { pending: 1, unknown: 0, }, + rule_last_run_outcome: { + succeeded: 1, + failed: 2, + warning: 3, + }, rule_muted_status: { muted: 2, unmuted: 39, @@ -156,6 +171,11 @@ describe('aggregateRulesRoute', () => { pending: 1, unknown: 0, }, + ruleLastRunOutcome: { + succeeded: 2, + failed: 4, + warning: 6, + }, }); const [context, req, res] = mockHandlerArguments( @@ -209,6 +229,11 @@ describe('aggregateRulesRoute', () => { pending: 1, unknown: 0, }, + ruleLastRunOutcome: { + succeeded: 2, + failed: 4, + warning: 6, + }, }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); const [, handler] = router.get.mock.calls[0]; diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts index c48c74fc28754..acab5ca75d2e0 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts @@ -47,6 +47,7 @@ const rewriteQueryReq: RewriteRequestCase = ({ }); const rewriteBodyRes: RewriteResponseCase = ({ alertExecutionStatus, + ruleLastRunOutcome, ruleEnabledStatus, ruleMutedStatus, ruleSnoozedStatus, @@ -55,6 +56,7 @@ const rewriteBodyRes: RewriteResponseCase = ({ }) => ({ ...rest, rule_execution_status: alertExecutionStatus, + rule_last_run_outcome: ruleLastRunOutcome, rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, rule_snoozed_status: ruleSnoozedStatus, diff --git a/x-pack/plugins/alerting/server/routes/create_rule.ts b/x-pack/plugins/alerting/server/routes/create_rule.ts index 442162ae21cbb..1b114dc54d26b 100644 --- a/x-pack/plugins/alerting/server/routes/create_rule.ts +++ b/x-pack/plugins/alerting/server/routes/create_rule.ts @@ -14,6 +14,7 @@ import { handleDisabledApiKeysError, verifyAccessAndContext, countUsageOfPredefinedIds, + rewriteRuleLastRun, } from './lib'; import { SanitizedRule, @@ -68,6 +69,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ muteAll, mutedInstanceIds, snoozeSchedule, + lastRun, + nextRun, executionStatus: { lastExecutionDate, lastDuration, ...executionStatus }, ...rest }) => ({ @@ -94,6 +97,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ params, connector_type_id: actionTypeId, })), + ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), + ...(nextRun ? { next_run: nextRun } : {}), }); export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOptions) => { diff --git a/x-pack/plugins/alerting/server/routes/get_rule.ts b/x-pack/plugins/alerting/server/routes/get_rule.ts index f7834ec52b9ed..0b529b35fa466 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.ts @@ -9,7 +9,7 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; import { ILicenseState } from '../lib'; -import { verifyAccessAndContext, RewriteResponseCase } from './lib'; +import { verifyAccessAndContext, RewriteResponseCase, rewriteRuleLastRun } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, @@ -37,6 +37,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ scheduledTaskId, snoozeSchedule, isSnoozedUntil, + lastRun, + nextRun, ...rest }) => ({ ...rest, @@ -63,6 +65,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ params, connector_type_id: actionTypeId, })), + ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), + ...(nextRun ? { next_run: nextRun } : {}), }); interface BuildGetRulesRouteParams { diff --git a/x-pack/plugins/alerting/server/routes/legacy/aggregate.test.ts b/x-pack/plugins/alerting/server/routes/legacy/aggregate.test.ts index a2fad24b24c0f..1fc3ee17ab51e 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/aggregate.test.ts @@ -54,6 +54,11 @@ describe('aggregateAlertRoute', () => { pending: 1, unknown: 0, }, + ruleLastRunOutcome: { + succeeded: 1, + failed: 2, + warning: 3, + }, }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); @@ -77,6 +82,11 @@ describe('aggregateAlertRoute', () => { "pending": 1, "unknown": 0, }, + "ruleLastRunOutcome": Object { + "failed": 2, + "succeeded": 1, + "warning": 3, + }, }, } `); @@ -113,6 +123,11 @@ describe('aggregateAlertRoute', () => { pending: 1, unknown: 0, }, + ruleLastRunOutcome: { + succeeded: 1, + failed: 2, + warning: 3, + }, }); const [context, req, res] = mockHandlerArguments( diff --git a/x-pack/plugins/alerting/server/routes/lib/index.ts b/x-pack/plugins/alerting/server/routes/lib/index.ts index 90d903ada6eed..cda768e7b363b 100644 --- a/x-pack/plugins/alerting/server/routes/lib/index.ts +++ b/x-pack/plugins/alerting/server/routes/lib/index.ts @@ -18,5 +18,5 @@ export type { } from './rewrite_request_case'; export { verifyAccessAndContext } from './verify_access_and_context'; export { countUsageOfPredefinedIds } from './count_usage_of_predefined_ids'; -export { rewriteRule } from './rewrite_rule'; +export { rewriteRule, rewriteRuleLastRun } from './rewrite_rule'; export { rewriteNamespaces } from './rewrite_namespaces'; diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_request_case.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_request_case.ts index 2874f9567231b..4eb352d2b2b8c 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_request_case.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_request_case.ts @@ -24,7 +24,7 @@ type RenameAlertToRule = K extends `alertTypeId` export type AsApiContract< T, - ComplexPropertyKeys = `actions` | `executionStatus`, + ComplexPropertyKeys = `actions` | `executionStatus` | 'lastRun', OpaquePropertyKeys = `params` > = T extends Array ? Array> diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts index 4b3ab65e3e8e4..270db64812b28 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -6,7 +6,16 @@ */ import { omit } from 'lodash'; -import { RuleTypeParams, SanitizedRule } from '../../types'; +import { RuleTypeParams, SanitizedRule, RuleLastRun } from '../../types'; + +export const rewriteRuleLastRun = (lastRun: RuleLastRun) => { + const { outcomeMsg, alertsCount, ...rest } = lastRun; + return { + alerts_count: alertsCount, + outcome_msg: outcomeMsg, + ...rest, + }; +}; export const rewriteRule = ({ alertTypeId, @@ -24,6 +33,8 @@ export const rewriteRule = ({ snoozeSchedule, isSnoozedUntil, activeSnoozes, + lastRun, + nextRun, ...rest }: SanitizedRule & { activeSnoozes?: string[] }) => ({ ...rest, @@ -51,4 +62,6 @@ export const rewriteRule = ({ params, connector_type_id: actionTypeId, })), + ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), + ...(nextRun ? { next_run: nextRun } : {}), }); diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.ts index b3576c0c5ed44..48d2253a03fc9 100644 --- a/x-pack/plugins/alerting/server/routes/resolve_rule.ts +++ b/x-pack/plugins/alerting/server/routes/resolve_rule.ts @@ -9,7 +9,7 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; import { ILicenseState } from '../lib'; -import { verifyAccessAndContext, RewriteResponseCase } from './lib'; +import { verifyAccessAndContext, RewriteResponseCase, rewriteRuleLastRun } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, @@ -34,6 +34,8 @@ const rewriteBodyRes: RewriteResponseCase> executionStatus, actions, scheduledTaskId, + lastRun, + nextRun, ...rest }) => ({ ...rest, @@ -58,6 +60,8 @@ const rewriteBodyRes: RewriteResponseCase> params, connector_type_id: actionTypeId, })), + ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), + ...(nextRun ? { next_run: nextRun } : {}), }); export const resolveRuleRoute = ( diff --git a/x-pack/plugins/alerting/server/routes/update_rule.ts b/x-pack/plugins/alerting/server/routes/update_rule.ts index 1faddd66c8f0e..2b7f6b3c98b39 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.ts @@ -15,6 +15,7 @@ import { RewriteResponseCase, RewriteRequestCase, handleDisabledApiKeysError, + rewriteRuleLastRun, } from './lib'; import { RuleTypeParams, @@ -72,6 +73,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ executionStatus, snoozeSchedule, isSnoozedUntil, + lastRun, + nextRun, ...rest }) => ({ ...rest, @@ -106,6 +109,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ })), } : {}), + ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), + ...(nextRun ? { next_run: nextRun } : {}), }); export const updateRuleRoute = ( diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 0d1a03ff6f3ed..67a347f5d45c1 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -65,6 +65,7 @@ import { RuleTaskState, AlertSummary, RuleExecutionStatusValues, + RuleLastRunOutcomeValues, RuleNotifyWhenType, RuleTypeParams, ResolvedSanitizedRule, @@ -83,6 +84,10 @@ import { convertRuleIdsToKueryNode, getRuleSnoozeEndTime, convertEsSortToEventLogSort, + getDefaultMonitoring, + updateMonitoring, + convertMonitoringFromRawAndVerify, + getNextRun, } from '../lib'; import { taskInstanceToAlertTaskInstance } from '../task_runner/alert_task_instance'; import { RegistryRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; @@ -113,7 +118,6 @@ import { getRuleExecutionStatusPending } from '../lib/rule_execution_status'; import { Alert } from '../alert'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; -import { getDefaultRuleMonitoring } from '../task_runner/task_runner'; import { getMappedParams, getModifiedField, @@ -153,6 +157,12 @@ export interface RuleAggregation { doc_count: number; }>; }; + outcome: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; muted: { buckets: Array<{ key: number; @@ -335,6 +345,7 @@ interface IndexType { export interface AggregateResult { alertExecutionStatus: { [status: string]: number }; + ruleLastRunOutcome: { [status: string]: number }; ruleEnabledStatus?: { enabled: number; disabled: number }; ruleMutedStatus?: { muted: number; unmuted: number }; ruleSnoozedStatus?: { snoozed: number }; @@ -364,6 +375,8 @@ export interface CreateOptions { | 'executionStatus' | 'snoozeSchedule' | 'isSnoozedUntil' + | 'lastRun' + | 'nextRun' > & { actions: NormalizedAlertAction[] }; options?: { id?: string; @@ -584,6 +597,7 @@ export class RulesClient { } = await this.extractReferences(ruleType, data.actions, validatedAlertTypeParams); const createTime = Date.now(); + const lastRunTimestamp = new Date(); const legacyId = Semver.lt(this.kibanaVersion, '8.0.0') ? id : null; const notifyWhen = getRuleNotifyWhenType(data.notifyWhen, data.throttle); @@ -601,8 +615,8 @@ export class RulesClient { muteAll: false, mutedInstanceIds: [], notifyWhen, - executionStatus: getRuleExecutionStatusPending(new Date().toISOString()), - monitoring: getDefaultRuleMonitoring(), + executionStatus: getRuleExecutionStatusPending(lastRunTimestamp.toISOString()), + monitoring: getDefaultMonitoring(lastRunTimestamp.toISOString()), }; const mappedParams = getMappedParams(updatedParams); @@ -1433,6 +1447,9 @@ export class RulesClient { status: { terms: { field: 'alert.attributes.executionStatus.status' }, }, + outcome: { + terms: { field: 'alert.attributes.lastRun.outcome' }, + }, enabled: { terms: { field: 'alert.attributes.enabled' }, }, @@ -1463,6 +1480,7 @@ export class RulesClient { // Return a placeholder with all zeroes const placeholder: AggregateResult = { alertExecutionStatus: {}, + ruleLastRunOutcome: {}, ruleEnabledStatus: { enabled: 0, disabled: 0, @@ -1487,11 +1505,21 @@ export class RulesClient { }) ); + const ruleLastRunOutcome = resp.aggregations.outcome.buckets.map( + ({ key, doc_count: docCount }) => ({ + [key]: docCount, + }) + ); + const ret: AggregateResult = { alertExecutionStatus: alertExecutionStatus.reduce( (acc, curr: { [status: string]: number }) => Object.assign(acc, curr), {} ), + ruleLastRunOutcome: ruleLastRunOutcome.reduce( + (acc, curr: { [status: string]: number }) => Object.assign(acc, curr), + {} + ), }; // Fill missing keys with zeroes @@ -1500,6 +1528,11 @@ export class RulesClient { ret.alertExecutionStatus[key] = 0; } } + for (const key of RuleLastRunOutcomeValues) { + if (!ret.ruleLastRunOutcome.hasOwnProperty(key)) { + ret.ruleLastRunOutcome[key] = 0; + } + } const enabledBuckets = resp.aggregations.enabled.buckets; ret.ruleEnabledStatus = { @@ -2606,17 +2639,28 @@ export class RulesClient { if (attributes.enabled === false) { const username = await this.getUserName(); + const now = new Date(); + + const schedule = attributes.schedule as IntervalSchedule; const updateAttributes = this.updateMeta({ ...attributes, ...(!existingApiKey && (await this.createNewAPIKeySet({ attributes, username }))), + ...(attributes.monitoring && { + monitoring: updateMonitoring({ + monitoring: attributes.monitoring, + timestamp: now.toISOString(), + duration: 0, + }), + }), + nextRun: getNextRun({ interval: schedule.interval }), enabled: true, updatedBy: username, - updatedAt: new Date().toISOString(), + updatedAt: now.toISOString(), executionStatus: { status: 'pending', lastDuration: 0, - lastExecutionDate: new Date().toISOString(), + lastExecutionDate: now.toISOString(), error: null, warning: null, }, @@ -2793,6 +2837,7 @@ export class RulesClient { scheduledTaskId: attributes.scheduledTaskId === id ? attributes.scheduledTaskId : null, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), + nextRun: null, }), { version } ); @@ -3442,6 +3487,8 @@ export class RulesClient { scheduledTaskId, params, executionStatus, + monitoring, + nextRun, schedule, actions, snoozeSchedule, @@ -3468,6 +3515,7 @@ export class RulesClient { snoozeSchedule, }) : null; + const includeMonitoring = monitoring && !excludeFromPublicApi; const rule = { id, notifyWhen, @@ -3493,6 +3541,10 @@ export class RulesClient { ...(executionStatus ? { executionStatus: ruleExecutionStatusFromRaw(this.logger, id, executionStatus) } : {}), + ...(includeMonitoring + ? { monitoring: convertMonitoringFromRawAndVerify(this.logger, id, monitoring) } + : {}), + ...(nextRun ? { nextRun: new Date(nextRun) } : {}), }; return includeLegacyId diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index 2826ba0284b90..494af8f668bfb 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -90,6 +90,13 @@ describe('aggregate()', () => { { key: 'warning', doc_count: 1 }, ], }, + outcome: { + buckets: [ + { key: 'succeeded', doc_count: 2 }, + { key: 'failed', doc_count: 4 }, + { key: 'warning', doc_count: 6 }, + ], + }, enabled: { buckets: [ { key: 0, key_as_string: '0', doc_count: 2 }, @@ -165,6 +172,11 @@ describe('aggregate()', () => { "disabled": 2, "enabled": 28, }, + "ruleLastRunOutcome": Object { + "failed": 4, + "succeeded": 2, + "warning": 6, + }, "ruleMutedStatus": Object { "muted": 3, "unmuted": 27, @@ -191,6 +203,9 @@ describe('aggregate()', () => { status: { terms: { field: 'alert.attributes.executionStatus.status' }, }, + outcome: { + terms: { field: 'alert.attributes.lastRun.outcome' }, + }, enabled: { terms: { field: 'alert.attributes.enabled' }, }, @@ -246,6 +261,9 @@ describe('aggregate()', () => { status: { terms: { field: 'alert.attributes.executionStatus.status' }, }, + outcome: { + terms: { field: 'alert.attributes.lastRun.outcome' }, + }, enabled: { terms: { field: 'alert.attributes.enabled' }, }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index f5192bf6cbe65..aad483f09fe9e 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -19,7 +19,7 @@ import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; -import { getDefaultRuleMonitoring } from '../../task_runner/task_runner'; +import { getDefaultMonitoring } from '../../lib/monitoring'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ @@ -421,11 +421,21 @@ describe('create()', () => { "versionApiKeyLastmodified": "v8.0.0", }, "monitoring": Object { - "execution": Object { + "run": Object { "calculated_metrics": Object { "success_ratio": 0, }, "history": Array [], + "last_run": Object { + "metrics": Object { + "gap_duration_s": null, + "total_alerts_created": null, + "total_alerts_detected": null, + "total_indexing_duration_ms": null, + "total_search_duration_ms": null, + }, + "timestamp": "2019-02-12T21:01:22.479Z", + }, }, }, "muteAll": false, @@ -628,11 +638,21 @@ describe('create()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "monitoring": Object { - "execution": Object { + "run": Object { "calculated_metrics": Object { "success_ratio": 0, }, "history": Array [], + "last_run": Object { + "metrics": Object { + "gap_duration_s": null, + "total_alerts_created": null, + "total_alerts_detected": null, + "total_indexing_duration_ms": null, + "total_search_duration_ms": null, + }, + "timestamp": "2019-02-12T21:01:22.479Z", + }, }, }, "muteAll": false, @@ -1056,7 +1076,7 @@ describe('create()', () => { status: 'pending', warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, snoozeSchedule: [], @@ -1255,7 +1275,7 @@ describe('create()', () => { status: 'pending', warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, snoozeSchedule: [], @@ -1423,7 +1443,7 @@ describe('create()', () => { status: 'pending', warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, snoozeSchedule: [], @@ -1601,7 +1621,7 @@ describe('create()', () => { error: null, warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), }, { id: 'mock-saved-object-id', @@ -1733,7 +1753,7 @@ describe('create()', () => { error: null, warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), }, { id: 'mock-saved-object-id', @@ -1865,7 +1885,7 @@ describe('create()', () => { error: null, warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), }, { id: 'mock-saved-object-id', @@ -2013,11 +2033,21 @@ describe('create()', () => { warning: null, }, monitoring: { - execution: { + run: { history: [], calculated_metrics: { success_ratio: 0, }, + last_run: { + timestamp: '2019-02-12T21:01:22.479Z', + metrics: { + gap_duration_s: null, + total_alerts_created: null, + total_alerts_detected: null, + total_indexing_duration_ms: null, + total_search_duration_ms: null, + }, + }, }, }, mapped_params: { @@ -2375,7 +2405,7 @@ describe('create()', () => { error: null, warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), }, { id: 'mock-saved-object-id', @@ -2477,7 +2507,7 @@ describe('create()', () => { error: null, warning: null, }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), }, { id: 'mock-saved-object-id', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 558d33ecca87c..5af5ec3e60bd1 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -224,6 +224,7 @@ describe('disable()', () => { }, }, ], + nextRun: null, }, { version: '123', @@ -294,6 +295,7 @@ describe('disable()', () => { }, }, ], + nextRun: null, }, { version: '123', @@ -375,6 +377,7 @@ describe('disable()', () => { }, }, ], + nextRun: null, }, { version: '123', @@ -418,6 +421,7 @@ describe('disable()', () => { }, }, ], + nextRun: null, }, { version: '123', @@ -509,6 +513,7 @@ describe('disable()', () => { }, }, ], + nextRun: null, }, { version: '123', @@ -556,6 +561,7 @@ describe('disable()', () => { }, }, ], + nextRun: null, }, { version: '123', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index 1c96241b15800..9cb6c356edaed 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -234,6 +234,7 @@ describe('enable()', () => { error: null, warning: null, }, + nextRun: '2019-02-12T21:01:32.479Z', }, { version: '123', @@ -290,6 +291,7 @@ describe('enable()', () => { error: null, warning: null, }, + nextRun: '2019-02-12T21:01:32.479Z', }, { version: '123', @@ -356,6 +358,7 @@ describe('enable()', () => { error: null, warning: null, }, + nextRun: '2019-02-12T21:01:32.479Z', }, { version: '123', diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 31f6f96d67c8b..76fc730409294 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -36,6 +36,8 @@ export const AlertAttributesExcludedFromAAD = [ 'snoozeEndTime', // field removed in 8.2, but must be retained in case an rule created/updated in 8.2 is being migrated 'snoozeSchedule', 'isSnoozedUntil', + 'lastRun', + 'nextRun', ]; // useful for Pick which is a @@ -52,7 +54,9 @@ export type AlertAttributesExcludedFromAADType = | 'monitoring' | 'snoozeEndTime' | 'snoozeSchedule' - | 'isSnoozedUntil'; + | 'isSnoozedUntil' + | 'lastRun' + | 'nextRun'; export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.ts b/x-pack/plugins/alerting/server/saved_objects/mappings.ts index f33dbdd788534..727e70e26055e 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.ts +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.ts @@ -114,7 +114,7 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = { }, monitoring: { properties: { - execution: { + run: { properties: { history: { properties: { @@ -127,6 +127,9 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = { timestamp: { type: 'date', }, + outcome: { + type: 'keyword', + }, }, }, calculated_metrics: { @@ -145,41 +148,34 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = { }, }, }, - }, - }, - }, - }, - executionStatus: { - properties: { - numberOfTriggeredActions: { - type: 'long', - }, - status: { - type: 'keyword', - }, - lastExecutionDate: { - type: 'date', - }, - lastDuration: { - type: 'long', - }, - error: { - properties: { - reason: { - type: 'keyword', - }, - message: { - type: 'keyword', - }, - }, - }, - warning: { - properties: { - reason: { - type: 'keyword', - }, - message: { - type: 'keyword', + last_run: { + properties: { + timestamp: { + type: 'date', + }, + metrics: { + properties: { + duration: { + type: 'long', + }, + total_search_duration_ms: { + type: 'long', + }, + total_indexing_duration_ms: { + type: 'long', + }, + total_alerts_detected: { + type: 'float', + }, + total_alerts_created: { + type: 'float', + }, + gap_duration_s: { + type: 'float', + }, + }, + }, + }, }, }, }, @@ -255,5 +251,77 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = { }, }, }, + nextRun: { + type: 'date', + }, + // Deprecated, if you need to add new property please do it in `last_run` + executionStatus: { + properties: { + numberOfTriggeredActions: { + type: 'long', + }, + status: { + type: 'keyword', + }, + lastExecutionDate: { + type: 'date', + }, + lastDuration: { + type: 'long', + }, + error: { + properties: { + reason: { + type: 'keyword', + }, + message: { + type: 'keyword', + }, + }, + }, + warning: { + properties: { + reason: { + type: 'keyword', + }, + message: { + type: 'keyword', + }, + }, + }, + }, + }, + lastRun: { + properties: { + outcome: { + type: 'keyword', + }, + outcomeOrder: { + type: 'float', + }, + warning: { + type: 'text', + }, + outcomeMsg: { + type: 'text', + }, + alertsCount: { + properties: { + active: { + type: 'float', + }, + new: { + type: 'float', + }, + recovered: { + type: 'float', + }, + ignored: { + type: 'float', + }, + }, + }, + }, + }, }, }; diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/8.6/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/8.6/index.ts new file mode 100644 index 0000000000000..d4535008bfbe6 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/8.6/index.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectUnsanitizedDoc } from '@kbn/core-saved-objects-server'; +import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; +import { createEsoMigration, pipeMigrations } from '../utils'; +import { RawRule, RuleLastRunOutcomeValues } from '../../../types'; +import { getDefaultMonitoring } from '../../../lib/monitoring'; + +const succeededStatus = ['ok', 'active', 'succeeded']; +const warningStatus = ['warning']; +const failedStatus = ['error', 'failed']; + +const getLastRun = (attributes: RawRule) => { + const { executionStatus } = attributes; + const { status, warning, error } = executionStatus || {}; + + let outcome; + if (succeededStatus.includes(status)) { + outcome = RuleLastRunOutcomeValues[0]; + } else if (warningStatus.includes(status) || warning) { + outcome = RuleLastRunOutcomeValues[1]; + } else if (failedStatus.includes(status) || error) { + outcome = RuleLastRunOutcomeValues[2]; + } + + // Don't set last run if status is unknown or pending, let the + // task runner do it instead + if (!outcome) { + return null; + } + + return { + outcome, + outcomeMsg: warning?.message || error?.message || null, + warning: warning?.reason || error?.reason || null, + alertsCount: {}, + }; +}; + +const getMonitoring = (attributes: RawRule) => { + const { executionStatus, monitoring } = attributes; + if (!monitoring) { + if (!executionStatus) { + return null; + } + + // monitoring now has data from executionStatus, therefore, we should migrate + // these fields even if monitoring doesn't exist. + const defaultMonitoring = getDefaultMonitoring(executionStatus.lastExecutionDate); + if (executionStatus.lastDuration) { + defaultMonitoring.run.last_run.metrics.duration = executionStatus.lastDuration; + } + return defaultMonitoring; + } + + const { lastExecutionDate, lastDuration } = executionStatus; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const monitoringExecution = (monitoring as any).execution; + + return { + run: { + ...monitoringExecution, + last_run: { + timestamp: lastExecutionDate, + metrics: { + ...(lastDuration ? { duration: lastDuration } : {}), + }, + }, + }, + }; +}; + +function migrateLastRun( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { attributes } = doc; + const lastRun = getLastRun(attributes); + const monitoring = getMonitoring(attributes); + + return { + ...doc, + attributes: { + ...attributes, + ...(lastRun ? { lastRun } : {}), + ...(monitoring ? { monitoring } : {}), + }, + }; +} + +export const getMigrations860 = (encryptedSavedObjects: EncryptedSavedObjectsPluginSetup) => + createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, + pipeMigrations(migrateLastRun) + ); diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/index.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/index.test.ts index 09f466a8e9a37..f7f31d1d06786 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/index.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/index.test.ts @@ -2150,339 +2150,416 @@ describe('successful migrations', () => { expect(migratedAlert.attributes.params.outputIndex).toEqual(''); } ); + }); - describe('8.0.1', () => { - describe.each(Object.keys(ruleTypeMappings) as RuleType[])( - 'auto_disabled %p rule tags', - (ruleType) => { - const alert717Enabled = getMockData( - { - params: { outputIndex: 'output-index', type: ruleType }, - alertTypeId: 'siem.signals', - enabled: true, - scheduledTaskId: 'abcd', - }, - true - ); - const alert717Disabled = getMockData( - { - params: { outputIndex: 'output-index', type: ruleType }, - alertTypeId: 'siem.signals', - enabled: false, - }, - true - ); - const alert800 = getMockData( - { - params: { outputIndex: '', type: ruleType }, - alertTypeId: ruleTypeMappings[ruleType], - enabled: false, - scheduledTaskId: 'abcd', - }, - true - ); + describe('8.0.1', () => { + describe.each(Object.keys(ruleTypeMappings) as RuleType[])( + 'auto_disabled %p rule tags', + (ruleType) => { + const alert717Enabled = getMockData( + { + params: { outputIndex: 'output-index', type: ruleType }, + alertTypeId: 'siem.signals', + enabled: true, + scheduledTaskId: 'abcd', + }, + true + ); + const alert717Disabled = getMockData( + { + params: { outputIndex: 'output-index', type: ruleType }, + alertTypeId: 'siem.signals', + enabled: false, + }, + true + ); + const alert800 = getMockData( + { + params: { outputIndex: '', type: ruleType }, + alertTypeId: ruleTypeMappings[ruleType], + enabled: false, + scheduledTaskId: 'abcd', + }, + true + ); - test('Does not update rule tags if rule has already been enabled', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); - const migration800 = migrations['8.0.0']; - const migration801 = migrations['8.0.1']; + test('Does not update rule tags if rule has already been enabled', () => { + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); + const migration800 = migrations['8.0.0']; + const migration801 = migrations['8.0.1']; - // migrate to 8.0.0 - const migratedAlert800 = migration800(alert717Enabled, migrationContext); - expect(migratedAlert800.attributes.enabled).toEqual(false); + // migrate to 8.0.0 + const migratedAlert800 = migration800(alert717Enabled, migrationContext); + expect(migratedAlert800.attributes.enabled).toEqual(false); - // reenable rule - migratedAlert800.attributes.enabled = true; + // reenable rule + migratedAlert800.attributes.enabled = true; - // migrate to 8.0.1 - const migratedAlert801 = migration801(migratedAlert800, migrationContext); + // migrate to 8.0.1 + const migratedAlert801 = migration801(migratedAlert800, migrationContext); - expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); - expect(migratedAlert801.attributes.enabled).toEqual(true); - expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); + expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); + expect(migratedAlert801.attributes.enabled).toEqual(true); + expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); - // tags not updated - expect(migratedAlert801.attributes.tags).toEqual(['foo']); - }); + // tags not updated + expect(migratedAlert801.attributes.tags).toEqual(['foo']); + }); - test('Does not update rule tags if rule was already disabled before upgrading to 8.0', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); - const migration800 = migrations['8.0.0']; - const migration801 = migrations['8.0.1']; + test('Does not update rule tags if rule was already disabled before upgrading to 8.0', () => { + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); + const migration800 = migrations['8.0.0']; + const migration801 = migrations['8.0.1']; - // migrate to 8.0.0 - const migratedAlert800 = migration800(alert717Disabled, migrationContext); - expect(migratedAlert800.attributes.enabled).toEqual(false); + // migrate to 8.0.0 + const migratedAlert800 = migration800(alert717Disabled, migrationContext); + expect(migratedAlert800.attributes.enabled).toEqual(false); - // migrate to 8.0.1 - const migratedAlert801 = migration801(migratedAlert800, migrationContext); + // migrate to 8.0.1 + const migratedAlert801 = migration801(migratedAlert800, migrationContext); - expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); - expect(migratedAlert801.attributes.enabled).toEqual(false); - expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); + expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); + expect(migratedAlert801.attributes.enabled).toEqual(false); + expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); - // tags not updated - expect(migratedAlert801.attributes.tags).toEqual(['foo']); - }); + // tags not updated + expect(migratedAlert801.attributes.tags).toEqual(['foo']); + }); - test('Updates rule tags if rule was auto-disabled in 8.0 upgrade and not reenabled', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); - const migration800 = migrations['8.0.0']; - const migration801 = migrations['8.0.1']; + test('Updates rule tags if rule was auto-disabled in 8.0 upgrade and not reenabled', () => { + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); + const migration800 = migrations['8.0.0']; + const migration801 = migrations['8.0.1']; - // migrate to 8.0.0 - const migratedAlert800 = migration800(alert717Enabled, migrationContext); - expect(migratedAlert800.attributes.enabled).toEqual(false); + // migrate to 8.0.0 + const migratedAlert800 = migration800(alert717Enabled, migrationContext); + expect(migratedAlert800.attributes.enabled).toEqual(false); - // migrate to 8.0.1 - const migratedAlert801 = migration801(migratedAlert800, migrationContext); + // migrate to 8.0.1 + const migratedAlert801 = migration801(migratedAlert800, migrationContext); - expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); - expect(migratedAlert801.attributes.enabled).toEqual(false); - expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); + expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); + expect(migratedAlert801.attributes.enabled).toEqual(false); + expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); - // tags updated - expect(migratedAlert801.attributes.tags).toEqual(['foo', 'auto_disabled_8.0']); - }); + // tags updated + expect(migratedAlert801.attributes.tags).toEqual(['foo', 'auto_disabled_8.0']); + }); - test('Updates rule tags correctly if tags are undefined', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); - const migration801 = migrations['8.0.1']; + test('Updates rule tags correctly if tags are undefined', () => { + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); + const migration801 = migrations['8.0.1']; - const alert = { - ...alert800, - attributes: { - ...alert800.attributes, - tags: undefined, - }, - }; + const alert = { + ...alert800, + attributes: { + ...alert800.attributes, + tags: undefined, + }, + }; - // migrate to 8.0.1 - const migratedAlert801 = migration801(alert, migrationContext); + // migrate to 8.0.1 + const migratedAlert801 = migration801(alert, migrationContext); - expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); - expect(migratedAlert801.attributes.enabled).toEqual(false); - expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); + expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); + expect(migratedAlert801.attributes.enabled).toEqual(false); + expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); - // tags updated - expect(migratedAlert801.attributes.tags).toEqual(['auto_disabled_8.0']); - }); + // tags updated + expect(migratedAlert801.attributes.tags).toEqual(['auto_disabled_8.0']); + }); - test('Updates rule tags correctly if tags are null', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); - const migration801 = migrations['8.0.1']; + test('Updates rule tags correctly if tags are null', () => { + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); + const migration801 = migrations['8.0.1']; - const alert = { - ...alert800, - attributes: { - ...alert800.attributes, - tags: null, - }, - }; + const alert = { + ...alert800, + attributes: { + ...alert800.attributes, + tags: null, + }, + }; - // migrate to 8.0.1 - const migratedAlert801 = migration801(alert, migrationContext); + // migrate to 8.0.1 + const migratedAlert801 = migration801(alert, migrationContext); - expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); - expect(migratedAlert801.attributes.enabled).toEqual(false); - expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); + expect(migratedAlert801.attributes.alertTypeId).toEqual(ruleTypeMappings[ruleType]); + expect(migratedAlert801.attributes.enabled).toEqual(false); + expect(migratedAlert801.attributes.params.outputIndex).toEqual(''); - // tags updated - expect(migratedAlert801.attributes.tags).toEqual(['auto_disabled_8.0']); - }); - } - ); - }); + // tags updated + expect(migratedAlert801.attributes.tags).toEqual(['auto_disabled_8.0']); + }); + } + ); + }); - describe('8.2.0', () => { - test('migrates params to mapped_params', () => { - const migration820 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.2.0' - ]; - const alert = getMockData( - { - params: { - risk_score: 60, - severity: 'high', - foo: 'bar', - }, - alertTypeId: 'siem.signals', + describe('8.2.0', () => { + test('migrates params to mapped_params', () => { + const migration820 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.2.0']; + const alert = getMockData( + { + params: { + risk_score: 60, + severity: 'high', + foo: 'bar', }, - true - ); + alertTypeId: 'siem.signals', + }, + true + ); - const migratedAlert820 = migration820(alert, migrationContext); + const migratedAlert820 = migration820(alert, migrationContext); - expect(migratedAlert820.attributes.mapped_params).toEqual({ - risk_score: 60, - severity: '60-high', - }); + expect(migratedAlert820.attributes.mapped_params).toEqual({ + risk_score: 60, + severity: '60-high', }); }); + }); - describe('8.3.0', () => { - test('migrates snoozed rules to the new data model', () => { - const fakeTimer = sinon.useFakeTimers(); - const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.3.0' - ]; - const mutedAlert = getMockData( - { - snoozeEndTime: '1970-01-02T00:00:00.000Z', - }, - true - ); - const migratedMutedAlert830 = migration830(mutedAlert, migrationContext); + describe('8.3.0', () => { + test('migrates snoozed rules to the new data model', () => { + const fakeTimer = sinon.useFakeTimers(); + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.3.0']; + const mutedAlert = getMockData( + { + snoozeEndTime: '1970-01-02T00:00:00.000Z', + }, + true + ); + const migratedMutedAlert830 = migration830(mutedAlert, migrationContext); - expect(migratedMutedAlert830.attributes.snoozeSchedule.length).toEqual(1); - expect(migratedMutedAlert830.attributes.snoozeSchedule[0].rRule.dtstart).toEqual( - '1970-01-01T00:00:00.000Z' - ); - expect(migratedMutedAlert830.attributes.snoozeSchedule[0].duration).toEqual(86400000); - fakeTimer.restore(); - }); + expect(migratedMutedAlert830.attributes.snoozeSchedule.length).toEqual(1); + expect(migratedMutedAlert830.attributes.snoozeSchedule[0].rRule.dtstart).toEqual( + '1970-01-01T00:00:00.000Z' + ); + expect(migratedMutedAlert830.attributes.snoozeSchedule[0].duration).toEqual(86400000); + fakeTimer.restore(); + }); - test('migrates es_query alert params', () => { - const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.3.0' - ]; - const alert = getMockData( - { - params: { esQuery: '{ "query": "test-query" }' }, - alertTypeId: '.es-query', - }, - true - ); - const migratedAlert820 = migration830(alert, migrationContext); + test('migrates es_query alert params', () => { + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.3.0']; + const alert = getMockData( + { + params: { esQuery: '{ "query": "test-query" }' }, + alertTypeId: '.es-query', + }, + true + ); + const migratedAlert820 = migration830(alert, migrationContext); - expect(migratedAlert820.attributes.params).toEqual({ - esQuery: '{ "query": "test-query" }', - searchType: 'esQuery', - }); + expect(migratedAlert820.attributes.params).toEqual({ + esQuery: '{ "query": "test-query" }', + searchType: 'esQuery', }); + }); - test('removes internal tags', () => { - const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.3.0' - ]; - const alert = getMockData( - { - tags: [ - '__internal_immutable:false', - '__internal_rule_id:064e3fed-6328-416b-bb85-c08265088f41', - 'test-tag', - ], - alertTypeId: 'siem.queryRule', - }, - true - ); + test('removes internal tags', () => { + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.3.0']; + const alert = getMockData( + { + tags: [ + '__internal_immutable:false', + '__internal_rule_id:064e3fed-6328-416b-bb85-c08265088f41', + 'test-tag', + ], + alertTypeId: 'siem.queryRule', + }, + true + ); - const migratedAlert830 = migration830(alert, migrationContext); + const migratedAlert830 = migration830(alert, migrationContext); - expect(migratedAlert830.attributes.tags).toEqual(['test-tag']); - }); + expect(migratedAlert830.attributes.tags).toEqual(['test-tag']); + }); - test('do not remove internal tags if rule is not Security solution rule', () => { - const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.3.0' - ]; - const alert = getMockData( - { - tags: ['__internal_immutable:false', 'tag-1'], - }, - true - ); + test('do not remove internal tags if rule is not Security solution rule', () => { + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.3.0']; + const alert = getMockData( + { + tags: ['__internal_immutable:false', 'tag-1'], + }, + true + ); - const migratedAlert830 = migration830(alert, migrationContext); + const migratedAlert830 = migration830(alert, migrationContext); - expect(migratedAlert830.attributes.tags).toEqual(['__internal_immutable:false', 'tag-1']); - }); + expect(migratedAlert830.attributes.tags).toEqual(['__internal_immutable:false', 'tag-1']); }); + }); - describe('8.4.1', () => { - test('removes isSnoozedUntil', () => { - const migration841 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.4.1' - ]; - const mutedAlert = getMockData( - { - isSnoozedUntil: '1970-01-02T00:00:00.000Z', - }, - true - ); - expect(mutedAlert.attributes.isSnoozedUntil).toBeTruthy(); - const migratedAlert841 = migration841(mutedAlert, migrationContext); + describe('8.4.1', () => { + test('removes isSnoozedUntil', () => { + const migration841 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.4.1']; + const mutedAlert = getMockData( + { + isSnoozedUntil: '1970-01-02T00:00:00.000Z', + }, + true + ); + expect(mutedAlert.attributes.isSnoozedUntil).toBeTruthy(); + const migratedAlert841 = migration841(mutedAlert, migrationContext); - expect(migratedAlert841.attributes.isSnoozedUntil).toBeFalsy(); + expect(migratedAlert841.attributes.isSnoozedUntil).toBeFalsy(); + }); + + test('works as expected if isSnoozedUntil is not populated', () => { + const migration841 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.4.1']; + const mutedAlert = getMockData({}, true); + expect(mutedAlert.attributes.isSnoozedUntil).toBeFalsy(); + expect(() => migration841(mutedAlert, migrationContext)).not.toThrowError(); + }); + }); + + describe('8.6.0', () => { + test('migrates executionStatus success', () => { + const migration860 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.6.0']; + + let ruleWithExecutionStatus = getMockData({ + executionStatus: { + status: 'ok', + lastExecutionDate: '2022-01-02T00:00:00.000Z', + lastDuration: 60000, + }, }); - test('works as expected if isSnoozedUntil is not populated', () => { - const migration841 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.4.1' - ]; - const mutedAlert = getMockData({}, true); - expect(mutedAlert.attributes.isSnoozedUntil).toBeFalsy(); - expect(() => migration841(mutedAlert, migrationContext)).not.toThrowError(); + let migratedRule = migration860(ruleWithExecutionStatus, migrationContext); + expect(migratedRule.attributes.lastRun.outcome).toEqual('succeeded'); + expect(migratedRule.attributes.lastRun.warning).toEqual(null); + ruleWithExecutionStatus = getMockData({ + executionStatus: { + status: 'active', + lastExecutionDate: '2022-01-02T00:00:00.000Z', + lastDuration: 60000, + }, }); + + migratedRule = migration860(ruleWithExecutionStatus, migrationContext); + expect(migratedRule.attributes.lastRun.outcome).toEqual('succeeded'); + expect(migratedRule.attributes.lastRun.warning).toEqual(null); }); - describe('Metrics Inventory Threshold rule', () => { - test('Migrates incorrect action group spelling', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.0.0' - ]; + test('migrates executionStatus warning and error', () => { + const migration860 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.6.0']; - const actions = [ - { - group: 'metrics.invenotry_threshold.fired', - params: { - level: 'info', - message: - '""{{alertName}} - {{context.group}} is in a state of {{context.alertState}} Reason: {{context.reason}}""', - }, - actionRef: 'action_0', - actionTypeId: '.server-log', + let ruleWithExecutionStatus = getMockData({ + executionStatus: { + status: 'warning', + lastExecutionDate: '2022-01-02T00:00:00.000Z', + lastDuration: 60000, + warning: { + reason: 'warning reason', + message: 'warning message', }, - ]; + }, + }); - const alert = getMockData({ alertTypeId: 'metrics.alert.inventory.threshold', actions }); + let migratedRule = migration860(ruleWithExecutionStatus, migrationContext); + expect(migratedRule.attributes.lastRun.outcome).toEqual('warning'); + expect(migratedRule.attributes.lastRun.outcomeMsg).toEqual('warning message'); + expect(migratedRule.attributes.lastRun.warning).toEqual('warning reason'); - expect(migration800(alert, migrationContext)).toMatchObject({ - ...alert, - attributes: { - ...alert.attributes, - actions: [{ ...actions[0], group: 'metrics.inventory_threshold.fired' }], + ruleWithExecutionStatus = getMockData({ + executionStatus: { + status: 'error', + lastExecutionDate: '2022-01-02T00:00:00.000Z', + lastDuration: 60000, + error: { + reason: 'failed reason', + message: 'failed message', }, - }); + }, }); - test('Works with the correct action group spelling', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ - '8.0.0' - ]; + migratedRule = migration860(ruleWithExecutionStatus, migrationContext); + expect(migratedRule.attributes.lastRun.outcome).toEqual('failed'); + expect(migratedRule.attributes.lastRun.outcomeMsg).toEqual('failed message'); + expect(migratedRule.attributes.lastRun.warning).toEqual('failed reason'); + }); - const actions = [ - { - group: 'metrics.inventory_threshold.fired', - params: { - level: 'info', - message: - '""{{alertName}} - {{context.group}} is in a state of {{context.alertState}} Reason: {{context.reason}}""', - }, - actionRef: 'action_0', - actionTypeId: '.server-log', + test('migrates empty monitoring', () => { + const migration860 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.6.0']; + + const ruleWithoutMonitoring = getMockData(); + const migratedRule = migration860(ruleWithoutMonitoring, migrationContext); + + expect(migratedRule.attributes.monitoring).toBeUndefined(); + }); + + test('migrates empty monitoring when executionStatus exists', () => { + const migration860 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.6.0']; + + const ruleWithMonitoring = getMockData({ + executionStatus: { + status: 'ok', + lastExecutionDate: '2022-01-02T00:00:00.000Z', + lastDuration: 60000, + }, + }); + const migratedRule = migration860(ruleWithMonitoring, migrationContext); + + expect(migratedRule.attributes.monitoring.run.history).toEqual([]); + expect(migratedRule.attributes.monitoring.run.last_run.timestamp).toEqual( + '2022-01-02T00:00:00.000Z' + ); + expect(migratedRule.attributes.monitoring.run.last_run.metrics.duration).toEqual(60000); + }); + }); + + describe('Metrics Inventory Threshold rule', () => { + test('Migrates incorrect action group spelling', () => { + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; + + const actions = [ + { + group: 'metrics.invenotry_threshold.fired', + params: { + level: 'info', + message: + '""{{alertName}} - {{context.group}} is in a state of {{context.alertState}} Reason: {{context.reason}}""', }, - ]; + actionRef: 'action_0', + actionTypeId: '.server-log', + }, + ]; + + const alert = getMockData({ alertTypeId: 'metrics.alert.inventory.threshold', actions }); + + expect(migration800(alert, migrationContext)).toMatchObject({ + ...alert, + attributes: { + ...alert.attributes, + actions: [{ ...actions[0], group: 'metrics.inventory_threshold.fired' }], + }, + }); + }); - const alert = getMockData({ alertTypeId: 'metrics.alert.inventory.threshold', actions }); + test('Works with the correct action group spelling', () => { + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; - expect(migration800(alert, migrationContext)).toMatchObject({ - ...alert, - attributes: { - ...alert.attributes, - actions: [{ ...actions[0], group: 'metrics.inventory_threshold.fired' }], + const actions = [ + { + group: 'metrics.inventory_threshold.fired', + params: { + level: 'info', + message: + '""{{alertName}} - {{context.group}} is in a state of {{context.alertState}} Reason: {{context.reason}}""', }, - }); + actionRef: 'action_0', + actionTypeId: '.server-log', + }, + ]; + + const alert = getMockData({ alertTypeId: 'metrics.alert.inventory.threshold', actions }); + + expect(migration800(alert, migrationContext)).toMatchObject({ + ...alert, + attributes: { + ...alert.attributes, + actions: [{ ...actions[0], group: 'metrics.inventory_threshold.fired' }], + }, }); }); }); diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts index a3a06614ec4c5..b44e6e4c09c6e 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts @@ -28,6 +28,7 @@ import { getMigrations820 } from './8.2'; import { getMigrations830 } from './8.3'; import { getMigrations841 } from './8.4'; import { getMigrations850 } from './8.5'; +import { getMigrations860 } from './8.6'; import { AlertLogMeta, AlertMigration } from './types'; import { MINIMUM_SS_MIGRATION_VERSION } from './constants'; import { createEsoMigration, isEsQueryRuleType, pipeMigrations } from './utils'; @@ -75,6 +76,7 @@ export function getMigrations( '8.3.0': executeMigrationWithErrorHandling(getMigrations830(encryptedSavedObjects), '8.3.0'), '8.4.1': executeMigrationWithErrorHandling(getMigrations841(encryptedSavedObjects), '8.4.1'), '8.5.0': executeMigrationWithErrorHandling(getMigrations850(encryptedSavedObjects), '8.5.0'), + '8.6.0': executeMigrationWithErrorHandling(getMigrations860(encryptedSavedObjects), '8.6.0'), }, getSearchSourceMigrations(encryptedSavedObjects, searchSourceMigrations) ); diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index ca9ddd0c48db6..b0e98263591a9 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -6,8 +6,8 @@ */ import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { Rule, RuleTypeParams, RecoveredActionGroup } from '../../common'; -import { getDefaultRuleMonitoring } from './task_runner'; +import { Rule, RuleTypeParams, RecoveredActionGroup, RuleMonitoring } from '../../common'; +import { getDefaultMonitoring } from '../lib/monitoring'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -54,29 +54,51 @@ export const RULE_ACTIONS = [ }, ]; +const defaultHistory = [ + { + success: true, + timestamp: 0, + }, +]; + export const generateSavedObjectParams = ({ error = null, warning = null, status = 'ok', + outcome = 'succeeded', + nextRun = '1970-01-01T00:00:10.000Z', + successRatio = 1, + history = defaultHistory, + alertsCount, }: { error?: null | { reason: string; message: string }; warning?: null | { reason: string; message: string }; status?: string; + outcome?: string; + nextRun?: string | null; + successRatio?: number; + history?: RuleMonitoring['run']['history']; + alertsCount?: Record; }) => [ 'alert', '1', { monitoring: { - execution: { + run: { calculated_metrics: { - success_ratio: 1, + success_ratio: successRatio, }, - history: [ - { - success: true, - timestamp: 0, + history, + last_run: { + timestamp: '1970-01-01T00:00:00.000Z', + metrics: { + gap_duration_s: null, + total_alerts_created: null, + total_alerts_detected: null, + total_indexing_duration_ms: null, + total_search_duration_ms: null, }, - ], + }, }, }, executionStatus: { @@ -86,6 +108,19 @@ export const generateSavedObjectParams = ({ status, warning, }, + lastRun: { + outcome, + outcomeMsg: error?.message || warning?.message || null, + warning: error?.reason || warning?.reason || null, + alertsCount: { + active: 0, + ignored: 0, + new: 0, + recovered: 0, + ...(alertsCount || {}), + }, + }, + nextRun, }, { refresh: false, namespace: undefined }, ]; @@ -155,7 +190,7 @@ export const mockedRuleTypeSavedObject: Rule = { status: 'unknown', lastExecutionDate: new Date('2020-08-20T19:23:38Z'), }, - monitoring: getDefaultRuleMonitoring(), + monitoring: getDefaultMonitoring('2020-08-20T19:23:38Z'), }; export const mockTaskInstance = () => ({ @@ -218,12 +253,22 @@ export const generateRunnerResult = ({ }: GeneratorParams = {}) => { return { monitoring: { - execution: { + run: { calculated_metrics: { success_ratio: successRatio, }, // @ts-ignore history: history.map((success) => ({ success, timestamp: 0 })), + last_run: { + metrics: { + gap_duration_s: null, + total_alerts_created: null, + total_alerts_detected: null, + total_indexing_duration_ms: null, + total_search_duration_ms: null, + }, + timestamp: '1970-01-01T00:00:00.000Z', + }, }, }, schedule: { diff --git a/x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts b/x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts index 96815743daaef..1cbe2bb2ae3b9 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts @@ -79,7 +79,7 @@ describe('rule_loader', () => { expect(result.rule.alertTypeId).toBe(ruleTypeId); expect(result.rule.name).toBe(ruleName); expect(result.rule.params).toBe(ruleParams); - expect(result.rule.monitoring?.execution.history.length).toBe(MONITORING_HISTORY_LIMIT - 1); + expect(result.rule.monitoring?.run.history.length).toBe(MONITORING_HISTORY_LIMIT - 1); }); test('without API key, any execution history, or validator', async () => { @@ -102,7 +102,7 @@ describe('rule_loader', () => { expect(result.rule.alertTypeId).toBe(ruleTypeId); expect(result.rule.name).toBe(ruleName); expect(result.rule.params).toBe(ruleParams); - expect(result.rule.monitoring?.execution.history.length).toBe(0); + expect(result.rule.monitoring?.run.history.length).toBe(0); }); }); @@ -348,7 +348,7 @@ function getTaskRunnerContext(ruleParameters: unknown, historyElements: number) alertTypeId: ruleTypeId, params: ruleParameters, monitoring: { - execution: { + run: { history: new Array(historyElements), }, }, diff --git a/x-pack/plugins/alerting/server/task_runner/rule_loader.ts b/x-pack/plugins/alerting/server/task_runner/rule_loader.ts index 75fcff9f4510f..8d4b00a54e094 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_loader.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_loader.ts @@ -79,9 +79,9 @@ export async function loadRule(params: LoadRulePa } if (rule.monitoring) { - if (rule.monitoring.execution.history.length >= MONITORING_HISTORY_LIMIT) { + if (rule.monitoring.run.history.length >= MONITORING_HISTORY_LIMIT) { // Remove the first (oldest) record - rule.monitoring.execution.history.shift(); + rule.monitoring.run.history.shift(); } } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 54573f1b7e2b2..69b648f099e44 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -197,8 +197,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams.executionContext.withContext.mockImplementation((ctx, fn) => fn() ); - mockedRuleTypeSavedObject.monitoring!.execution.history = []; - mockedRuleTypeSavedObject.monitoring!.execution.calculated_metrics.success_ratio = 0; + mockedRuleTypeSavedObject.monitoring!.run.history = []; + mockedRuleTypeSavedObject.monitoring!.run.calculated_metrics.success_ratio = 0; alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); @@ -252,14 +252,18 @@ describe('Task Runner', () => { expect(call.services.scopedClusterClient).toBeTruthy(); expect(call.services).toBeTruthy(); - expect(logger.debug).toHaveBeenCalledTimes(4); + expect(logger.debug).toHaveBeenCalledTimes(5); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' + 'deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' ); expect(logger.debug).nthCalledWith( 3, + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":0,"new":0,"recovered":0,"ignored":0}}' + ); + expect(logger.debug).nthCalledWith( + 4, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); @@ -327,7 +331,7 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith(generateEnqueueFunctionInput(inputIsArray)); - expect(logger.debug).toHaveBeenCalledTimes(5); + expect(logger.debug).toHaveBeenCalledTimes(6); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, @@ -335,10 +339,14 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 3, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); expect(logger.debug).nthCalledWith( 4, + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":1,"recovered":0,"ignored":0}}' + ); + expect(logger.debug).nthCalledWith( + 5, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); @@ -405,7 +413,7 @@ describe('Task Runner', () => { await taskRunner.run(); expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0); - expect(logger.debug).toHaveBeenCalledTimes(6); + expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, @@ -417,10 +425,14 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); expect(logger.debug).nthCalledWith( 5, + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":1,"recovered":0,"ignored":0}}' + ); + expect(logger.debug).nthCalledWith( + 6, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); @@ -579,7 +591,7 @@ describe('Task Runner', () => { await taskRunner.run(); expect(enqueueFunction).toHaveBeenCalledTimes(1); - expect(logger.debug).toHaveBeenCalledTimes(6); + expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, @@ -591,10 +603,14 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); expect(logger.debug).nthCalledWith( 5, + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":2,"new":2,"recovered":0,"ignored":0}}' + ); + expect(logger.debug).nthCalledWith( + 6, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":2,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":2,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); @@ -702,7 +718,7 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); expect(enqueueFunction).toHaveBeenCalledTimes(1); - expect(logger.debug).toHaveBeenCalledTimes(6); + expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith( 3, `skipping scheduling of actions for '2' in rule test:1: '${RULE_NAME}': rule is muted` @@ -1010,7 +1026,7 @@ describe('Task Runner', () => { generateAlertInstance({ id: 1, duration: MOCK_DURATION, start: DATE_1969 }) ); - expect(logger.debug).toHaveBeenCalledTimes(6); + expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, @@ -1022,10 +1038,14 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); expect(logger.debug).nthCalledWith( 5, + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":0,"recovered":1,"ignored":0}}' + ); + expect(logger.debug).nthCalledWith( + 6, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); @@ -1135,10 +1155,14 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - `ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` + `deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` ); expect(logger.debug).nthCalledWith( 5, + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":0,"recovered":1,"ignored":0}}' + ); + expect(logger.debug).nthCalledWith( + 6, `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}` ); @@ -1544,10 +1568,15 @@ describe('Task Runner', () => { return taskRunner.run().catch((ex) => { expect(ex.toString()).toEqual(`Error: Saved object [alert/1] not found`); - const executeRuleDebugLogger = logger.debug.mock.calls[3][0]; + const updateRuleDebugLogger = logger.debug.mock.calls[3][0]; + expect(updateRuleDebugLogger as string).toMatchInlineSnapshot( + `"Updating rule task for test rule with id 1 - {\\"lastExecutionDate\\":\\"1970-01-01T00:00:00.000Z\\",\\"status\\":\\"error\\",\\"error\\":{\\"reason\\":\\"read\\",\\"message\\":\\"Saved object [alert/1] not found\\"}} - {\\"outcome\\":\\"failed\\",\\"warning\\":\\"read\\",\\"outcomeMsg\\":\\"Saved object [alert/1] not found\\",\\"alertsCount\\":{}}"` + ); + const executeRuleDebugLogger = logger.debug.mock.calls[4][0]; expect(executeRuleDebugLogger as string).toMatchInlineSnapshot( `"Executing Rule foo:test:1 has resulted in Error: Saved object [alert/1] not found"` ); + expect(logger.error).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).nthCalledWith( @@ -1626,10 +1655,15 @@ describe('Task Runner', () => { return taskRunner.run().catch((ex) => { expect(ex.toString()).toEqual(`Error: Saved object [alert/1] not found`); - const ruleExecuteDebugLog = logger.debug.mock.calls[3][0]; + const updateRuleDebugLogger = logger.debug.mock.calls[3][0]; + expect(updateRuleDebugLogger as string).toMatchInlineSnapshot( + `"Updating rule task for test rule with id 1 - {\\"lastExecutionDate\\":\\"1970-01-01T00:00:00.000Z\\",\\"status\\":\\"error\\",\\"error\\":{\\"reason\\":\\"read\\",\\"message\\":\\"Saved object [alert/1] not found\\"}} - {\\"outcome\\":\\"failed\\",\\"warning\\":\\"read\\",\\"outcomeMsg\\":\\"Saved object [alert/1] not found\\",\\"alertsCount\\":{}}"` + ); + const ruleExecuteDebugLog = logger.debug.mock.calls[4][0]; expect(ruleExecuteDebugLog as string).toMatchInlineSnapshot( `"Executing Rule test space:test:1 has resulted in Error: Saved object [alert/1] not found"` ); + expect(logger.error).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).nthCalledWith( @@ -2077,14 +2111,18 @@ describe('Task Runner', () => { expect(call.services.scopedClusterClient).toBeTruthy(); expect(call.services).toBeTruthy(); - expect(logger.debug).toHaveBeenCalledTimes(4); + expect(logger.debug).toHaveBeenCalledTimes(5); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' + 'deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' ); expect(logger.debug).nthCalledWith( 3, + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":0,"new":0,"recovered":0,"ignored":0}}' + ); + expect(logger.debug).nthCalledWith( + 4, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); @@ -2176,6 +2214,78 @@ describe('Task Runner', () => { ); }); + test('successfully stores next run', async () => { + const taskRunner = new TaskRunner( + ruleType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams, + inMemoryMetrics + ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ + ...mockedRuleTypeSavedObject, + schedule: { interval: '50s' }, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); + + await taskRunner.run(); + expect( + taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update + ).toHaveBeenCalledWith( + ...generateSavedObjectParams({ + nextRun: '1970-01-01T00:00:50.000Z', + }) + ); + }); + + test('updates the rule saved object correctly when failed', async () => { + const taskRunner = new TaskRunner( + ruleType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams, + inMemoryMetrics + ); + expect(AlertingEventLogger).toHaveBeenCalled(); + + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); + + ruleType.executor.mockImplementation( + async ({ + services: executorServices, + }: RuleExecutorOptions< + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + throw new Error(GENERIC_ERROR_MESSAGE); + } + ); + await taskRunner.run(); + ruleType.executor.mockClear(); + expect( + taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update + ).toHaveBeenCalledWith( + ...generateSavedObjectParams({ + error: { + message: GENERIC_ERROR_MESSAGE, + reason: 'execute', + }, + outcome: 'failed', + status: 'error', + successRatio: 0, + history: [ + { + success: false, + timestamp: 0, + }, + ], + }) + ); + }); + test('caps monitoring history at 200', async () => { const taskRunner = new TaskRunner( ruleType, @@ -2192,7 +2302,7 @@ describe('Task Runner', () => { await taskRunner.run(); } const runnerResult = await taskRunner.run(); - expect(runnerResult.monitoring?.execution.history.length).toBe(200); + expect(runnerResult.monitoring?.run.history.length).toBe(200); }); test('Actions circuit breaker kicked in, should set status as warning and log a message in event log', async () => { @@ -2276,7 +2386,17 @@ describe('Task Runner', () => { expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({ status: 'warning', warning })); + ).toHaveBeenCalledWith( + ...generateSavedObjectParams({ + status: 'warning', + outcome: 'warning', + warning, + alertsCount: { + active: 1, + new: 1, + }, + }) + ); expect(runnerResult).toEqual( generateRunnerResult({ @@ -2299,7 +2419,7 @@ describe('Task Runner', () => { }) ); - expect(logger.debug).toHaveBeenCalledTimes(6); + expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith( 3, @@ -2428,7 +2548,17 @@ describe('Task Runner', () => { expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({ status: 'warning', warning })); + ).toHaveBeenCalledWith( + ...generateSavedObjectParams({ + status: 'warning', + outcome: 'warning', + warning, + alertsCount: { + active: 2, + new: 2, + }, + }) + ); expect(runnerResult).toEqual( generateRunnerResult({ @@ -2463,7 +2593,7 @@ describe('Task Runner', () => { }) ); - expect(logger.debug).toHaveBeenCalledTimes(6); + expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith( 3, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 354b18d3d38d4..a06bd01cb9ce0 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -23,6 +23,8 @@ import { ruleExecutionStatusToRaw, isRuleSnoozed, processAlerts, + lastRunFromError, + getNextRun, } from '../lib'; import { RuleExecutionStatus, @@ -30,13 +32,12 @@ import { IntervalSchedule, RawAlertInstance, RawRuleExecutionStatus, - RuleMonitoring, - RuleMonitoringHistory, + RawRuleMonitoring, RuleTaskState, RuleTypeRegistry, + RawRuleLastRun, } from '../types'; -import { asErr, asOk, map, resolveErr, Result } from '../lib/result_type'; -import { getExecutionDurationPercentiles, getExecutionSuccessRatio } from '../lib/monitoring'; +import { asErr, asOk, isOk, map, resolveErr, Result } from '../lib/result_type'; import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; import { isAlertSavedObjectNotFoundError, isEsUnavailableError } from '../lib/is_alerting_error'; import { partiallyUpdateAlert } from '../saved_objects'; @@ -66,19 +67,12 @@ import { loadRule } from './rule_loader'; import { logAlerts } from './log_alerts'; import { getPublicAlertFactory } from '../alert/create_alert_factory'; import { TaskRunnerTimer, TaskRunnerTimerSpan } from './task_runner_timer'; +import { RuleMonitoringService } from '../monitoring/rule_monitoring_service'; +import { ILastRun, lastRunFromState, lastRunToRaw } from '../lib/last_run_status'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; -export const getDefaultRuleMonitoring = (): RuleMonitoring => ({ - execution: { - history: [], - calculated_metrics: { - success_ratio: 0, - }, - }, -}); - interface StackTraceLog { message: ElasticsearchError; stackTrace?: string; @@ -117,6 +111,7 @@ export class TaskRunner< private searchAbortController: AbortController; private cancelled: boolean; private stackTraceLog: StackTraceLog | null; + private ruleMonitoring: RuleMonitoringService; constructor( ruleType: NormalizedRuleType< @@ -149,15 +144,20 @@ export class TaskRunner< this.timer = new TaskRunnerTimer({ logger: this.logger }); this.alertingEventLogger = new AlertingEventLogger(this.context.eventLogger); this.stackTraceLog = null; + this.ruleMonitoring = new RuleMonitoringService(); } private async updateRuleSavedObject( ruleId: string, namespace: string | undefined, - attributes: { executionStatus?: RawRuleExecutionStatus; monitoring?: RuleMonitoring } + attributes: { + executionStatus?: RawRuleExecutionStatus; + monitoring?: RawRuleMonitoring; + nextRun?: string | null; + lastRun?: RawRuleLastRun | null; + } ) { const client = this.context.internalSavedObjectsRepository; - try { await partiallyUpdateAlert(client, ruleId, attributes, { ignore404: true, @@ -324,6 +324,7 @@ export class TaskRunner< alertFactory: getPublicAlertFactory(alertFactory), shouldWriteAlerts: () => this.shouldLogAndScheduleActionsForAlerts(), shouldStopExecution: () => this.cancelled, + ruleMonitoringService: this.ruleMonitoring.getLastRunMetricsSetters(), }, params, state: ruleTypeState as RuleState, @@ -521,13 +522,13 @@ export class TaskRunner< } private async processRunResults({ + nextRun, runDate, stateWithMetrics, - monitoring, }: { + nextRun: string | null; runDate: Date; stateWithMetrics: Result; - monitoring: RuleMonitoring; }) { const { params: { alertId: ruleId, spaceId }, @@ -535,7 +536,8 @@ export class TaskRunner< const namespace = this.context.spaceIdToNamespace(spaceId); - const { status: executionStatus, metrics: executionMetrics } = map< + // Getting executionStatus for backwards compatibility + const { status: executionStatus } = map< RuleTaskStateAndMetrics, ElasticsearchError, IExecutionStatusAndMetrics @@ -545,16 +547,36 @@ export class TaskRunner< (err: ElasticsearchError) => executionStatusFromError(err, runDate) ); + // New consolidated statuses for lastRun + const { lastRun, metrics: executionMetrics } = map< + RuleTaskStateAndMetrics, + ElasticsearchError, + ILastRun + >( + stateWithMetrics, + (ruleRunStateWithMetrics) => lastRunFromState(ruleRunStateWithMetrics), + (err: ElasticsearchError) => lastRunFromError(err) + ); + if (apm.currentTransaction) { if (executionStatus.status === 'ok' || executionStatus.status === 'active') { apm.currentTransaction.setOutcome('success'); } else if (executionStatus.status === 'error' || executionStatus.status === 'unknown') { apm.currentTransaction.setOutcome('failure'); + } else if (lastRun.outcome === 'succeeded') { + apm.currentTransaction.setOutcome('success'); + } else if (lastRun.outcome === 'failed') { + apm.currentTransaction.setOutcome('failure'); } } this.logger.debug( - `ruleRunStatus for ${this.ruleType.id}:${ruleId}: ${JSON.stringify(executionStatus)}` + `deprecated ruleRunStatus for ${this.ruleType.id}:${ruleId}: ${JSON.stringify( + executionStatus + )}` + ); + this.logger.debug( + `ruleRunStatus for ${this.ruleType.id}:${ruleId}: ${JSON.stringify(lastRun)}` ); if (executionMetrics) { this.logger.debug( @@ -562,11 +584,6 @@ export class TaskRunner< ); } - const monitoringHistory: RuleMonitoringHistory = { - success: true, - timestamp: +new Date(), - }; - // set start and duration based on event log const { start, duration } = this.alertingEventLogger.getStartAndDuration(); if (null != start) { @@ -574,34 +591,32 @@ export class TaskRunner< } if (null != duration) { executionStatus.lastDuration = nanosToMillis(duration); - monitoringHistory.duration = executionStatus.lastDuration; } // if executionStatus indicates an error, fill in fields in - // event from it - if (executionStatus.error) { - monitoringHistory.success = false; - } - - monitoring.execution.history.push(monitoringHistory); - monitoring.execution.calculated_metrics = { - success_ratio: getExecutionSuccessRatio(monitoring), - ...getExecutionDurationPercentiles(monitoring), - }; + this.ruleMonitoring.addHistory({ + duration: executionStatus.lastDuration, + hasError: executionStatus.error != null, + runDate, + }); if (!this.cancelled) { this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS); - if (executionStatus.error) { + if (lastRun.outcome === 'failed') { + this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_FAILURES); + } else if (executionStatus.error) { this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_FAILURES); } this.logger.debug( `Updating rule task for ${this.ruleType.id} rule with id ${ruleId} - ${JSON.stringify( executionStatus - )}` + )} - ${JSON.stringify(lastRun)}` ); await this.updateRuleSavedObject(ruleId, namespace, { executionStatus: ruleExecutionStatusToRaw(executionStatus), - monitoring, + nextRun, + lastRun: lastRunToRaw(lastRun), + monitoring: this.ruleMonitoring.getMonitoring() as RawRuleMonitoring, }); } @@ -625,15 +640,13 @@ export class TaskRunner< } let stateWithMetrics: Result; - let monitoring: RuleMonitoring = getDefaultRuleMonitoring(); let schedule: Result; try { const preparedResult = await this.timer.runWithTimer( TaskRunnerTimerSpan.PrepareRule, async () => this.prepareToRun() ); - - monitoring = preparedResult.rule.monitoring ?? getDefaultRuleMonitoring(); + this.ruleMonitoring.setMonitoring(preparedResult.rule.monitoring); stateWithMetrics = asOk(await this.runRule(preparedResult)); @@ -645,13 +658,20 @@ export class TaskRunner< schedule = asErr(err); } + let nextRun: string | null = null; + if (isOk(schedule)) { + nextRun = getNextRun({ startDate: startedAt, interval: schedule.value.interval }); + } else if (taskSchedule) { + nextRun = getNextRun({ startDate: startedAt, interval: taskSchedule.interval }); + } + const { executionStatus, executionMetrics } = await this.timer.runWithTimer( TaskRunnerTimerSpan.ProcessRuleRun, async () => this.processRunResults({ + nextRun, runDate, stateWithMetrics, - monitoring, }) ); @@ -721,7 +741,7 @@ export class TaskRunner< return { interval: retryInterval }; }), - monitoring, + monitoring: this.ruleMonitoring.getMonitoring(), }; } @@ -735,6 +755,8 @@ export class TaskRunner< // Write event log entry const { params: { alertId: ruleId, spaceId, consumer }, + schedule: taskSchedule, + startedAt, } = this.taskInstance; const namespace = this.context.spaceIdToNamespace(spaceId); @@ -755,13 +777,20 @@ export class TaskRunner< this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_TIMEOUTS); + let nextRun: string | null = null; + if (taskSchedule) { + nextRun = getNextRun({ startDate: startedAt, interval: taskSchedule.interval }); + } + + const outcomeMsg = `${this.ruleType.id}:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of ${this.ruleType.ruleTaskTimeout}`; + const date = new Date(); // Update the rule saved object with execution status const executionStatus: RuleExecutionStatus = { - lastExecutionDate: new Date(), + lastExecutionDate: date, status: 'error', error: { reason: RuleExecutionStatusErrorReasons.Timeout, - message: `${this.ruleType.id}:${ruleId}: execution cancelled due to timeout - exceeded rule type timeout of ${this.ruleType.ruleTaskTimeout}`, + message: outcomeMsg, }, }; this.logger.debug( @@ -769,6 +798,14 @@ export class TaskRunner< ); await this.updateRuleSavedObject(ruleId, namespace, { executionStatus: ruleExecutionStatusToRaw(executionStatus), + lastRun: { + outcome: 'failed', + warning: RuleExecutionStatusErrorReasons.Timeout, + outcomeMsg, + alertsCount: {}, + }, + monitoring: this.ruleMonitoring.getMonitoring() as RawRuleMonitoring, + nextRun: nextRun && new Date(nextRun).getTime() > date.getTime() ? nextRun : null, }); } } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 92353cb043984..33efc649add26 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -214,6 +214,32 @@ describe('Task Runner Cancel', () => { status: 'error', warning: null, }, + lastRun: { + alertsCount: {}, + outcome: 'failed', + outcomeMsg: + 'test:1: execution cancelled due to timeout - exceeded rule type timeout of 5m', + warning: 'timeout', + }, + monitoring: { + run: { + calculated_metrics: { + success_ratio: 0, + }, + history: [], + last_run: { + metrics: { + gap_duration_s: null, + total_alerts_created: null, + total_alerts_detected: null, + total_indexing_duration_ms: null, + total_search_duration_ms: null, + }, + timestamp: '1970-01-01T00:00:00.000Z', + }, + }, + }, + nextRun: '1970-01-01T00:00:10.000Z', }, { refresh: false, namespace: undefined } ); @@ -391,7 +417,7 @@ describe('Task Runner Cancel', () => { }); function testLogger() { - expect(logger.debug).toHaveBeenCalledTimes(7); + expect(logger.debug).toHaveBeenCalledTimes(8); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); expect(logger.debug).nthCalledWith( 2, @@ -411,10 +437,10 @@ describe('Task Runner Cancel', () => { ); expect(logger.debug).nthCalledWith( 6, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + 'deprecated ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' ); expect(logger.debug).nthCalledWith( - 7, + 8, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); } diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 88f5d8d1562de..3eccd1d362127 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -43,6 +43,7 @@ import { RuleMonitoring, MappedParams, RuleSnooze, + RuleLastRun, } from '../common'; import { PublicAlertFactory } from './alert/create_alert_factory'; export type WithoutQueryAndParams = Pick>; @@ -81,6 +82,7 @@ export interface RuleExecutorServices< alertFactory: PublicAlertFactory; shouldWriteAlerts: () => boolean; shouldStopExecution: () => boolean; + ruleMonitoringService?: PublicRuleMonitoringService; } export interface RuleExecutorOptions< @@ -271,9 +273,11 @@ export interface RawRule extends SavedObjectAttributes { mutedInstanceIds: string[]; meta?: RuleMeta; executionStatus: RawRuleExecutionStatus; - monitoring?: RuleMonitoring; + monitoring?: RawRuleMonitoring; snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API isSnoozedUntil?: string | null; + lastRun?: RawRuleLastRun | null; + nextRun?: string | null; } export interface AlertingPlugin { @@ -302,3 +306,14 @@ export interface InvalidatePendingApiKey { export type RuleTypeRegistry = PublicMethodsOf; export type RulesClientApi = PublicMethodsOf; + +export interface PublicRuleMonitoringService { + setLastRunMetricsTotalSearchDurationMs: (totalSearchDurationMs: number) => void; + setLastRunMetricsTotalIndexingDurationMs: (totalIndexingDurationMs: number) => void; + setLastRunMetricsTotalAlertsDetected: (totalAlertDetected: number) => void; + setLastRunMetricsTotalAlertsCreated: (totalAlertCreated: number) => void; + setLastRunMetricsGapDurationS: (gapDurationS: number) => void; +} + +export interface RawRuleLastRun extends SavedObjectAttributes, RuleLastRun {} +export interface RawRuleMonitoring extends SavedObjectAttributes, RuleMonitoring {} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/__snapshots__/most_used_chart.test.tsx.snap b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/__snapshots__/most_used_chart.test.tsx.snap index 5dcaf604acfb9..bc2ecef25d310 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/__snapshots__/most_used_chart.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/__snapshots__/most_used_chart.test.tsx.snap @@ -86,7 +86,9 @@ Object { "layerType": "data", "legendDisplay": "hide", "legendPosition": "bottom", - "metric": "countColumn", + "metrics": Array [ + "countColumn", + ], "nestedLegend": false, "numberDisplay": "percent", "primaryGroups": Array [ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/get_lens_attributes.ts b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/get_lens_attributes.ts index bd43169f94399..c311fa561f81c 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/get_lens_attributes.ts +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/most_used_chart/get_lens_attributes.ts @@ -93,7 +93,7 @@ export function getLensAttributes({ { layerId: metricId, primaryGroups: [columnA], - metric: columnB, + metrics: [columnB], categoryDisplay: 'default', legendDisplay: 'hide', nestedLegend: false, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/elements/pie_vis/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/elements/pie_vis/index.ts index e7ece1155560a..52368f2718843 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/elements/pie_vis/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/elements/pie_vis/index.ts @@ -16,6 +16,6 @@ export const pieVis: ElementFactory = () => ({ | selectFilter | demodata | head 10 -| pieVis metric={visdimension "age"} buckets={visdimension "project"} buckets={visdimension "cost"} legendDisplay="default" +| pieVis metrics={visdimension "age"} buckets={visdimension "project"} buckets={visdimension "cost"} legendDisplay="default" | render`, }); diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index dea87bf81826f..f75289bfa020b 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -16,10 +16,10 @@ export interface FindingsEvaluation { totalFindings: number; totalPassed: number; totalFailed: number; + postureScore: Score; } export interface Stats extends FindingsEvaluation { - postureScore: Score; resourcesEvaluated?: number; } diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index 597e037cb68d8..6356f00b5ec9f 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -5,13 +5,15 @@ * 2.0. */ -import { euiPaletteForStatus } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { euiThemeVars } from '@kbn/ui-theme'; import { CLOUDBEAT_EKS, CLOUDBEAT_VANILLA } from '../../common/constants'; -const [success, warning, danger] = euiPaletteForStatus(3); +export const statusColors = { + passed: euiThemeVars.euiColorVis0, + failed: euiThemeVars.euiColorVis9, +}; -export const statusColors = { success, warning, danger }; export const CSP_MOMENT_FORMAT = 'MMMM D, YYYY @ HH:mm:ss.SSS'; export type CloudPostureIntegrations = typeof cloudPostureIntegrations; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx index 20ebd4b893f20..b0f16e0066733 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx @@ -29,6 +29,7 @@ import { import { FormattedDate, FormattedTime } from '@kbn/i18n-react'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; +import { statusColors } from '../../../common/constants'; import { RULE_FAILED, RULE_PASSED } from '../../../../common/constants'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; import type { Evaluation, PostureTrend, Stats } from '../../../../common/types'; @@ -163,7 +164,7 @@ export const CloudPostureScoreChart = ({ onEvalCounterClick(RULE_PASSED)} tooltipContent={i18n.translate( 'xpack.csp.cloudPostureScoreChart.counterLink.passedFindingsTooltip', @@ -174,7 +175,7 @@ export const CloudPostureScoreChart = ({ onEvalCounterClick(RULE_FAILED)} tooltipContent={i18n.translate( 'xpack.csp.cloudPostureScoreChart.counterLink.failedFindingsTooltip', diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts index 6b2c00c507e6f..48f8c25b68703 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts @@ -12,6 +12,7 @@ const podsAgg = { totalFindings: 2, totalPassed: 1, totalFailed: 1, + postureScore: 50.0, }; const etcdAgg = { @@ -19,6 +20,7 @@ const etcdAgg = { totalFindings: 5, totalPassed: 0, totalFailed: 5, + postureScore: 0, }; const clusterAgg = { @@ -26,6 +28,7 @@ const clusterAgg = { totalFindings: 2, totalPassed: 2, totalFailed: 0, + postureScore: 100.0, }; const systemAgg = { @@ -33,6 +36,7 @@ const systemAgg = { totalFindings: 10, totalPassed: 6, totalFailed: 4, + postureScore: 60.0, }; const apiAgg = { @@ -40,6 +44,7 @@ const apiAgg = { totalFindings: 19100, totalPassed: 2100, totalFailed: 17000, + postureScore: 10.9, }; const serverAgg = { @@ -47,6 +52,7 @@ const serverAgg = { totalFindings: 7, totalPassed: 4, totalFailed: 3, + postureScore: 57.1, }; const mockData: RisksTableProps['data'] = [ @@ -59,15 +65,11 @@ const mockData: RisksTableProps['data'] = [ ]; describe('getTopRisks', () => { - it('returns sorted by failed findings', () => { - expect(getTopRisks([systemAgg, etcdAgg, apiAgg], 3)).toEqual([apiAgg, etcdAgg, systemAgg]); + it('returns sorted by posture score', () => { + expect(getTopRisks([systemAgg, etcdAgg, apiAgg], 3)).toEqual([etcdAgg, apiAgg, systemAgg]); }); - it('return array filtered with failed findings only', () => { - expect(getTopRisks([systemAgg, clusterAgg, apiAgg], 3)).toEqual([apiAgg, systemAgg]); - }); - - it('return sorted and filtered array with the correct number of elements', () => { - expect(getTopRisks(mockData, 5)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]); + it('return sorted array with the correct number of elements', () => { + expect(getTopRisks(mockData, 5)).toEqual([etcdAgg, apiAgg, podsAgg, serverAgg, systemAgg]); }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx index a1850a793ab3e..34fdff3eac69c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx @@ -7,24 +7,26 @@ import React, { useMemo } from 'react'; import { - EuiBasicTable, EuiBasicTableColumn, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, + EuiInMemoryTable, EuiLink, EuiText, + EuiToolTip, + useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { statusColors } from '../../../common/constants'; import { ComplianceDashboardData, GroupedFindingsEvaluation } from '../../../../common/types'; -import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; export interface RisksTableProps { data: ComplianceDashboardData['groupedFindingsEvaluation']; maxItems: number; onCellClick: (name: string) => void; onViewAllClick: () => void; + viewAllButtonTitle: string; compact?: boolean; } @@ -32,19 +34,23 @@ export const getTopRisks = ( groupedFindingsEvaluation: ComplianceDashboardData['groupedFindingsEvaluation'], maxItems: number ) => { - const filtered = groupedFindingsEvaluation.filter((x) => x.totalFailed > 0); - const sorted = filtered.slice().sort((first, second) => second.totalFailed - first.totalFailed); + const sorted = groupedFindingsEvaluation + .slice() + .sort((first, second) => first.postureScore - second.postureScore); return sorted.slice(0, maxItems); }; export const RisksTable = ({ - data: resourcesTypes, + data: cisSectionsEvaluations, maxItems, onCellClick, onViewAllClick, + viewAllButtonTitle, compact, }: RisksTableProps) => { + const { euiTheme } = useEuiTheme(); + const columns: Array> = useMemo( () => [ { @@ -62,49 +68,87 @@ export const RisksTable = ({ ), }, { - field: 'totalFailed', + field: 'postureScore', + width: '115px', name: compact ? '' - : i18n.translate('xpack.csp.dashboard.risksTable.findingsColumnLabel', { - defaultMessage: 'Findings', + : i18n.translate('xpack.csp.dashboard.risksTable.complianceColumnLabel', { + defaultMessage: 'Compliance', }), - render: ( - totalFailed: GroupedFindingsEvaluation['totalFailed'], - resource: GroupedFindingsEvaluation - ) => ( - <> - - - - - {'/'} - - - + render: (postureScore: GroupedFindingsEvaluation['postureScore'], data) => ( + + + + + + + + + + + {`${ + postureScore?.toFixed(0) || 0 + }%`} + + ), }, ], - [compact, onCellClick] + [ + compact, + euiTheme.border.radius.medium, + euiTheme.font.weight.bold, + euiTheme.size.s, + euiTheme.size.xs, + onCellClick, + ] ); - const items = useMemo(() => getTopRisks(resourcesTypes, maxItems), [resourcesTypes, maxItems]); + const sortedByComplianceScore = getTopRisks(cisSectionsEvaluations, maxItems); return ( - - rowHeader="name" - items={items} + + items={sortedByComplianceScore} columns={columns} />
- + {viewAllButtonTitle}
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx index d68e8d7364a3f..c658eac5462a5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx @@ -43,42 +43,49 @@ export const mockDashboardData: ComplianceDashboardData = { totalFindings: 104, totalFailed: 0, totalPassed: 104, + postureScore: 100, }, { name: 'API Server', totalFindings: 27, totalFailed: 11, totalPassed: 16, + postureScore: 59.2, }, { name: 'Master Node Configuration Files', totalFindings: 17, totalFailed: 1, totalPassed: 16, + postureScore: 94.1, }, { name: 'Kubelet', totalFindings: 11, totalFailed: 4, totalPassed: 7, + postureScore: 63.6, }, { name: 'etcd', totalFindings: 6, totalFailed: 0, totalPassed: 6, + postureScore: 100, }, { name: 'Worker Node Configuration Files', totalFindings: 5, totalFailed: 0, totalPassed: 5, + postureScore: 100, }, { name: 'Scheduler', totalFindings: 2, totalFailed: 1, totalPassed: 1, + postureScore: 50.0, }, ], clusters: [ @@ -101,42 +108,49 @@ export const mockDashboardData: ComplianceDashboardData = { totalFindings: 104, totalFailed: 0, totalPassed: 104, + postureScore: 100, }, { name: 'API Server', totalFindings: 27, totalFailed: 11, totalPassed: 16, + postureScore: 59.2, }, { name: 'Master Node Configuration Files', totalFindings: 17, totalFailed: 1, totalPassed: 16, + postureScore: 94.1, }, { name: 'Kubelet', totalFindings: 11, totalFailed: 4, totalPassed: 7, + postureScore: 63.6, }, { name: 'etcd', totalFindings: 6, totalFailed: 0, totalPassed: 6, + postureScore: 100, }, { name: 'Worker Node Configuration Files', totalFindings: 5, totalFailed: 0, totalPassed: 5, + postureScore: 100, }, { name: 'Scheduler', totalFindings: 2, totalFailed: 1, totalPassed: 1, + postureScore: 50.0, }, ], trend: [ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx index 26bce94cd49a3..288a41d0a7a96 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiFlexItem, EuiFlexGroup, useEuiTheme, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; import type { ComplianceDashboardData, Evaluation } from '../../../../common/types'; import { RisksTable } from '../compliance_charts/risks_table'; @@ -126,6 +127,10 @@ export const CloudBenchmarksSection = ({ onCellClick={(resourceTypeName) => handleCellClick(cluster.meta.clusterId, resourceTypeName) } + viewAllButtonTitle={i18n.translate( + 'xpack.csp.dashboard.risksTable.clusterCardViewAllButtonTitle', + { defaultMessage: 'View all failed findings for this cluster' } + )} onViewAllClick={() => handleViewAllClick(cluster.meta.clusterId)} />
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx index 005d174079f02..4168beb2ffd30 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx @@ -9,6 +9,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; +import { statusColors } from '../../../common/constants'; import { DASHBOARD_COUNTER_CARDS } from '../test_subjects'; import { CspCounterCard, CspCounterCardProps } from '../../../components/csp_counter_card'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; @@ -79,7 +80,7 @@ export const CloudSummarySection = ({ { defaultMessage: 'Failing Findings' } ), title: , - titleColor: complianceData.stats.totalFailed > 0 ? 'danger' : 'text', + titleColor: complianceData.stats.totalFailed > 0 ? statusColors.failed : 'text', onClick: () => { navToFindings({ 'result.evaluation': RULE_FAILED }); }, @@ -131,6 +132,10 @@ export const CloudSummarySection = ({ maxItems={5} onCellClick={handleCellClick} onViewAllClick={handleViewAllClick} + viewAllButtonTitle={i18n.translate( + 'xpack.csp.dashboard.risksTable.viewAllButtonTitle', + { defaultMessage: 'View all failed findings' } + )} /> diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts index 2b288d6e696e8..ba97be21ad718 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts @@ -47,6 +47,9 @@ const mockClusterBuckets: ClusterBucket[] = [ passed_findings: { doc_count: 3, }, + score: { + value: 0.5, + }, }, { key: 'boo_type', @@ -57,6 +60,9 @@ const mockClusterBuckets: ClusterBucket[] = [ passed_findings: { doc_count: 3, }, + score: { + value: 0.5, + }, }, ], }, @@ -87,12 +93,14 @@ describe('getClustersFromAggs', () => { totalFindings: 6, totalFailed: 3, totalPassed: 3, + postureScore: 50.0, }, { name: 'boo_type', totalFindings: 6, totalFailed: 3, totalPassed: 3, + postureScore: 50.0, }, ], }, diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.test.ts index 46c73c4250157..6af6d97f51e26 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.test.ts @@ -17,6 +17,9 @@ const resourceTypeBuckets: FailedFindingsBucket[] = [ passed_findings: { doc_count: 11, }, + score: { + value: 0.268, + }, }, { key: 'boo_type', @@ -27,6 +30,9 @@ const resourceTypeBuckets: FailedFindingsBucket[] = [ passed_findings: { doc_count: 6, }, + score: { + value: 0.545, + }, }, ]; @@ -39,12 +45,14 @@ describe('getFailedFindingsFromAggs', () => { totalFindings: 41, totalFailed: 30, totalPassed: 11, + postureScore: 26.8, }, { name: 'boo_type', totalFindings: 11, totalFailed: 5, totalPassed: 6, + postureScore: 54.5, }, ]); }); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.ts index 8641a47c5e523..4f295ad7a2fdb 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_grouped_findings_evaluation.ts @@ -11,6 +11,7 @@ import type { QueryDslQueryContainer, SearchRequest, } from '@elastic/elasticsearch/lib/api/types'; +import { calculatePostureScore } from './get_stats'; import type { ComplianceDashboardData } from '../../../common/types'; import { KeyDocCount } from './compliance_dashboard'; @@ -25,12 +26,14 @@ export interface FailedFindingsBucket extends KeyDocCount { passed_findings: { doc_count: number; }; + score: { value: number }; } export const failedFindingsAggQuery = { aggs_by_resource_type: { terms: { field: 'rule.section', + size: 5, }, aggs: { failed_findings: { @@ -39,6 +42,22 @@ export const failedFindingsAggQuery = { passed_findings: { filter: { term: { 'result.evaluation': 'passed' } }, }, + score: { + bucket_script: { + buckets_path: { + passed: 'passed_findings>_count', + failed: 'failed_findings>_count', + }, + script: 'params.passed / (params.passed + params.failed)', + }, + }, + sort_by_score: { + bucket_sort: { + sort: { + score: 'asc' as 'asc', + }, + }, + }, }, }, }; @@ -55,12 +74,18 @@ export const getRisksEsQuery = (query: QueryDslQueryContainer, pitId: string): S export const getFailedFindingsFromAggs = ( queryResult: FailedFindingsBucket[] ): ComplianceDashboardData['groupedFindingsEvaluation'] => - queryResult.map((bucket) => ({ - name: bucket.key, - totalFindings: bucket.doc_count, - totalFailed: bucket.failed_findings.doc_count || 0, - totalPassed: bucket.passed_findings.doc_count || 0, - })); + queryResult.map((bucket) => { + const totalPassed = bucket.passed_findings.doc_count || 0; + const totalFailed = bucket.failed_findings.doc_count || 0; + + return { + name: bucket.key, + totalFindings: bucket.doc_count, + totalFailed, + totalPassed, + postureScore: calculatePostureScore(totalPassed, totalFailed), + }; + }); export const getGroupedFindingsEvaluation = async ( esClient: ElasticsearchClient, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts index c77781af69bd1..0fccbf2c6f8a5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts @@ -191,7 +191,7 @@ export const indices: ElasticsearchIndexWithIngestion[] = [ count: 1, crawler: { id: '5', - index_name: 'crawler', + index_name: 'connector-crawler', }, hidden: false, name: 'crawler', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts index 6e1ebda40bc91..4f5d4f97cab8e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts @@ -205,7 +205,7 @@ export const crawlerIndex: CrawlerViewIndex = { count: 1, crawler: { id: '5', - index_name: 'crawler', + index_name: 'connector-crawler', }, hidden: false, ingestionMethod: IngestionMethod.CRAWLER, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_flyout.tsx new file mode 100644 index 0000000000000..ee382bfc373ae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_flyout.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFormRow, + EuiText, + EuiSpacer, + EuiFlyoutFooter, + EuiButtonEmpty, + EuiButton, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { Status } from '../../../../../../../common/types/api'; +import { CANCEL_BUTTON_LABEL } from '../../../../../shared/constants'; + +import { ConnectorNameAndDescriptionApiLogic } from '../../../../api/connector/update_connector_name_and_description_api_logic'; + +import { ConnectorNameAndDescriptionFormContent } from './connector_name_and_description_form_content'; +import { ConnectorNameAndDescriptionLogic } from './connector_name_and_description_logic'; + +export const ConnectorNameAndDescriptionFlyout: React.FC = () => { + const { status } = useValues(ConnectorNameAndDescriptionApiLogic); + const { isEditing } = useValues(ConnectorNameAndDescriptionLogic); + const { saveNameAndDescription, setIsEditing } = useActions(ConnectorNameAndDescriptionLogic); + + if (!isEditing) return null; + + return ( + setIsEditing(false)} size="s"> + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.title', + { + defaultMessage: 'Describe this crawler', + } + )} +

+
+
+ + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.description', + { + defaultMessage: + 'By naming and describing this connector your colleagues and wider team will know what this connector is meant for.', + } + )} + + + + + + + + + + setIsEditing(false)} + isLoading={status === Status.LOADING} + > + {CANCEL_BUTTON_LABEL} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.configurationConnector.nameAndDescriptionFlyout.saveButtonLabel', + { + defaultMessage: 'Save name and description', + } + )} + + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_form.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_form.tsx index 18716a2c402de..2f361c8e37da4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_form.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_form.tsx @@ -12,40 +12,23 @@ import { useActions, useValues } from 'kea'; import { EuiButton, EuiButtonEmpty, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow, - EuiTextArea, } from '@elastic/eui'; import { Status } from '../../../../../../../common/types/api'; -import { - NAME_LABEL, - DESCRIPTION_LABEL, - SAVE_BUTTON_LABEL, - CANCEL_BUTTON_LABEL, -} from '../../../../../shared/constants'; +import { SAVE_BUTTON_LABEL, CANCEL_BUTTON_LABEL } from '../../../../../shared/constants'; import { ConnectorNameAndDescriptionApiLogic } from '../../../../api/connector/update_connector_name_and_description_api_logic'; -import { isConnectorIndex } from '../../../../utils/indices'; -import { IndexViewLogic } from '../../index_view_logic'; +import { ConnectorNameAndDescriptionFormContent } from './connector_name_and_description_form_content'; import { ConnectorNameAndDescriptionLogic } from './connector_name_and_description_logic'; export const ConnectorNameAndDescriptionForm: React.FC = () => { - const { index } = useValues(IndexViewLogic); const { status } = useValues(ConnectorNameAndDescriptionApiLogic); - const { - localNameAndDescription: { name, description }, - } = useValues(ConnectorNameAndDescriptionLogic); - const { saveNameAndDescription, setIsEditing, updateLocalNameAndDescription } = useActions( - ConnectorNameAndDescriptionLogic - ); - if (!isConnectorIndex(index)) { - return <>; - } + const { saveNameAndDescription, setIsEditing } = useActions(ConnectorNameAndDescriptionLogic); return ( { }} component="form" > - - { - updateLocalNameAndDescription({ name: event.target.value }); - }} - /> - - - { - updateLocalNameAndDescription({ description: event.target.value }); - }} - /> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_form_content.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_form_content.tsx new file mode 100644 index 0000000000000..695e69a2efe06 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_form_content.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiFieldText, EuiFormRow, EuiTextArea } from '@elastic/eui'; + +import { NAME_LABEL, DESCRIPTION_LABEL, OPTIONAL_LABEL } from '../../../../../shared/constants'; + +import { ConnectorNameAndDescriptionLogic } from './connector_name_and_description_logic'; + +export const ConnectorNameAndDescriptionFormContent: React.FC = () => { + const { + localNameAndDescription: { name, description }, + } = useValues(ConnectorNameAndDescriptionLogic); + const { updateLocalNameAndDescription } = useActions(ConnectorNameAndDescriptionLogic); + + return ( + <> + + { + updateLocalNameAndDescription({ name: event.target.value }); + }} + /> + + + { + updateLocalNameAndDescription({ description: event.target.value }); + }} + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_logic.ts index d8d83bbdeb540..934b7c11e180f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/connector_name_and_description/connector_name_and_description_logic.ts @@ -26,7 +26,7 @@ import { CachedFetchIndexApiLogicActions, } from '../../../../api/index/cached_fetch_index_api_logic'; import { FetchIndexApiResponse } from '../../../../api/index/fetch_index_api_logic'; -import { isConnectorIndex } from '../../../../utils/indices'; +import { isConnectorIndex, isCrawlerIndex } from '../../../../utils/indices'; type NameAndDescription = Partial>; @@ -72,7 +72,9 @@ export const ConnectorNameAndDescriptionLogic = kea< }, events: ({ actions, values }) => ({ afterMount: () => - actions.setNameAndDescription(isConnectorIndex(values.index) ? values.index.connector : {}), + actions.setNameAndDescription( + isConnectorIndex(values.index) || isCrawlerIndex(values.index) ? values.index.connector : {} + ), }), listeners: ({ actions, values }) => ({ apiError: (error) => flashAPIErrors(error), @@ -92,7 +94,7 @@ export const ConnectorNameAndDescriptionLogic = kea< }, makeRequest: () => clearFlashMessages(), saveNameAndDescription: () => { - if (isConnectorIndex(values.index)) { + if (isConnectorIndex(values.index) || isCrawlerIndex(values.index)) { actions.makeRequest({ connectorId: values.index.connector.id, indexName: values.index.connector.index_name, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler_total_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler_total_stats.tsx index f396dd19d5385..99687662419bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler_total_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler_total_stats.tsx @@ -9,10 +9,18 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiStatProps, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiStat } from '@elastic/eui'; +import { + EuiStatProps, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiStat, + EuiSpacer, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { CrawlerLogic } from './crawler/crawler_logic'; +import { NameAndDescriptionStats } from './name_and_description_stats'; import { OverviewLogic } from './overview.logic'; export const CrawlerTotalStats: React.FC = () => { @@ -60,14 +68,18 @@ export const CrawlerTotalStats: React.FC = () => { ]; return ( - - {stats.map((item, index) => ( - - - - - - ))} - + <> + + + + {stats.map((item, index) => ( + + + + + + ))} + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/name_and_description_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/name_and_description_stats.tsx index a7f150d24cbf2..3c5b44f6f571a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/name_and_description_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/name_and_description_stats.tsx @@ -5,55 +5,58 @@ * 2.0. */ -import React from 'react'; +import React, { MouseEventHandler } from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiStat, EuiStatProps, EuiText } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiStat, + EuiStatProps, + EuiText, +} from '@elastic/eui'; import { DESCRIPTION_LABEL, NAME_LABEL } from '../../../shared/constants'; -import { generateEncodedPath } from '../../../shared/encode_path_params'; -import { EuiLinkTo } from '../../../shared/react_router_helpers'; -import { SEARCH_INDEX_TAB_PATH } from '../../routes'; -import { isConnectorIndex } from '../../utils/indices'; +import { isConnectorIndex, isCrawlerIndex } from '../../utils/indices'; -import { IndexNameLogic } from './index_name_logic'; +import { ConnectorNameAndDescriptionFlyout } from './connector/connector_name_and_description/connector_name_and_description_flyout'; +import { ConnectorNameAndDescriptionLogic } from './connector/connector_name_and_description/connector_name_and_description_logic'; import { OverviewLogic } from './overview.logic'; -import { SearchIndexTabId } from './search_index'; -const EditDescription: React.FC<{ label: string; indexName: string }> = ({ label, indexName }) => ( +const EditDescription: React.FC<{ + label: string; + onClick: MouseEventHandler; +}> = ({ label, onClick }) => ( {label} - - Edit - + Edit ); export const NameAndDescriptionStats: React.FC = () => { - const { indexName } = useValues(IndexNameLogic); const { indexData, isError, isLoading } = useValues(OverviewLogic); const hideStats = isLoading || isError; + const { setIsEditing: setIsFlyoutVisible } = useActions(ConnectorNameAndDescriptionLogic); - if (!isConnectorIndex(indexData)) { + if (!(isConnectorIndex(indexData) || isCrawlerIndex(indexData))) { return <>; } const stats: EuiStatProps[] = [ { - description: , + description: setIsFlyoutVisible(true)} />, isLoading: hideStats, title: indexData.connector.name, }, { - description: , + description: ( + setIsFlyoutVisible(true)} /> + ), isLoading: hideStats, title: {indexData.connector.description || ''}, titleElement: 'p', @@ -61,14 +64,17 @@ export const NameAndDescriptionStats: React.FC = () => { ]; return ( - - {stats.map((item, index) => ( - - - - - - ))} - + <> + + {stats.map((item, index) => ( + + + + + + ))} + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts index edfef1ba1f3fc..65c751493890d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/labels.ts @@ -37,3 +37,7 @@ export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.nameLabel', { export const DESCRIPTION_LABEL = i18n.translate('xpack.enterpriseSearch.descriptionLabel', { defaultMessage: 'Description', }); + +export const OPTIONAL_LABEL = i18n.translate('xpack.enterpriseSearch.optionalLabel', { + defaultMessage: 'Optional', +}); diff --git a/x-pack/plugins/graph/public/apps/listing_route.tsx b/x-pack/plugins/graph/public/apps/listing_route.tsx index af869f7afaa21..15f4898b84364 100644 --- a/x-pack/plugins/graph/public/apps/listing_route.tsx +++ b/x-pack/plugins/graph/public/apps/listing_route.tsx @@ -21,19 +21,13 @@ import { GraphServices } from '../application'; const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit'; const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage'; -interface GraphUserContent extends UserContentCommonSchema { - type: string; - attributes: { - title: string; - description?: string; - }; -} +type GraphUserContent = UserContentCommonSchema; const toTableListViewSavedObject = (savedObject: GraphWorkspaceSavedObject): GraphUserContent => { return { id: savedObject.id!, updatedAt: savedObject.updatedAt!, - references: savedObject.references, + references: savedObject.references ?? [], type: savedObject.type, attributes: { title: savedObject.title, diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 3bf19d4d78b5c..c852acd9f1953 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -52,9 +52,10 @@ export enum EmptySizeRatios { } export interface SharedPieLayerState { + metrics: string[]; primaryGroups: string[]; secondaryGroups?: string[]; - metric?: string; + allowMultipleMetrics?: boolean; collapseFns?: Record; numberDisplay: NumberDisplayType; categoryDisplay: CategoryDisplayType; diff --git a/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx b/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx index 5409b135b4b98..a464e12c80d1c 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx @@ -618,22 +618,6 @@ export function getFormBasedDatasource({ getDropProps, onDrop, - getSupportedActionsForLayer(layerId, state, _, openLayerSettings) { - if (!openLayerSettings) { - return []; - } - return [ - { - displayName: i18n.translate('xpack.lens.indexPattern.layerSettingsAction', { - defaultMessage: 'Layer settings', - }), - execute: openLayerSettings, - icon: 'gear', - isCompatible: Boolean(state.layers[layerId]), - 'data-test-subj': 'lnsLayerSettings', - }, - ]; - }, getCustomWorkspaceRenderer: ( state: FormBasedPrivateState, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx index 97d0cf73b80dd..64a59df81c911 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx @@ -22,6 +22,7 @@ export const getCloneLayerAction = (props: CloneLayerAction): LayerAction => { }); return { + id: 'cloneLayerAction', execute: props.execute, displayName, isCompatible: Boolean(props.activeVisualization.cloneLayer && !props.isTextBasedLanguage), diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx index 9c32a24eac4dd..397325be3f4d7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx @@ -22,6 +22,7 @@ import type { LayerType } from '../../../..'; import type { LayerAction, Visualization } from '../../../../types'; import { getCloneLayerAction } from './clone_layer_action'; import { getRemoveLayerAction } from './remove_layer_action'; +import { getOpenLayerSettingsAction } from './open_layer_settings'; export interface LayerActionsProps { layerIndex: number; @@ -36,18 +37,28 @@ export const getSharedActions = ({ activeVisualization, isOnlyLayer, isTextBasedLanguage, + hasLayerSettings, + openLayerSettings, onCloneLayer, onRemoveLayer, }: { onRemoveLayer: () => void; onCloneLayer: () => void; layerIndex: number; + layerId: string; isOnlyLayer: boolean; activeVisualization: Visualization; + visualizationState: unknown; layerType?: LayerType; isTextBasedLanguage?: boolean; + hasLayerSettings: boolean; + openLayerSettings: () => void; core: Pick; }) => [ + getOpenLayerSettingsAction({ + hasLayerSettings, + openLayerSettings, + }), getCloneLayerAction({ execute: onCloneLayer, layerIndex, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/open_layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/open_layer_settings.tsx new file mode 100644 index 0000000000000..13570df68c581 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/open_layer_settings.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 { i18n } from '@kbn/i18n'; +import type { LayerAction } from '../../../../types'; + +export const getOpenLayerSettingsAction = (props: { + openLayerSettings: () => void; + hasLayerSettings: boolean; +}): LayerAction => { + const displayName = i18n.translate('xpack.lens.layerActions.layerSettingsAction', { + defaultMessage: 'Layer settings', + }); + + return { + id: 'openLayerSettings', + displayName, + execute: props.openLayerSettings, + icon: 'gear', + isCompatible: props.hasLayerSettings, + 'data-test-subj': 'lnsLayerSettings', + }; +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx index 5156fd7efd755..c9a353f07d14b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx @@ -207,6 +207,7 @@ export const getRemoveLayerAction = (props: RemoveLayerAction): LayerAction => { ); return { + id: 'removeLayerAction', execute: async () => { const storage = new Storage(localStorage); const lensLocalStorage = storage.get(LOCAL_STORAGE_LENS_KEY) ?? {}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 173378c6fb9ef..7389f423b7a85 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -698,6 +698,54 @@ describe('LayerPanel', () => { expect(mockDatasource.updateStateOnCloseDimension).toHaveBeenCalled(); expect(updateDatasource).toHaveBeenCalledWith('testDatasource', { newState: true }); }); + + it('should display the fake final accessor if present in the group config', async () => { + const fakeAccessorLabel = "I'm a fake!"; + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'a' }], + filterOperations: () => true, + fakeFinalAccessor: { + label: fakeAccessorLabel, + }, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const { instance } = await mountWithProvider(); + + expect(instance.exists('[data-test-subj="lns-fakeDimension"]')).toBeTruthy(); + expect( + instance + .find('[data-test-subj="lns-fakeDimension"] .lnsLayerPanel__triggerTextLabel') + .text() + ).toBe(fakeAccessorLabel); + }); + + it('should not display the fake final accessor if not present in the group config', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'a' }], + filterOperations: () => true, + fakeFinalAccessor: undefined, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const { instance } = await mountWithProvider(); + + expect(instance.exists('[data-test-subj="lns-fakeDimension"]')).toBeFalsy(); + }); }); // This test is more like an integration test, since the layer panel owns all diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 656daef46fccd..0e759db7fc9fc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -18,6 +18,8 @@ import { EuiIconTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; import { LayerType } from '../../../../common'; import { LayerActions } from './layer_actions'; import { IndexPatternServiceAPI } from '../../../data_views_service/service'; @@ -28,6 +30,7 @@ import { DragDropOperation, DropType, isOperation, + LayerAction, VisualizationDimensionGroupConfig, } from '../../../types'; import { DragDropIdentifier, ReorderProvider } from '../../../drag_drop'; @@ -48,13 +51,13 @@ import { onDropForVisualization, shouldRemoveSource } from './buttons/drop_targe import { getSharedActions } from './layer_actions/layer_actions'; import { FlyoutContainer } from './flyout_container'; +// hide the random sampling settings from the UI +const DISPLAY_RANDOM_SAMPLING_SETTINGS = false; + const initialActiveDimensionState = { isNew: false, }; -// hide the random sampling settings from the UI -const DISPLAY_RANDOM_SAMPLING_SETTINGS = false; - export function LayerPanel( props: Exclude & { activeVisualization: Visualization; @@ -317,30 +320,33 @@ export function LayerPanel( const [datasource] = Object.values(framePublicAPI.datasourceLayers); const isTextBasedLanguage = Boolean(datasource?.isTextBasedLanguage()); - const compatibleActions = useMemo( + const compatibleActions = useMemo( () => [ - ...(activeVisualization.getSupportedActionsForLayer?.( - layerId, - visualizationState, - updateVisualization, - () => setPanelSettingsOpen(true) - ) || []), - ...((DISPLAY_RANDOM_SAMPLING_SETTINGS && - layerDatasource?.getSupportedActionsForLayer?.( - layerId, - layerDatasourceState, - (newState) => updateDatasource(datasourceId, newState), - () => setPanelSettingsOpen(true) - )) || - []), + ...(activeVisualization + .getSupportedActionsForLayer?.(layerId, visualizationState) + .map((action) => ({ + ...action, + execute: () => { + updateVisualization( + activeVisualization.onLayerAction?.(layerId, action.id, visualizationState) + ); + }, + })) || []), ...getSharedActions({ + layerId, activeVisualization, + visualizationState, core, layerIndex, layerType: activeVisualization.getLayerType(layerId, visualizationState), isOnlyLayer, isTextBasedLanguage, + hasLayerSettings: Boolean( + activeVisualization.renderLayerSettings || + (layerDatasource?.renderLayerSettings && DISPLAY_RANDOM_SAMPLING_SETTINGS) + ), + openLayerSettings: () => setPanelSettingsOpen(true), onCloneLayer, onRemoveLayer: () => onRemoveLayer(layerId), }), @@ -348,16 +354,13 @@ export function LayerPanel( [ activeVisualization, core, - datasourceId, isOnlyLayer, isTextBasedLanguage, layerDatasource, - layerDatasourceState, layerId, layerIndex, onCloneLayer, onRemoveLayer, - updateDatasource, updateVisualization, visualizationState, ] @@ -599,6 +602,30 @@ export function LayerPanel( ) : null} + {group.fakeFinalAccessor && ( +
+ + + {group.fakeFinalAccessor.label} + + +
+ )} + {group.supportsMoreColumns ? ( - {((DISPLAY_RANDOM_SAMPLING_SETTINGS && layerDatasource?.renderLayerSettings) || - activeVisualization?.renderLayerSettings) && ( + {(layerDatasource?.renderLayerSettings || activeVisualization?.renderLayerSettings) && ( (settingsPanelRef.current = el)} isOpen={isPanelSettingsOpen} @@ -661,11 +687,14 @@ export function LayerPanel( >
- {DISPLAY_RANDOM_SAMPLING_SETTINGS && layerDatasource?.renderLayerSettings && ( - + {layerDatasource?.renderLayerSettings && DISPLAY_RANDOM_SAMPLING_SETTINGS && ( + <> + + + )} {activeVisualization?.renderLayerSettings && ( { */ getUsedDataViews: (state: T) => string[]; - getSupportedActionsForLayer?: ( - layerId: string, - state: T, - setState: StateSetter, - openLayerSettings?: () => void - ) => LayerAction[]; - getDatasourceInfo: ( state: T, references?: SavedObjectReference[], @@ -581,6 +574,7 @@ export interface DatasourceDataPanelProps { /** @internal **/ export interface LayerAction { + id: string; displayName: string; description?: string; execute: () => void | Promise; @@ -590,6 +584,8 @@ export interface LayerAction { 'data-test-subj'?: string; } +export type LayerActionFromVisualization = Omit; + interface SharedDimensionProps { /** Visualizations can restrict operations based on their own rules. * For example, limiting to only bucketed or only numeric operations. @@ -796,6 +792,10 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { /** ID is passed back to visualization. For example, `x` */ groupId: string; accessors: AccessorConfig[]; + // currently used only on partition charts to display non-editable UI dimension trigger in the buckets group when multiple metrics exist + fakeFinalAccessor?: { + label: string; + }; supportsMoreColumns: boolean; dimensionsTooMany?: number; /** If required, a warning will appear if accessors are empty */ @@ -1065,12 +1065,13 @@ export interface Visualization { * returns a list of custom actions supported by the visualization layer. * Default actions like delete/clear are not included in this list and are managed by the editor frame * */ - getSupportedActionsForLayer?: ( - layerId: string, - state: T, - setState: StateSetter, - openLayerSettings?: () => void - ) => LayerAction[]; + getSupportedActionsForLayer?: (layerId: string, state: T) => LayerActionFromVisualization[]; + + /** + * Perform state mutations in response to a layer action + */ + onLayerAction?: (layerId: string, actionId: string, state: T) => T; + /** returns the type string of the given layer */ getLayerType: (layerId: string, state?: T) => LayerType | undefined; diff --git a/x-pack/plugins/lens/public/visualizations/partition/layer_settings.test.tsx b/x-pack/plugins/lens/public/visualizations/partition/layer_settings.test.tsx new file mode 100644 index 0000000000000..9acedbc9b8dd2 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/partition/layer_settings.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { PieLayerState, PieVisualizationState } from '../..'; +import { LayerSettings } from './layer_settings'; +import { FramePublicAPI, VisualizationLayerSettingsProps } from '../../types'; +import { EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; + +describe('layer settings', () => { + describe('multiple metrics switch', () => { + const getState = (allowMultipleMetrics: boolean): PieVisualizationState => ({ + shape: 'pie', + layers: [ + { + layerId, + allowMultipleMetrics, + } as PieLayerState, + ], + }); + + const layerId = 'layer-id'; + const props: VisualizationLayerSettingsProps = { + setState: jest.fn(), + layerId, + state: getState(false), + frame: {} as FramePublicAPI, + panelRef: {} as React.MutableRefObject, + }; + + it('toggles multiple metrics', () => { + const toggleOn = () => + shallow() + .find(EuiSwitch) + .props() + .onChange({} as EuiSwitchEvent); + + const toggleOff = () => + shallow() + .find(EuiSwitch) + .props() + .onChange({} as EuiSwitchEvent); + + expect(props.setState).not.toHaveBeenCalled(); + + toggleOn(); + + expect(props.setState).toHaveBeenLastCalledWith({ + ...props.state, + layers: [ + { + ...props.state.layers[0], + allowMultipleMetrics: true, + }, + ], + }); + + toggleOff(); + + expect(props.setState).toHaveBeenLastCalledWith({ + ...props.state, + layers: [ + { + ...props.state.layers[0], + allowMultipleMetrics: false, + }, + ], + }); + }); + + test('switch reflects state', () => { + const isChecked = (state: PieVisualizationState) => + shallow() + .find(EuiSwitch) + .props().checked; + + expect(isChecked(getState(false))).toBeFalsy(); + expect(isChecked(getState(true))).toBeTruthy(); + }); + + test('hides option for mosaic', () => { + expect( + shallow( + + ).isEmptyRender() + ).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/partition/layer_settings.tsx b/x-pack/plugins/lens/public/visualizations/partition/layer_settings.tsx new file mode 100644 index 0000000000000..6876b218c235e --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/partition/layer_settings.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { PieChartTypes } from '../../../common'; +import { PieVisualizationState } from '../..'; +import { VisualizationLayerSettingsProps } from '../../types'; + +export function LayerSettings(props: VisualizationLayerSettingsProps) { + if (props.state.shape === PieChartTypes.MOSAIC) { + return null; + } + + const currentLayer = props.state.layers.find((layer) => layer.layerId === props.layerId); + + if (!currentLayer) { + return null; + } + + return ( + <> + + { + props.setState({ + ...props.state, + layers: props.state.layers.map((layer) => + layer.layerId !== props.layerId + ? layer + : { + ...layer, + allowMultipleMetrics: !layer.allowMultipleMetrics, + } + ), + }); + }} + /> + + + ); +} diff --git a/x-pack/plugins/lens/public/visualizations/partition/suggestions.test.ts b/x-pack/plugins/lens/public/visualizations/partition/suggestions.test.ts index c7285fb8771a6..0be4fbde6c1d2 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/suggestions.test.ts +++ b/x-pack/plugins/lens/public/visualizations/partition/suggestions.test.ts @@ -16,7 +16,7 @@ import { PieLayerState, PieVisualizationState, } from '../../../common'; -import { LayerTypes } from '@kbn/expression-xy-plugin/public'; +import { layerTypes } from '../../../common/layer_types'; describe('suggestions', () => { describe('pie', () => { @@ -64,9 +64,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: [], - metric: 'a', + metrics: ['a'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, @@ -267,6 +267,88 @@ describe('suggestions', () => { ).toHaveLength(0); }); + it('should accept multiple metrics when active and multi-metric', () => { + const chk = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: { + shape: PieChartTypes.PIE, + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + primaryGroups: ['a'], + metrics: ['b'], + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.INSIDE, + legendDisplay: LegendDisplay.SHOW, + allowMultipleMetrics: true, + }, + ], + }, + keptLayerIds: ['first'], + }); + + expect(chk).toHaveLength(2); + chk.forEach(({ state }) => { + expect(state.layers[0].allowMultipleMetrics).toBeTruthy(); + expect(state.layers[0].metrics).toEqual(['d', 'e']); + }); + }); + + it('should reject multiple metrics when NOT currently active', () => { + const chk = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + + expect(chk).toHaveLength(0); + }); + it('should reject if there are no buckets and it is not a specific chart type switch', () => { expect( suggestions({ @@ -566,10 +648,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a'], - metric: 'b', - + metrics: ['b'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.INSIDE, legendDisplay: LegendDisplay.SHOW, @@ -590,9 +671,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a'], - metric: 'b', + metrics: ['b'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.INSIDE, legendDisplay: 'show', @@ -623,9 +704,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: [], - metric: 'a', + metrics: ['a'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.DEFAULT, @@ -673,9 +754,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a', 'b'], - metric: 'e', + metrics: ['e'], numberDisplay: NumberDisplay.VALUE, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, @@ -687,7 +768,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject when there are too many metrics', () => { + it('should accept multiple metrics if active visualization', () => { expect( suggestions({ table: { @@ -722,9 +803,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a', 'b'], - metric: 'e', + metrics: ['e'], numberDisplay: NumberDisplay.PERCENT, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, @@ -733,6 +814,42 @@ describe('suggestions', () => { }, keptLayerIds: ['first'], }) + ).toHaveLength(2); + }); + + it('should reject multiple metrics if not active visualization', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) ).toHaveLength(0); }); @@ -759,9 +876,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a'], - metric: 'b', + metrics: ['b'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.INSIDE, @@ -782,9 +899,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a'], - metric: 'b', + metrics: ['b'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.DEFAULT, // This is changed @@ -816,9 +933,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: [], - metric: 'a', + metrics: ['a'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.DEFAULT, @@ -831,6 +948,50 @@ describe('suggestions', () => { ).toHaveLength(0); }); + it('should turn off multiple metrics for mosaic when switching from other partition type', () => { + const suggs = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: { + shape: PieChartTypes.PIE, + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + primaryGroups: ['a'], + metrics: ['b', 'c'], + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, + allowMultipleMetrics: true, + }, + ], + }, + keptLayerIds: ['first'], + subVisualizationId: 'mosaic', + }); + + expect(suggs).toHaveLength(1); + expect(suggs[0].state.layers[0].allowMultipleMetrics).toBeFalsy(); + }); + it('mosaic type should be hidden from the suggestion list', () => { expect( suggestions({ @@ -858,9 +1019,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a', 'b'], - metric: 'c', + metrics: ['c'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.INSIDE, @@ -893,9 +1054,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: [], - metric: 'a', + metrics: ['a'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.DEFAULT, @@ -931,9 +1092,9 @@ describe('suggestions', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, primaryGroups: ['a', 'b'], - metric: 'c', + metrics: ['c'], numberDisplay: NumberDisplay.HIDDEN, categoryDisplay: CategoryDisplay.INSIDE, legendDisplay: LegendDisplay.SHOW, diff --git a/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts b/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts index 961b6a68e745c..93887559a094e 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts +++ b/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts @@ -7,7 +7,6 @@ import { partition } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import type { SuggestionRequest, TableSuggestionColumn, @@ -23,6 +22,7 @@ import { import { isPartitionShape } from '../../../common/visualizations'; import type { PieChartType } from '../../../common/types'; import { PartitionChartsMeta } from './partition_charts_meta'; +import { layerTypes } from '../..'; function hasIntervalScale(columns: TableSuggestionColumn[]) { return columns.some((col) => col.operation.scale === 'interval'); @@ -80,6 +80,8 @@ export function suggestions({ return []; } + const isActive = Boolean(state); + const [groups, metrics] = partition( // filter out all metrics which are not number based table.columns.filter((col) => col.operation.isBucketed || col.operation.dataType === 'number'), @@ -90,12 +92,11 @@ export function suggestions({ return []; } - if (metrics.length > 1 || groups.length > maximumGroupLength) { + if ((metrics.length > 1 && !isActive) || groups.length > maximumGroupLength) { return []; } const incompleteConfiguration = metrics.length === 0 || groups.length === 0; - const metricColumnId = metrics.length > 0 ? metrics[0].columnId : undefined; if (incompleteConfiguration && state && !subVisualizationId) { // reject incomplete configurations if the sub visualization isn't specifically requested @@ -104,6 +105,8 @@ export function suggestions({ return []; } + const metricColumnIds = metrics.map(({ columnId }) => columnId); + const results: Array> = []; // Histograms are not good for pi. But we should not hide suggestion on switching between partition charts. @@ -131,18 +134,18 @@ export function suggestions({ ...state.layers[0], layerId: table.layerId, primaryGroups: groups.map((col) => col.columnId), - metric: metricColumnId, - layerType: LayerTypes.DATA, + metrics: metricColumnIds, + layerType: layerTypes.DATA, } : { layerId: table.layerId, primaryGroups: groups.map((col) => col.columnId), - metric: metricColumnId, + metrics: metricColumnIds, numberDisplay: NumberDisplay.PERCENT, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, }, ], }, @@ -178,7 +181,7 @@ export function suggestions({ if ( groups.length <= PartitionChartsMeta.treemap.maxBuckets && - (!subVisualizationId || subVisualizationId === 'treemap') + (!subVisualizationId || subVisualizationId === PieChartTypes.TREEMAP) ) { results.push({ title: i18n.translate('xpack.lens.pie.treemapSuggestionLabel', { @@ -196,22 +199,22 @@ export function suggestions({ ...state.layers[0], layerId: table.layerId, primaryGroups: groups.map((col) => col.columnId), - metric: metricColumnId, + metrics: metricColumnIds, categoryDisplay: state.layers[0].categoryDisplay === CategoryDisplay.INSIDE ? CategoryDisplay.DEFAULT : state.layers[0].categoryDisplay, - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, } : { layerId: table.layerId, primaryGroups: groups.map((col) => col.columnId), - metric: metricColumnId, + metrics: metricColumnIds, numberDisplay: NumberDisplay.PERCENT, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, }, ], }, @@ -244,20 +247,22 @@ export function suggestions({ layerId: table.layerId, primaryGroups: groups[0] ? [groups[0].columnId] : [], secondaryGroups: groups[1] ? [groups[1].columnId] : [], - metric: metricColumnId, + metrics: metricColumnIds, categoryDisplay: CategoryDisplay.DEFAULT, - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, + allowMultipleMetrics: false, } : { layerId: table.layerId, primaryGroups: groups[0] ? [groups[0].columnId] : [], secondaryGroups: groups[1] ? [groups[1].columnId] : [], - metric: metricColumnId, + metrics: metricColumnIds, numberDisplay: NumberDisplay.PERCENT, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, + allowMultipleMetrics: false, }, ], }, @@ -284,20 +289,20 @@ export function suggestions({ ...state.layers[0], layerId: table.layerId, primaryGroups: groups.map((col) => col.columnId), + metrics: metricColumnIds, secondaryGroups: [], - metric: metricColumnId, categoryDisplay: CategoryDisplay.DEFAULT, - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, } : { layerId: table.layerId, primaryGroups: groups.map((col) => col.columnId), - metric: metricColumnId, + metrics: metricColumnIds, numberDisplay: NumberDisplay.PERCENT, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, }, ], }, @@ -309,7 +314,9 @@ export function suggestions({ return [...results] .map((suggestion) => ({ ...suggestion, - score: shouldHideSuggestion ? 0 : suggestion.score + 0.05 * groups.length, + score: shouldHideSuggestion + ? 0 + : suggestion.score + 0.05 * groups.length + 0.01 * metricColumnIds.length, })) .sort((a, b) => b.score - a.score) .map((suggestion) => ({ diff --git a/x-pack/plugins/lens/public/visualizations/partition/to_expression.ts b/x-pack/plugins/lens/public/visualizations/partition/to_expression.ts index 42371f85e5984..2aa6651865d0c 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/to_expression.ts +++ b/x-pack/plugins/lens/public/visualizations/partition/to_expression.ts @@ -64,6 +64,20 @@ type GenerateLabelsAstArguments = ( layer: PieLayerState ) => [Ast]; +export const getColumnToLabelMap = ( + columnIds: string[], + datasource: DatasourcePublicAPI | undefined +) => { + const columnToLabel: Record = {}; + columnIds.forEach((accessor) => { + const operation = datasource?.getOperationForColumnId(accessor); + if (operation?.label) { + columnToLabel[accessor] = operation.label; + } + }); + return columnToLabel; +}; + export const getSortedGroups = ( datasource: DatasourcePublicAPI | undefined, layer: PieLayerState, @@ -139,7 +153,12 @@ const generateCommonArguments = ( .filter(({ columnId }) => !isCollapsed(columnId, layer)) .map(({ columnId }) => columnId) .map(prepareDimension), - metric: layer.metric ? prepareDimension(layer.metric) : '', + metrics: (layer.allowMultipleMetrics ? layer.metrics : [layer.metrics[0]]).map( + prepareDimension + ), + metricsToLabels: JSON.stringify( + getColumnToLabelMap(layer.metrics, datasourceLayers[layer.layerId]) + ), legendDisplay: (attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay) as PartitionVisLegendDisplay, @@ -189,10 +208,13 @@ const generateTreemapVisAst: GenerateExpressionAstFunction = (...rest) => { ]).toAst(); }; -const generateMosaicVisAst: GenerateExpressionAstFunction = (...rest) => - buildExpression([ +const generateMosaicVisAst: GenerateExpressionAstFunction = (...rest) => { + const { metrics, ...args } = generateCommonArguments(...rest); + + return buildExpression([ buildExpressionFunction('mosaicVis', { - ...generateCommonArguments(...rest), + ...{ ...args, metricsToLabels: undefined }, + metric: metrics, // flip order of bucket dimensions so the rows are fetched before the columns to keep them stable buckets: rest[2] .filter(({ columnId }) => !isCollapsed(columnId, rest[3])) @@ -201,6 +223,7 @@ const generateMosaicVisAst: GenerateExpressionAstFunction = (...rest) => .map(prepareDimension), }), ]).toAst(); +}; const generateWaffleVisAst: GenerateExpressionAstFunction = (...rest) => { const { buckets, nestedLegend, ...args } = generateCommonArguments(...rest); @@ -251,7 +274,7 @@ function expressionHelper( })) .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); - if (!layer.metric || !operations.length) { + if (!layer.metrics.length) { return null; } const visualizationAst = generateExprAst( @@ -273,7 +296,7 @@ function expressionHelper( .map((columnId) => { return buildExpressionFunction('lens_collapse', { by: groups.filter((chk) => chk !== columnId), - metric: layer.metric ? [layer.metric] : [], + metric: layer.metrics, fn: [layer.collapseFns![columnId]!], }).toAst(); }), diff --git a/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx b/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx index e92ce29c4d368..cab4781a6a319 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx +++ b/x-pack/plugins/lens/public/visualizations/partition/toolbar.tsx @@ -315,7 +315,7 @@ export function DimensionEditor( } const firstNonCollapsedColumnId = currentLayer.primaryGroups.find( - (columnId) => !isCollapsed(columnId, currentLayer) + (id) => !isCollapsed(id, currentLayer) ); return ( @@ -346,25 +346,29 @@ export function DimensionDataExtraEditor( return ( <> - { - props.setState({ - ...props.state, - layers: props.state.layers.map((layer) => - layer.layerId !== props.layerId - ? layer - : { - ...layer, - collapseFns: { - ...layer.collapseFns, - [props.accessor]: collapseFn, - }, - } - ), - }); - }} - /> + {[...currentLayer.primaryGroups, ...(currentLayer.secondaryGroups ?? [])].includes( + props.accessor + ) && ( + { + props.setState({ + ...props.state, + layers: props.state.layers.map((layer) => + layer.layerId !== props.layerId + ? layer + : { + ...layer, + collapseFns: { + ...layer.collapseFns, + [props.accessor]: collapseFn, + }, + } + ), + }); + }} + /> + )} ); } diff --git a/x-pack/plugins/lens/public/visualizations/partition/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/partition/visualization.test.ts index a08250cb3d5b9..1266169e6673e 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/partition/visualization.test.ts @@ -16,16 +16,23 @@ import { import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; -import { FramePublicAPI } from '../../types'; +import { FramePublicAPI, Visualization } from '../../types'; import { themeServiceMock } from '@kbn/core/public/mocks'; import { cloneDeep } from 'lodash'; import { PartitionChartsMeta } from './partition_charts_meta'; import { CollapseFunction } from '../../../common/expressions'; +import { PaletteOutput } from '@kbn/coloring'; jest.mock('../../id_generator'); const LAYER_ID = 'l1'; +const findPrimaryGroup = (config: ReturnType) => + config.groups.find((group) => group.groupId === 'primaryGroups'); + +const findMetricGroup = (config: ReturnType) => + config.groups.find((group) => group.groupId === 'metric'); + const pieVisualization = getPieVisualization({ paletteService: chartPluginMock.createPaletteRegistry(), kibanaTheme: themeServiceMock.createStartContract(), @@ -39,7 +46,7 @@ function getExampleState(): PieVisualizationState { layerId: LAYER_ID, layerType: LayerTypes.DATA, primaryGroups: [], - metric: undefined, + metrics: [], numberDisplay: NumberDisplay.PERCENT, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, @@ -76,11 +83,22 @@ describe('pie_visualization', () => { }); it("doesn't count collapsed dimensions", () => { - state.layers[0].collapseFns = { + const localState = cloneDeep(state); + localState.layers[0].collapseFns = { [colIds[0]]: 'some-fn' as CollapseFunction, }; - expect(pieVisualization.getErrorMessages(state)).toHaveLength(0); + expect(pieVisualization.getErrorMessages(localState)).toHaveLength(0); + }); + + it('counts multiple metrics as an extra bucket dimension', () => { + const localState = cloneDeep(state); + localState.layers[0].primaryGroups.pop(); + expect(pieVisualization.getErrorMessages(localState)).toHaveLength(0); + + localState.layers[0].metrics.push('one-metric', 'another-metric'); + + expect(pieVisualization.getErrorMessages(localState)).toHaveLength(1); }); }); }); @@ -110,7 +128,7 @@ describe('pie_visualization', () => { categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, - metric: undefined, + metrics: [], }, ], shape: PieChartTypes.DONUT, @@ -153,6 +171,34 @@ describe('pie_visualization', () => { expect(newState.layers[0].collapseFns).not.toHaveProperty('3'); }); + it('removes custom palette if removing final slice-by dimension in multi-metric chart', () => { + const state = getExampleState(); + + state.layers[0].primaryGroups = ['1', '2']; + state.layers[0].allowMultipleMetrics = true; + state.layers[0].metrics = ['3', '4']; + state.palette = {} as PaletteOutput; + + let newState = pieVisualization.removeDimension({ + layerId: LAYER_ID, + columnId: '1', + prevState: state, + frame: mockFrame(), + }); + + expect(newState.layers[0].primaryGroups).toEqual(['2']); + expect(newState.palette).toBeDefined(); + + newState = pieVisualization.removeDimension({ + layerId: LAYER_ID, + columnId: '2', + prevState: newState, + frame: mockFrame(), + }); + + expect(newState.layers[0].primaryGroups).toEqual([]); + expect(newState.palette).toBeUndefined(); + }); }); describe('#getConfiguration', () => { @@ -265,12 +311,128 @@ describe('pie_visualization', () => { layerId: state.layers[0].layerId, }); - expect(getConfig(state).groups[0].supportsMoreColumns).toBeFalsy(); + expect(findPrimaryGroup(getConfig(state))?.supportsMoreColumns).toBeFalsy(); const stateWithCollapsed = cloneDeep(state); stateWithCollapsed.layers[0].collapseFns = { '1': 'sum' }; - expect(getConfig(stateWithCollapsed).groups[0].supportsMoreColumns).toBeTruthy(); + expect(findPrimaryGroup(getConfig(stateWithCollapsed))?.supportsMoreColumns).toBeTruthy(); + }); + + it('counts multiple metrics toward the dimension limits when not mosaic', () => { + const colIds = new Array(PartitionChartsMeta.pie.maxBuckets - 1) + .fill(undefined) + .map((_, i) => String(i + 1)); + + const frame = mockFrame(); + frame.datasourceLayers[LAYER_ID]!.getTableSpec = () => + colIds.map((id) => ({ columnId: id, fields: [] })); + + const state = getExampleState(); + state.layers[0].primaryGroups = colIds; + state.layers[0].allowMultipleMetrics = true; + + const getConfig = (_state: PieVisualizationState) => + pieVisualization.getConfiguration({ + state: _state, + frame, + layerId: state.layers[0].layerId, + }); + + expect(findPrimaryGroup(getConfig(state))?.supportsMoreColumns).toBeTruthy(); + + const stateWithMultipleMetrics = cloneDeep(state); + stateWithMultipleMetrics.layers[0].metrics.push('1', '2'); + + expect( + findPrimaryGroup(getConfig(stateWithMultipleMetrics))?.supportsMoreColumns + ).toBeFalsy(); }); + + it('does NOT count multiple metrics toward the dimension limits when mosaic', () => { + const frame = mockFrame(); + frame.datasourceLayers[LAYER_ID]!.getTableSpec = () => []; + + const state = getExampleState(); + state.shape = 'mosaic'; + state.layers[0].primaryGroups = []; + state.layers[0].allowMultipleMetrics = false; // always true for mosaic + + const getConfig = (_state: PieVisualizationState) => + pieVisualization.getConfiguration({ + state: _state, + frame, + layerId: state.layers[0].layerId, + }); + + expect(findPrimaryGroup(getConfig(state))?.supportsMoreColumns).toBeTruthy(); + + const stateWithMultipleMetrics = cloneDeep(state); + stateWithMultipleMetrics.layers[0].metrics.push('1', '2'); + + expect( + findPrimaryGroup(getConfig(stateWithMultipleMetrics))?.supportsMoreColumns + ).toBeTruthy(); + }); + + it('reports too many metric dimensions if multiple not enabled', () => { + const colIds = ['1', '2', '3', '4']; + + const frame = mockFrame(); + frame.datasourceLayers[LAYER_ID]!.getTableSpec = () => + colIds.map((id) => ({ columnId: id, fields: [] })); + + const state = getExampleState(); + state.layers[0].metrics = colIds; + state.layers[0].allowMultipleMetrics = false; + expect( + findMetricGroup( + pieVisualization.getConfiguration({ + state, + frame, + layerId: state.layers[0].layerId, + }) + )?.dimensionsTooMany + ).toBe(3); + + state.layers[0].allowMultipleMetrics = true; + expect( + findMetricGroup( + pieVisualization.getConfiguration({ + state, + frame, + layerId: state.layers[0].layerId, + }) + )?.dimensionsTooMany + ).toBe(0); + }); + + it.each(Object.values(PieChartTypes).filter((type) => type !== 'mosaic'))( + '%s adds fake dimension', + (type) => { + const state = { ...getExampleState(), type }; + state.layers[0].metrics.push('1', '2'); + state.layers[0].allowMultipleMetrics = true; + expect( + findPrimaryGroup( + pieVisualization.getConfiguration({ + state, + frame: mockFrame(), + layerId: state.layers[0].layerId, + }) + )?.fakeFinalAccessor + ).toEqual({ label: '2 metrics' }); + + // but not when multiple metrics aren't allowed + state.layers[0].allowMultipleMetrics = false; + expect( + pieVisualization.getConfiguration({ + state, + frame: mockFrame(), + layerId: state.layers[0].layerId, + }).groups[1].fakeFinalAccessor + ).toBeUndefined(); + } + ); }); }); diff --git a/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx b/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx index 2c64acf64285b..0ba29502cf280 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx @@ -24,6 +24,7 @@ import type { VisualizationDimensionGroupConfig, Suggestion, VisualizeEditorContext, + VisualizationInfo, } from '../../types'; import { getSortedGroups, toExpression, toPreviewExpression } from './to_expression'; import { @@ -37,14 +38,19 @@ import { import { suggestions } from './suggestions'; import { PartitionChartsMeta } from './partition_charts_meta'; import { DimensionDataExtraEditor, DimensionEditor, PieToolbar } from './toolbar'; +import { LayerSettings } from './layer_settings'; import { checkTableForContainsSmallValues } from './render_helpers'; +const metricLabel = i18n.translate('xpack.lens.pie.groupMetricLabelSingular', { + defaultMessage: 'Metric', +}); + function newLayerState(layerId: string): PieLayerState { return { layerId, primaryGroups: [], secondaryGroups: undefined, - metric: undefined, + metrics: [], numberDisplay: NumberDisplay.PERCENT, categoryDisplay: CategoryDisplay.DEFAULT, legendDisplay: LegendDisplay.DEFAULT, @@ -66,25 +72,22 @@ const numberMetricOperations = (op: OperationMetadata) => export const isCollapsed = (columnId: string, layer: PieLayerState) => Boolean(layer.collapseFns?.[columnId]); -const applyPaletteToColumnConfig = ( +const applyPaletteToAccessorConfigs = ( columns: AccessorConfig[], layer: PieLayerState, palette: PieVisualizationState['palette'], paletteService: PaletteRegistry ) => { - const firstNonCollapsedColumnIdx = columns.findIndex( - (column) => !isCollapsed(column.columnId, layer) - ); - - if (firstNonCollapsedColumnIdx > -1) { - columns[firstNonCollapsedColumnIdx] = { - columnId: columns[firstNonCollapsedColumnIdx].columnId, - triggerIcon: 'colorBy', - palette: paletteService + const firstNonCollapsedColumnId = layer.primaryGroups.find((id) => !isCollapsed(id, layer)); + + columns.forEach((accessorConfig) => { + if (firstNonCollapsedColumnId === accessorConfig.columnId) { + accessorConfig.triggerIcon = 'colorBy'; + accessorConfig.palette = paletteService .get(palette?.name || 'default') - .getCategoricalColors(10, palette?.params), - }; - } + .getCategoricalColors(10, palette?.params); + } + }); }; export const getPieVisualization = ({ @@ -161,21 +164,45 @@ export const getPieVisualization = ({ })); if (accessors.length) { - applyPaletteToColumnConfig(accessors, layer, state.palette, paletteService); + applyPaletteToAccessorConfigs(accessors, layer, state.palette, paletteService); } const primaryGroupConfigBaseProps = { - requiredMinDimensionCount: 1, groupId: 'primaryGroups', accessors, enableDimensionEditor: true, filterOperations: bucketedOperations, }; - const totalNonCollapsedAccessors = accessors.reduce( - (total, { columnId }) => total + (isCollapsed(columnId, layer) ? 0 : 1), - 0 - ); + // We count multiple metrics as a bucket dimension. + // + // However, if this is a mosaic chart, we don't support multiple metrics + // so if there is more than one metric we got here via a chart switch from + // a subtype that supports multi-metrics e.g. pie. + // + // The user will be prompted to remove the extra metric dimensions and we don't + // count multiple metrics as a bucket dimension so that the rest of the dimension + // groups UI behaves correctly. + const multiMetricsBucketDimensionCount = + layer.metrics.length > 1 && state.shape !== 'mosaic' ? 1 : 0; + + const totalNonCollapsedAccessors = + accessors.reduce( + (total, { columnId }) => total + (isCollapsed(columnId, layer) ? 0 : 1), + 0 + ) + multiMetricsBucketDimensionCount; + + const fakeFinalAccessor = + layer.metrics.length > 1 && layer.allowMultipleMetrics + ? { + label: i18n.translate('xpack.lens.pie.multiMetricAccessorLabel', { + defaultMessage: '{number} metrics', + values: { + number: layer.metrics.length, + }, + }), + } + : undefined; switch (state.shape) { case 'donut': @@ -188,6 +215,7 @@ export const getPieVisualization = ({ dimensionEditorGroupLabel: i18n.translate('xpack.lens.pie.sliceDimensionGroupLabel', { defaultMessage: 'Slice', }), + fakeFinalAccessor, supportsMoreColumns: totalNonCollapsedAccessors < PartitionChartsMeta.pie.maxBuckets, dimensionsTooMany: totalNonCollapsedAccessors - PartitionChartsMeta.pie.maxBuckets, dataTestSubj: 'lnsPie_sliceByDimensionPanel', @@ -215,6 +243,7 @@ export const getPieVisualization = ({ dimensionEditorGroupLabel: i18n.translate('xpack.lens.pie.treemapDimensionGroupLabel', { defaultMessage: 'Group', }), + fakeFinalAccessor, supportsMoreColumns: totalNonCollapsedAccessors < PartitionChartsMeta[state.shape].maxBuckets, dimensionsTooMany: @@ -267,26 +296,34 @@ export const getPieVisualization = ({ } }; - const getMetricGroupConfig = (): VisualizationDimensionGroupConfig => ({ - groupId: 'metric', - groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { - defaultMessage: 'Size by', - }), - isMetricDimension: true, - dimensionEditorGroupLabel: i18n.translate('xpack.lens.pie.groupSizeLabel', { - defaultMessage: 'Size', - }), - paramEditorCustomProps: { - headingLabel: i18n.translate('xpack.lens.pie.headingLabel', { - defaultMessage: 'Value', - }), - }, - accessors: layer.metric ? [{ columnId: layer.metric }] : [], - supportsMoreColumns: !layer.metric, - filterOperations: numberMetricOperations, - requiredMinDimensionCount: 1, - dataTestSubj: 'lnsPie_sizeByDimensionPanel', - }); + const getMetricGroupConfig = (): VisualizationDimensionGroupConfig => { + const accessors = layer.metrics.map((columnId) => ({ columnId })); + applyPaletteToAccessorConfigs(accessors, layer, state.palette, paletteService); + + const groupLabel = layer.allowMultipleMetrics + ? i18n.translate('xpack.lens.pie.groupMetricLabel', { + defaultMessage: 'Metrics', + }) + : metricLabel; + + return { + groupId: 'metric', + groupLabel, + dimensionEditorGroupLabel: groupLabel, + paramEditorCustomProps: { + headingLabel: i18n.translate('xpack.lens.pie.headingLabel', { + defaultMessage: 'Value', + }), + }, + accessors, + supportsMoreColumns: layer.metrics.length === 0 || Boolean(layer.allowMultipleMetrics), + filterOperations: numberMetricOperations, + requiredMinDimensionCount: 1, + dimensionsTooMany: layer.allowMultipleMetrics ? 0 : layer.metrics.length - 1, + dataTestSubj: 'lnsPie_sizeByDimensionPanel', + enableDimensionEditor: true, + }; + }; return { groups: [getPrimaryGroupConfig(), getSecondaryGroupConfig(), getMetricGroupConfig()].filter( @@ -317,35 +354,48 @@ export const getPieVisualization = ({ ], }; } - return { ...l, metric: columnId }; + return { ...l, metrics: [...l.metrics.filter((metric) => metric !== columnId), columnId] }; }), }; }, removeDimension({ prevState, layerId, columnId }) { - return { - ...prevState, - layers: prevState.layers.map((l) => { - if (l.layerId !== layerId) { - return l; - } + const newState = { ...prevState }; - const newLayer = { ...l }; + const layerToChange = prevState.layers.find((l) => l.layerId === layerId); - if (l.collapseFns?.[columnId]) { - const newCollapseFns = { ...l.collapseFns }; - delete newCollapseFns[columnId]; - newLayer.collapseFns = newCollapseFns; - } + if (!layerToChange) { + return prevState; + } - if (newLayer.metric === columnId) { - return { ...newLayer, metric: undefined }; - } - return { - ...newLayer, - primaryGroups: newLayer.primaryGroups.filter((c) => c !== columnId), - secondaryGroups: newLayer.secondaryGroups?.filter((c) => c !== columnId) ?? undefined, - }; - }), + if ( + layerToChange.primaryGroups.includes(columnId) && + layerToChange.primaryGroups.length === 1 && + layerToChange.allowMultipleMetrics && + layerToChange.metrics.length + ) { + // we don't support palette selection for multiple metrics without a slice-by dimension + // so revert to default if the last slice-by is removed + delete newState.palette; + } + + let newLayer = { ...layerToChange }; + + if (layerToChange.collapseFns?.[columnId]) { + const newCollapseFns = { ...layerToChange.collapseFns }; + delete newCollapseFns[columnId]; + newLayer.collapseFns = newCollapseFns; + } + + newLayer = { + ...newLayer, + primaryGroups: newLayer.primaryGroups.filter((c) => c !== columnId), + secondaryGroups: newLayer.secondaryGroups?.filter((c) => c !== columnId) ?? undefined, + metrics: newLayer.metrics.filter((c) => c !== columnId), + }; + + return { + ...newState, + layers: newState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), }; }, renderDimensionEditor(domElement, props) { @@ -401,6 +451,17 @@ export const getPieVisualization = ({ ); }, + renderLayerSettings(domElement, props) { + render( + + + + + , + domElement + ); + }, + getWarningMessages(state, frame) { if (state?.layers.length === 0 || !frame.activeData) { return; @@ -408,13 +469,13 @@ export const getPieVisualization = ({ const warningMessages = []; for (const layer of state.layers) { - const { layerId, metric } = layer; + const { layerId, metrics } = layer; const rows = frame.activeData[layerId]?.rows; const numericColumn = frame.activeData[layerId]?.columns.find( ({ meta }) => meta?.type === 'number' ); - if (!rows || !metric) { + if (!rows || !metrics.length) { break; } @@ -432,17 +493,26 @@ export const getPieVisualization = ({ ); } - const columnToLabel = frame.datasourceLayers[layerId]?.getOperationForColumnId(metric)?.label; - const hasArrayValues = rows.some((row) => Array.isArray(row[metric])); - if (hasArrayValues) { + const metricsWithArrayValues = metrics + .map((metricColId) => { + if (rows.some((row) => Array.isArray(row[metricColId]))) { + return metricColId; + } + }) + .filter(Boolean) as string[]; + + if (metricsWithArrayValues.length) { + const labels = metricsWithArrayValues.map( + (colId) => frame.datasourceLayers[layerId]?.getOperationForColumnId(colId)?.label || colId + ); warningMessages.push( {columnToLabel || metric}, + label: {labels.join(', ')}, }} /> ); @@ -478,12 +548,15 @@ export const getPieVisualization = ({ getErrorMessages(state) { const hasTooManyBucketDimensions = state.layers - .map( - (layer) => + .map((layer) => { + const totalBucketDimensions = Array.from(new Set([...layer.primaryGroups, ...(layer.secondaryGroups ?? [])])).filter( (columnId) => !isCollapsed(columnId, layer) - ).length > PartitionChartsMeta[state.shape].maxBuckets - ) + ).length + + // multiple metrics counts as a dimension + (layer.metrics.length > 1 ? 1 : 0); + return totalBucketDimensions > PartitionChartsMeta[state.shape].maxBuckets; + }) .some(Boolean); return hasTooManyBucketDimensions @@ -511,15 +584,14 @@ export const getPieVisualization = ({ getVisualizationInfo(state: PieVisualizationState) { const layer = state.layers[0]; - const dimensions = []; - if (layer.metric) { + const dimensions: VisualizationInfo['layers'][number]['dimensions'] = []; + + layer.metrics.forEach((metric) => { dimensions.push({ - id: layer.metric, - name: i18n.translate('xpack.lens.pie.groupsizeLabel', { - defaultMessage: 'Size by', - }), + id: metric, + name: metricLabel, }); - } + }); if (state.shape === 'mosaic' && layer.secondaryGroups && layer.secondaryGroups.length) { layer.secondaryGroups.forEach((accessor) => { diff --git a/x-pack/plugins/lens/public/visualizations/xy/annotations/actions.ts b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions.ts index d7d3c9ca8a56a..4bd8a2f67d64d 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/annotations/actions.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions.ts @@ -6,20 +6,21 @@ */ import { i18n } from '@kbn/i18n'; -import type { LayerAction, StateSetter } from '../../../types'; +import type { LayerActionFromVisualization } from '../../../types'; import type { XYState, XYAnnotationLayerConfig } from '../types'; +export const IGNORE_GLOBAL_FILTERS_ACTION_ID = 'ignoreGlobalFilters'; +export const KEEP_GLOBAL_FILTERS_ACTION_ID = 'keepGlobalFilters'; + export const createAnnotationActions = ({ state, layer, layerIndex, - setState, }: { state: XYState; layer: XYAnnotationLayerConfig; layerIndex: number; - setState: StateSetter; -}): LayerAction[] => { +}): LayerActionFromVisualization[] => { const label = !layer.ignoreGlobalFilters ? i18n.translate('xpack.lens.xyChart.annotations.ignoreGlobalFiltersLabel', { defaultMessage: 'Ignore global filters', @@ -29,6 +30,9 @@ export const createAnnotationActions = ({ }); return [ { + id: !layer.ignoreGlobalFilters + ? IGNORE_GLOBAL_FILTERS_ACTION_ID + : KEEP_GLOBAL_FILTERS_ACTION_ID, displayName: label, description: !layer.ignoreGlobalFilters ? i18n.translate('xpack.lens.xyChart.annotations.ignoreGlobalFiltersDescription', { @@ -39,11 +43,6 @@ export const createAnnotationActions = ({ defaultMessage: 'All the dimensions configured in this layer respect filters defined at kibana level.', }), - execute: () => { - const newLayers = [...state.layers]; - newLayers[layerIndex] = { ...layer, ignoreGlobalFilters: !layer.ignoreGlobalFilters }; - return setState({ ...state, layers: newLayers }); - }, icon: !layer.ignoreGlobalFilters ? 'eyeClosed' : 'eye', isCompatible: true, 'data-test-subj': !layer.ignoreGlobalFilters diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts index 12baaab25af84..e1fe1d9c7f4cb 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts @@ -16,7 +16,6 @@ import type { XYReferenceLineLayerConfig, SeriesType, } from './types'; -import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; import { IconChartBar } from '@kbn/chart-icons'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; @@ -31,6 +30,8 @@ import { DataViewsState } from '../../state_management'; import { createMockedIndexPattern } from '../../datasources/form_based/mocks'; import { createMockDataViewsState } from '../../data_views_service/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; +import { KEEP_GLOBAL_FILTERS_ACTION_ID } from './annotations/actions'; +import { layerTypes } from '../..'; const exampleAnnotation: EventAnnotationConfig = { id: 'an1', @@ -61,7 +62,7 @@ function exampleState(): XYState { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -221,7 +222,7 @@ describe('xy_visualization', () => { ...exampleState().layers, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'e', xAccessor: 'f', @@ -239,7 +240,7 @@ describe('xy_visualization', () => { const layers = xyVisualization.appendLayer!( exampleState(), 'foo', - LayerTypes.DATA, + layerTypes.DATA, 'indexPattern1' ).layers; expect(layers.length).toEqual(exampleState().layers.length + 1); @@ -329,7 +330,7 @@ describe('xy_visualization', () => { describe('#getLayerType', () => { it('should return the type only if the layer is in the state', () => { - expect(xyVisualization.getLayerType('first', exampleState())).toEqual(LayerTypes.DATA); + expect(xyVisualization.getLayerType('first', exampleState())).toEqual(layerTypes.DATA); expect(xyVisualization.getLayerType('foo', exampleState())).toBeUndefined(); }); }); @@ -378,7 +379,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -391,7 +392,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'newCol', accessors: [], @@ -407,7 +408,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -420,7 +421,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'newCol', accessors: [], @@ -436,7 +437,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'referenceLine', - layerType: LayerTypes.REFERENCELINE, + layerType: layerTypes.REFERENCELINE, accessors: [], }, ], @@ -447,7 +448,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'referenceLine', - layerType: LayerTypes.REFERENCELINE, + layerType: layerTypes.REFERENCELINE, accessors: ['newCol'], yConfig: [ { @@ -468,7 +469,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, @@ -481,7 +482,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', ignoreGlobalFilters: true, annotations: [ @@ -717,7 +718,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, @@ -746,7 +747,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [ exampleAnnotation2, @@ -777,7 +778,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, @@ -806,7 +807,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [ { @@ -837,7 +838,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, @@ -863,7 +864,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2, { ...exampleAnnotation2, id: 'newColId' }], ignoreGlobalFilters: true, @@ -878,7 +879,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation, exampleAnnotation2], ignoreGlobalFilters: true, @@ -905,7 +906,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2, exampleAnnotation], ignoreGlobalFilters: true, @@ -921,14 +922,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, @@ -956,14 +957,14 @@ describe('xy_visualization', () => { ).toEqual([ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [{ ...exampleAnnotation, id: 'an2' }], ignoreGlobalFilters: true, @@ -979,14 +980,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, @@ -1014,14 +1015,14 @@ describe('xy_visualization', () => { ).toEqual([ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, @@ -1037,14 +1038,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, @@ -1071,14 +1072,14 @@ describe('xy_visualization', () => { ).toEqual([ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, @@ -1094,14 +1095,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [], ignoreGlobalFilters: true, @@ -1129,14 +1130,14 @@ describe('xy_visualization', () => { ).toEqual([ { layerId: 'first', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [], ignoreGlobalFilters: true, }, { layerId: 'second', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, @@ -1184,7 +1185,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -1196,7 +1197,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -1211,14 +1212,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'ann', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation, { ...exampleAnnotation, id: 'an2' }], ignoreGlobalFilters: true, @@ -1231,14 +1232,14 @@ describe('xy_visualization', () => { ).toEqual([ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'ann', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, @@ -1534,7 +1535,7 @@ describe('xy_visualization', () => { ...baseState.layers[0], accessors: ['e'], seriesType: 'bar_percentage_stacked', - layerType: LayerTypes.REFERENCELINE, + layerType: layerTypes.REFERENCELINE, }, ], ], @@ -1601,7 +1602,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: undefined, xAccessor: undefined, @@ -1609,7 +1610,7 @@ describe('xy_visualization', () => { }, { layerId: 'referenceLine', - layerType: LayerTypes.REFERENCELINE, + layerType: layerTypes.REFERENCELINE, accessors: [], yConfig: [{ axisMode: 'left', forAccessor: 'a' }], }, @@ -1957,7 +1958,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: undefined, xAccessor: 'a', @@ -1965,7 +1966,7 @@ describe('xy_visualization', () => { }, { layerId: 'annotations', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation], ignoreGlobalFilters: true, @@ -2189,7 +2190,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -2205,14 +2206,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -2228,14 +2229,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: ['a'], @@ -2252,7 +2253,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -2268,7 +2269,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -2276,7 +2277,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -2293,14 +2294,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: ['a'], @@ -2321,14 +2322,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -2336,7 +2337,7 @@ describe('xy_visualization', () => { }, { layerId: 'third', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -2358,21 +2359,21 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'third', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], @@ -2395,7 +2396,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -2443,7 +2444,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -2451,7 +2452,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'e', @@ -2499,7 +2500,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -2507,7 +2508,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'e', @@ -2677,7 +2678,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', - layerType: LayerTypes.DATA, + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['b'], @@ -2797,7 +2798,7 @@ describe('xy_visualization', () => { ...baseState.layers, { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, annotations: [exampleAnnotation2], ignoreGlobalFilters: true, }, @@ -2817,7 +2818,7 @@ describe('xy_visualization', () => { ...baseState.layers, { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, @@ -2836,7 +2837,7 @@ describe('xy_visualization', () => { ...baseState.layers, { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, annotations: [exampleAnnotation2], ignoreGlobalFilters: true, }, @@ -2856,7 +2857,7 @@ describe('xy_visualization', () => { ...baseState.layers, { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', annotations: [exampleAnnotation2], ignoreGlobalFilters: true, @@ -2866,33 +2867,29 @@ describe('xy_visualization', () => { }); }); - describe('getSupportedActionsForLayer', () => { + describe('layer actions', () => { it('should return no actions for a data layer', () => { - expect( - xyVisualization.getSupportedActionsForLayer?.('first', exampleState(), jest.fn()) - ).toHaveLength(0); + expect(xyVisualization.getSupportedActionsForLayer?.('first', exampleState())).toHaveLength( + 0 + ); }); it('should return one action for an annotation layer', () => { const baseState = exampleState(); expect( - xyVisualization.getSupportedActionsForLayer?.( - 'annotation', - { - ...baseState, - layers: [ - ...baseState.layers, - { - layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, - annotations: [exampleAnnotation2], - ignoreGlobalFilters: true, - indexPatternId: 'myIndexPattern', - }, - ], - }, - jest.fn() - ) + xyVisualization.getSupportedActionsForLayer?.('annotation', { + ...baseState, + layers: [ + ...baseState.layers, + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + indexPatternId: 'myIndexPattern', + }, + ], + }) ).toEqual([ expect.objectContaining({ displayName: 'Keep global filters', @@ -2905,34 +2902,34 @@ describe('xy_visualization', () => { ]); }); - it('should return an action that performs a state update on click', () => { + it('should handle an annotation action', () => { const baseState = exampleState(); - const setState = jest.fn(); - const [action] = xyVisualization.getSupportedActionsForLayer?.( + const state = { + ...baseState, + layers: [ + ...baseState.layers, + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + indexPatternId: 'myIndexPattern', + }, + ], + }; + + const newState = xyVisualization.onLayerAction!( 'annotation', - { - ...baseState, - layers: [ - ...baseState.layers, - { - layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, - annotations: [exampleAnnotation2], - ignoreGlobalFilters: true, - indexPatternId: 'myIndexPattern', - }, - ], - }, - setState - )!; - action.execute(); + KEEP_GLOBAL_FILTERS_ACTION_ID, + state + ); - expect(setState).toHaveBeenCalledWith( + expect(newState).toEqual( expect.objectContaining({ layers: expect.arrayContaining([ { layerId: 'annotation', - layerType: LayerTypes.ANNOTATIONS, + layerType: layerTypes.ANNOTATIONS, annotations: [exampleAnnotation2], ignoreGlobalFilters: false, indexPatternId: 'myIndexPattern', diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx index 45ec49bea847b..590c8a36a2f5e 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx @@ -43,6 +43,7 @@ import { type XYDataLayerConfig, type SeriesType, type PersistedState, + type XYAnnotationLayerConfig, visualizationTypes, } from './types'; import { @@ -94,7 +95,11 @@ import { AnnotationsPanel } from './xy_config_panel/annotations_config_panel'; import { DimensionTrigger } from '../../shared_components/dimension_trigger'; import { defaultAnnotationLabel } from './annotations/helpers'; import { onDropForVisualization } from '../../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils'; -import { createAnnotationActions } from './annotations/actions'; +import { + createAnnotationActions, + IGNORE_GLOBAL_FILTERS_ACTION_ID, + KEEP_GLOBAL_FILTERS_ACTION_ID, +} from './annotations/actions'; const XY_ID = 'lnsXY'; export const getXyVisualization = ({ @@ -249,16 +254,34 @@ export const getXyVisualization = ({ ]; }, - getSupportedActionsForLayer(layerId, state, setState) { + getSupportedActionsForLayer(layerId, state) { const layerIndex = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[layerIndex]; const actions = []; if (isAnnotationsLayer(layer)) { - actions.push(...createAnnotationActions({ state, layerIndex, layer, setState })); + actions.push(...createAnnotationActions({ state, layerIndex, layer })); } return actions; }, + onLayerAction(layerId, actionId, state) { + if ([IGNORE_GLOBAL_FILTERS_ACTION_ID, KEEP_GLOBAL_FILTERS_ACTION_ID].includes(actionId)) { + return { + ...state, + layers: state.layers.map((layer) => + layer.layerId === layerId + ? { + ...layer, + ignoreGlobalFilters: !(layer as XYAnnotationLayerConfig).ignoreGlobalFilters, + } + : layer + ), + }; + } + + return state; + }, + onIndexPatternChange(state, indexPatternId, layerId) { const layerIndex = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[layerIndex]; diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts index 42846e84377dd..aa4f634b700af 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -32,6 +32,7 @@ import { getLensDataViewMigrations, commonMigrateMetricIds, commonMigratePartitionChartGroups, + commonMigratePartitionMetrics, commonMigrateIndexPatternDatasource, } from '../migrations/common_migrations'; import { @@ -160,12 +161,15 @@ export const makeLensEmbeddableFactory = '8.6.0': (state) => { const lensState = state as unknown as SavedObject>; - const migratedLensState = commonMigrateIndexPatternDatasource(lensState.attributes); + let migratedLensState = commonMigrateIndexPatternDatasource(lensState.attributes); + migratedLensState = commonMigratePartitionMetrics(migratedLensState); return { ...lensState, attributes: migratedLensState, } as unknown as SerializableRecord; }, + // FOLLOW THESE GUIDELINES IF YOU ARE ADDING A NEW MIGRATION! + // 1. Make sure you are applying migrations for a given version in the same order here as they are applied in x-pack/plugins/lens/server/migrations/saved_object_migrations.ts }), getLensCustomVisualizationMigrations(customVisualizationMigrations) ), diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index b56f4b691911b..3559e79def68a 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -541,3 +541,32 @@ export const commonMigratePartitionChartGroups = ( layers: Array<{ primaryGroups?: string[]; secondaryGroups?: string[] }>; }>; }; + +export const commonMigratePartitionMetrics = (attributes: LensDocShape860) => { + if (attributes.visualizationType !== 'lnsPie') { + return attributes as LensDocShape860; + } + + const partitionAttributes = attributes as LensDocShape860<{ + shape: string; + layers: Array<{ metric: string }>; + }>; + + return { + ...attributes, + state: { + ...attributes.state, + visualization: { + ...partitionAttributes.state.visualization, + layers: partitionAttributes.state.visualization.layers.map((layer) => ({ + ...layer, + metrics: [layer.metric], + metric: undefined, + })), + }, + }, + } as LensDocShape860<{ + shape: string; + layers: Array<{ metrics: string[] }>; + }>; +}; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index 54504b9201f67..eec31924fdc85 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -2364,6 +2364,49 @@ describe('Lens migrations', () => { }); }); + describe('8.6.0 migrates partition metrics', () => { + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'some title', + description: '', + visualizationType: 'lnsPie', + state: { + visualization: { + layers: [ + { + metric: 'some-metric', + }, + ], + }, + datasourceStates: { + indexpattern: {}, + }, + }, + }, + } as unknown as SavedObjectUnsanitizedDoc; + + it('make metric an array', () => { + const result = migrations['8.6.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + expect( + (result.attributes.state.visualization as { layers: Array<{ metrics: string[] }> }) + .layers[0] + ).toMatchInlineSnapshot(` + Object { + "metric": undefined, + "metrics": Array [ + "some-metric", + ], + } + `); + }); + }); + describe('8.6.0 migrates indexpattern datasource', () => { const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 8e4914b94fe83..aa480c4c0b151 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -60,6 +60,7 @@ import { getLensDataViewMigrations, commonMigrateMetricIds, commonMigratePartitionChartGroups, + commonMigratePartitionMetrics, commonMigrateIndexPatternDatasource, } from './common_migrations'; @@ -554,6 +555,13 @@ const migratePartitionChartGroups: SavedObjectMigrationFn = ( + doc +) => ({ + ...doc, + attributes: commonMigratePartitionMetrics(doc.attributes), +}); + const lensMigrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -575,7 +583,9 @@ const lensMigrations: SavedObjectMigrationMap = { ), '8.3.0': flow(lockOldMetricVisSettings, preserveOldLegendSizeDefault, fixValueLabelsInXY), '8.5.0': flow(migrateMetricIds, enrichAnnotationLayers, migratePartitionChartGroups), - '8.6.0': flow(migrateIndexPatternDatasource), + '8.6.0': flow(migrateIndexPatternDatasource, migratePartitionMetrics), + // FOLLOW THESE GUIDELINES IF YOU ARE ADDING A NEW MIGRATION! + // 1. Make sure you are applying migrations for a given version in the same order here as they are applied in x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts }; export const getAllMigrations = ( diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index e9c3102e2aa85..1506a2569c273 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -65,7 +65,16 @@ const toTableListViewSavedObject = ( }; }; -async function findMaps(searchTerm: string, tagReferences?: SavedObjectsFindOptionsReference[]) { +async function findMaps( + searchTerm: string, + { + references, + referencesToExclude, + }: { + references?: SavedObjectsFindOptionsReference[]; + referencesToExclude?: SavedObjectsFindOptionsReference[]; + } = {} +) { const resp = await getSavedObjectsClient().find({ type: MAP_SAVED_OBJECT_TYPE, search: searchTerm ? `${searchTerm}*` : undefined, @@ -74,7 +83,8 @@ async function findMaps(searchTerm: string, tagReferences?: SavedObjectsFindOpti searchFields: ['title^3', 'description'], defaultSearchOperator: 'AND', fields: ['description', 'title'], - hasReference: tagReferences, + hasReference: references, + hasNoReference: referencesToExclude, }); return { diff --git a/x-pack/plugins/osquery/public/lens/view_results_in_lens.tsx b/x-pack/plugins/osquery/public/lens/view_results_in_lens.tsx index 247255d9ffba3..48fdd928f7aea 100644 --- a/x-pack/plugins/osquery/public/lens/view_results_in_lens.tsx +++ b/x-pack/plugins/osquery/public/lens/view_results_in_lens.tsx @@ -140,7 +140,7 @@ function getLensAttributes( legendDisplay: 'default', nestedLegend: false, layerId: 'layer1', - metric: 'ed999e9d-204c-465b-897f-fe1a125b39ed', + metrics: ['ed999e9d-204c-465b-897f-fe1a125b39ed'], numberDisplay: 'percent', primaryGroups: ['8690befd-fd69-4246-af4a-dd485d2a3b38'], categoryDisplay: 'default', diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index c9a323d1c4a5a..26ea480f26edc 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -114,7 +114,7 @@ function getLensAttributes( legendDisplay: 'default', nestedLegend: false, layerId: 'layer1', - metric: 'ed999e9d-204c-465b-897f-fe1a125b39ed', + metrics: ['ed999e9d-204c-465b-897f-fe1a125b39ed'], numberDisplay: 'percent', primaryGroups: ['8690befd-fd69-4246-af4a-dd485d2a3b38'], categoryDisplay: 'default', diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx index d18e224b96932..cfe67c5ed0827 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx @@ -13,17 +13,22 @@ import { TagBadge } from './tag_badge'; export interface TagListProps { tags: TagWithOptionalId[]; onClick?: (tag: TagWithOptionalId) => void; + tagRender?: (tag: TagWithOptionalId) => JSX.Element; } /** * Displays a list of tag */ -export const TagList: FC = ({ tags, onClick }) => { +export const TagList: FC = ({ tags, onClick, tagRender }) => { return ( - {tags.map((tag) => ( - - ))} + {tags.map((tag) => + tagRender ? ( + {tagRender(tag)} + ) : ( + + ) + )} ); }; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx index 2ef49512340ac..46deb57db7f40 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx @@ -19,16 +19,22 @@ interface SavedObjectTagListProps { object: { references: SavedObjectReference[] }; tags: Tag[]; onClick?: (tag: TagWithOptionalId) => void; + tagRender?: (tag: TagWithOptionalId) => JSX.Element; } -const SavedObjectTagList: FC = ({ object, tags: allTags, onClick }) => { +const SavedObjectTagList: FC = ({ + object, + tags: allTags, + onClick, + tagRender, +}) => { const objectTags = useMemo(() => { const { tags } = getObjectTags(object, allTags); tags.sort(byNameTagSorter); return tags; }, [object, allTags]); - return ; + return ; }; interface GetConnectedTagListOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts index 96a498580c2e4..e5216ea209177 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts @@ -20,10 +20,12 @@ const expectTagOption = (tag: Tag, useName: boolean) => ({ describe('getSearchBarFilter', () => { let cache: ReturnType; let getSearchBarFilter: SavedObjectsTaggingApiUi['getSearchBarFilter']; + let getTagList: () => Tag[]; beforeEach(() => { cache = tagsCacheMock.create(); - getSearchBarFilter = buildGetSearchBarFilter({ cache }); + getTagList = () => cache.getState(); + getSearchBarFilter = buildGetSearchBarFilter({ getTagList }); }); it('has the correct base configuration', () => { @@ -59,20 +61,6 @@ describe('getSearchBarFilter', () => { expect(fetched).toEqual(tags.map((tag) => expectTagOption(tag, true))); }); - it('sorts the tags by name', async () => { - const tag1 = createTag({ id: 'id-1', name: 'aaa' }); - const tag2 = createTag({ id: 'id-2', name: 'ccc' }); - const tag3 = createTag({ id: 'id-3', name: 'bbb' }); - - cache.getState.mockReturnValue([tag1, tag2, tag3]); - - // EUI types for filters are incomplete - const { options } = getSearchBarFilter() as any; - - const fetched = await options(); - expect(fetched).toEqual([tag1, tag3, tag2].map((tag) => expectTagOption(tag, true))); - }); - it('uses the `useName` option', async () => { const tags = [ createTag({ id: 'id-1', name: 'name-1' }), diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx index 5ce3a8fd8b731..25e674bbf39d2 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx @@ -11,16 +11,16 @@ import { SavedObjectsTaggingApiUi, GetSearchBarFilterOptions, } from '@kbn/saved-objects-tagging-oss-plugin/public'; -import { ITagsCache } from '../services'; + +import { Tag } from '../../common'; import { TagSearchBarOption } from '../components'; -import { byNameTagSorter } from '../utils'; export interface BuildGetSearchBarFilterOptions { - cache: ITagsCache; + getTagList: () => Tag[]; } export const buildGetSearchBarFilter = ({ - cache, + getTagList, }: BuildGetSearchBarFilterOptions): SavedObjectsTaggingApiUi['getSearchBarFilter'] => { return ({ useName = true, tagField = 'tag' }: GetSearchBarFilterOptions = {}) => { return { @@ -35,16 +35,13 @@ export const buildGetSearchBarFilter = ({ // everytime the filter is opened. That way we can keep in sync in case of tags // that would be added without the searchbar having trigger a re-render. return Promise.resolve( - cache - .getState() - .sort(byNameTagSorter) - .map((tag) => { - return { - value: useName ? tag.name : tag.id, - name: tag.name, - view: , - }; - }) + getTagList().map((tag) => { + return { + value: useName ? tag.name : tag.id, + name: tag.name, + view: , + }; + }) ); }, }; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts new file mode 100644 index 0000000000000..3821d3a4bc313 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createTag } from '../../common/test_utils'; +import { tagsCacheMock } from '../services/tags/tags_cache.mock'; +import { buildGetTagList } from './get_tag_list'; + +describe('getTagList', () => { + it('sorts the tags by name', async () => { + const tag1 = createTag({ id: 'id-1', name: 'aaa' }); + const tag2 = createTag({ id: 'id-2', name: 'ccc' }); + const tag3 = createTag({ id: 'id-3', name: 'bbb' }); + + const cache = tagsCacheMock.create(); + cache.getState.mockReturnValue([tag1, tag2, tag3]); + + const getTagList = buildGetTagList(cache); + + const tags = getTagList(); + expect(tags).toEqual([tag1, tag3, tag2]); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.ts new file mode 100644 index 0000000000000..6fc6e7cd51df9 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.ts @@ -0,0 +1,11 @@ +/* + * 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 { byNameTagSorter } from '../utils'; +import type { ITagsCache } from '../services'; + +export const buildGetTagList = (cache: ITagsCache) => () => cache.getState().sort(byNameTagSorter); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 6b4eddf357478..8d53135f3f55a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -20,6 +20,7 @@ import { buildGetTableColumnDefinition } from './get_table_column_definition'; import { buildGetSearchBarFilter } from './get_search_bar_filter'; import { buildParseSearchQuery } from './parse_search_query'; import { buildConvertNameToReference } from './convert_name_to_reference'; +import { buildGetTagList } from './get_tag_list'; import { hasTagDecoration } from './has_tag_decoration'; interface GetUiApiOptions { @@ -39,10 +40,12 @@ export const getUiApi = ({ }: GetUiApiOptions): SavedObjectsTaggingApiUi => { const components = getComponents({ cache, capabilities, overlays, theme, tagClient: client }); + const getTagList = buildGetTagList(cache); + return { components, getTableColumnDefinition: buildGetTableColumnDefinition({ components, cache }), - getSearchBarFilter: buildGetSearchBarFilter({ cache }), + getSearchBarFilter: buildGetSearchBarFilter({ getTagList }), parseSearchQuery: buildParseSearchQuery({ cache }), convertNameToReference: buildConvertNameToReference({ cache }), hasTagDecoration, @@ -50,5 +53,6 @@ export const getUiApi = ({ getTagIdFromName: (tagName: string) => convertTagNameToId(tagName, cache.getState()), updateTagsReferences, getTag: (tagId: string) => getTag(tagId, cache.getState()), + getTagList, }; }; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts index 02de2673885e3..15e2349af47dc 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts @@ -38,6 +38,7 @@ describe('parseSearchQuery', () => { expect(parseSearchQuery(searchTerm)).toEqual({ searchTerm, tagReferences: [], + tagReferencesToExclude: [], valid: true, }); }); @@ -48,6 +49,7 @@ describe('parseSearchQuery', () => { expect(parseSearchQuery(searchTerm)).toEqual({ searchTerm, tagReferences: [], + tagReferencesToExclude: [], valid: false, }); }); @@ -58,6 +60,18 @@ describe('parseSearchQuery', () => { expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1'), tagRef('id-2')], + tagReferencesToExclude: [], + valid: true, + }); + }); + + it('returns the tag references to exclude matching the tag field clause when using `useName: false`', () => { + const searchTerm = '-tag:(id-1 OR id-2) my search term'; + + expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({ + searchTerm: 'my search term', + tagReferences: [], + tagReferencesToExclude: [tagRef('id-1'), tagRef('id-2')], valid: true, }); }); @@ -68,6 +82,18 @@ describe('parseSearchQuery', () => { expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1'), tagRef('id-2')], + tagReferencesToExclude: [], + valid: true, + }); + }); + + it('returns the tag references to exclude matching the tag field clause when using `useName: true`', () => { + const searchTerm = '-tag:(name-1 OR name-2) my search term'; + + expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ + searchTerm: 'my search term', + tagReferences: [], + tagReferencesToExclude: [tagRef('id-1'), tagRef('id-2')], valid: true, }); }); @@ -78,6 +104,7 @@ describe('parseSearchQuery', () => { expect(parseSearchQuery(searchTerm, { tagField: 'custom' })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1'), tagRef('id-2')], + tagReferencesToExclude: [], valid: true, }); }); @@ -88,6 +115,7 @@ describe('parseSearchQuery', () => { expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1')], + tagReferencesToExclude: [], valid: true, }); }); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts index a5ac82f8c9821..8f22fcea3f782 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts @@ -29,6 +29,7 @@ export const buildParseSearchQuery = ({ return { searchTerm: query, tagReferences: [], + tagReferencesToExclude: [], valid: false, }; } @@ -39,12 +40,12 @@ export const buildParseSearchQuery = ({ return { searchTerm: '', tagReferences: [], + tagReferencesToExclude: [], valid: true, }; } let searchTerm: string = ''; - let tagReferences: SavedObjectsFindOptionsReference[] = []; if (parsed.ast.getTermClauses().length) { searchTerm = parsed.ast @@ -52,26 +53,52 @@ export const buildParseSearchQuery = ({ .map((clause: any) => clause.value) .join(' '); } + + let tagReferences: SavedObjectsFindOptionsReference[] = []; + let tagReferencesToExclude: SavedObjectsFindOptionsReference[] = []; + if (parsed.ast.getFieldClauses(tagField)) { - const selectedTags = parsed.ast.getFieldClauses(tagField)[0].value as string[]; - if (useName) { - selectedTags.forEach((tagName) => { - const found = cache.getState().find((tag) => tag.name === tagName); - if (found) { - tagReferences.push({ - type: 'tag', - id: found.id, - }); + // The query can have clauses that either *must* match or *must_not* match + // We will retrieve the list of name for both list and convert them to references + const { selectedTags, excludedTags } = parsed.ast.getFieldClauses(tagField).reduce( + (acc, clause) => { + if (clause.match === 'must') { + acc.selectedTags = clause.value as string[]; + } else if (clause.match === 'must_not') { + acc.excludedTags = clause.value as string[]; } - }); - } else { - tagReferences = selectedTags.map((tagId) => ({ type: 'tag', id: tagId })); - } + + return acc; + }, + { selectedTags: [], excludedTags: [] } as { selectedTags: string[]; excludedTags: string[] } + ); + + const tagsToReferences = (tagNames: string[]) => { + if (useName) { + const references: SavedObjectsFindOptionsReference[] = []; + tagNames.forEach((tagName) => { + const found = cache.getState().find((tag) => tag.name === tagName); + if (found) { + references.push({ + type: 'tag', + id: found.id, + }); + } + }); + return references; + } else { + return tagNames.map((tagId) => ({ type: 'tag', id: tagId })); + } + }; + + tagReferences = tagsToReferences(selectedTags); + tagReferencesToExclude = tagsToReferences(excludedTags); } return { searchTerm, tagReferences, + tagReferencesToExclude, valid: true, }; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx index a43036e2d8a14..ee139ced9181c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx @@ -84,15 +84,13 @@ export const useDetailPanel = ({ const loadDetailsPanel = useCallback( (panelConfig?: TimelineExpandedDetailType) => { if (panelConfig && scopedActions) { - if (isTimelineScope(scopeId)) { - dispatch( - scopedActions.toggleDetailPanel({ - ...panelConfig, - tabType, - id: scopeId, - }) - ); - } + dispatch( + scopedActions.toggleDetailPanel({ + ...panelConfig, + tabType, + id: scopeId, + }) + ); } }, [scopedActions, scopeId, dispatch, tabType] diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts index 27dd32f5d48f1..71e96e4c82c3f 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/configuration.ts @@ -6,13 +6,14 @@ */ import type { Logger } from '@kbn/core/server'; +import { TASK_METRICS_CHANNEL } from '../constants'; import type { ITelemetryEventsSender } from '../sender'; import type { TelemetryConfiguration } from '../types'; import type { ITelemetryReceiver } from '../receiver'; import type { TaskExecutionPeriod } from '../task'; import { artifactService } from '../artifact'; import { telemetryConfiguration } from '../configuration'; -import { tlog } from '../helpers'; +import { createTaskMetric, tlog } from '../helpers'; export function createTelemetryConfigurationTaskConfig() { return { @@ -28,6 +29,8 @@ export function createTelemetryConfigurationTaskConfig() { sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { + const startTime = Date.now(); + const taskName = 'Security Solution Telemetry Configuration Task'; try { const artifactName = 'telemetry-buffer-and-batch-sizes-v1'; const configArtifact = (await artifactService.getArtifact( @@ -42,10 +45,16 @@ export function createTelemetryConfigurationTaskConfig() { configArtifact.max_endpoint_telemetry_batch; telemetryConfiguration.max_security_list_telemetry_batch = configArtifact.max_security_list_telemetry_batch; + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); return 0; } catch (err) { tlog(logger, `Failed to set telemetry configuration due to ${err.message}`); telemetryConfiguration.resetAllToDefault(); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); return 0; } }, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts index 67a6c4d270b6f..3919f3c8c7f4c 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/filterlists.ts @@ -6,13 +6,14 @@ */ import type { Logger } from '@kbn/core/server'; +import { TASK_METRICS_CHANNEL } from '../constants'; import type { ITelemetryEventsSender } from '../sender'; import type { TelemetryFilterListArtifact } from '../types'; import type { ITelemetryReceiver } from '../receiver'; import type { TaskExecutionPeriod } from '../task'; import { artifactService } from '../artifact'; import { filterList } from '../filterlists'; -import { tlog } from '../helpers'; +import { createTaskMetric, tlog } from '../helpers'; export function createTelemetryFilterListArtifactTaskConfig() { return { @@ -28,6 +29,8 @@ export function createTelemetryFilterListArtifactTaskConfig() { sender: ITelemetryEventsSender, taskExecutionPeriod: TaskExecutionPeriod ) => { + const startTime = Date.now(); + const taskName = 'Security Solution Telemetry Filter List Artifact Task'; try { const artifactName = 'telemetry-filterlists-v1'; const artifact = (await artifactService.getArtifact( @@ -36,10 +39,16 @@ export function createTelemetryFilterListArtifactTaskConfig() { filterList.endpointAlerts = artifact.endpoint_alerts; filterList.exceptionLists = artifact.exception_lists; filterList.prebuiltRulesAlerts = artifact.prebuilt_rules_alerts; + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, true, startTime), + ]); return 0; } catch (err) { tlog(logger, `Failed to set telemetry filterlist artifact due to ${err.message}`); filterList.resetAllToDefault(); + await sender.sendOnDemand(TASK_METRICS_CHANNEL, [ + createTaskMetric(taskName, false, startTime, err.message), + ]); return 0; } }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index fb5af35a6fb33..67ed617822058 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -9855,7 +9855,6 @@ "xpack.csp.cspSettings.rules": "Règles de sécurité du CSP - ", "xpack.csp.dashboard.cspPageTemplate.pageTitle": "Niveau du cloud", "xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "Section CIS", - "xpack.csp.dashboard.risksTable.findingsColumnLabel": "Résultats", "xpack.csp.dashboard.risksTable.viewAllButtonTitle": "Afficher tous les échecs des résultats", "xpack.csp.dashboard.summarySection.cloudPostureScorePanelTitle": "Score du niveau du cloud", "xpack.csp.expandColumnDescriptionLabel": "Développer", @@ -17846,7 +17845,6 @@ "xpack.lens.pie.addLayer": "Visualisation", "xpack.lens.pie.donutLabel": "Graphique en anneau", "xpack.lens.pie.groupLabel": "Proportion", - "xpack.lens.pie.groupsizeLabel": "Taille par", "xpack.lens.pie.mosaiclabel": "Mosaïque", "xpack.lens.pie.mosaicSuggestionLabel": "En mosaïque", "xpack.lens.pie.pielabel": "Camembert", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b792335f133ef..7a2cbd5929707 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9842,7 +9842,6 @@ "xpack.csp.cspSettings.rules": "CSPセキュリティルール - ", "xpack.csp.dashboard.cspPageTemplate.pageTitle": "クラウド態勢", "xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "CISセクション", - "xpack.csp.dashboard.risksTable.findingsColumnLabel": "調査結果", "xpack.csp.dashboard.risksTable.viewAllButtonTitle": "すべてのフィールド調査結果を表示", "xpack.csp.dashboard.summarySection.cloudPostureScorePanelTitle": "クラウド態勢スコア", "xpack.csp.expandColumnDescriptionLabel": "拡張", @@ -17829,7 +17828,6 @@ "xpack.lens.pie.addLayer": "ビジュアライゼーション", "xpack.lens.pie.donutLabel": "ドーナッツ", "xpack.lens.pie.groupLabel": "比率", - "xpack.lens.pie.groupsizeLabel": "サイズ単位", "xpack.lens.pie.mosaiclabel": "モザイク", "xpack.lens.pie.mosaicSuggestionLabel": "モザイクとして", "xpack.lens.pie.pielabel": "円", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f8ab94422d563..af00cf9497094 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9860,7 +9860,6 @@ "xpack.csp.cspSettings.rules": "CSP 安全规则 - ", "xpack.csp.dashboard.cspPageTemplate.pageTitle": "云态势", "xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "CIS 部分", - "xpack.csp.dashboard.risksTable.findingsColumnLabel": "结果", "xpack.csp.dashboard.risksTable.viewAllButtonTitle": "查看所有失败的结果", "xpack.csp.dashboard.summarySection.cloudPostureScorePanelTitle": "云态势分数", "xpack.csp.expandColumnDescriptionLabel": "展开", @@ -17854,7 +17853,6 @@ "xpack.lens.pie.addLayer": "可视化", "xpack.lens.pie.donutLabel": "圆环图", "xpack.lens.pie.groupLabel": "比例", - "xpack.lens.pie.groupsizeLabel": "大小调整依据", "xpack.lens.pie.mosaiclabel": "马赛克", "xpack.lens.pie.mosaicSuggestionLabel": "为马赛克", "xpack.lens.pie.pielabel": "饼图", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/mock/rule_details/alert_summary/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/mock/rule_details/alert_summary/index.ts index 2d5af068ae55d..0c00abae8d855 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/mock/rule_details/alert_summary/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/mock/rule_details/alert_summary/index.ts @@ -33,7 +33,7 @@ export const mockRule = (): Rule => { lastExecutionDate: new Date('2020-08-20T19:23:38Z'), }, monitoring: { - execution: { + run: { history: [ { success: true, @@ -55,7 +55,13 @@ export const mockRule = (): Rule => { success_ratio: 0.66, p50: 200000, p95: 300000, - p99: 300000, + p99: 390000, + }, + last_run: { + timestamp: '2020-08-20T19:23:38Z', + metrics: { + duration: 500, + }, }, }, }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx index 5166642eaabba..6f215e139adf0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx @@ -221,7 +221,7 @@ function mockRule(overwrite = {}): Rule { lastExecutionDate: new Date('2020-08-20T19:23:38Z'), }, monitoring: { - execution: { + run: { history: [ { success: true, @@ -245,6 +245,12 @@ function mockRule(overwrite = {}): Rule { p95: 300000, p99: 300000, }, + last_run: { + timestamp: '2020-08-20T19:23:38Z', + metrics: { + duration: 500, + }, + }, }, }, ...overwrite, diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/__snapshots__/visitor_breakdown_chart.test.tsx.snap b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/__snapshots__/visitor_breakdown_chart.test.tsx.snap index 1e7d3ca13e43c..ad309f99bf04c 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/__snapshots__/visitor_breakdown_chart.test.tsx.snap +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/__snapshots__/visitor_breakdown_chart.test.tsx.snap @@ -112,7 +112,9 @@ Object { "layerId": "layer1", "layerType": "data", "legendDisplay": "hide", - "metric": "col2", + "metrics": Array [ + "col2", + ], "nestedLegend": false, "numberDisplay": "percent", "primaryGroups": Array [ diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/visitor_breakdown_chart.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/visitor_breakdown_chart.tsx index e20e9b1f89f7e..87c3779365677 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/visitor_breakdown_chart.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/visitor_breakdown_chart.tsx @@ -105,7 +105,7 @@ const visConfig: PieVisualizationState = { { layerId: 'layer1', primaryGroups: ['col1'], - metric: 'col2', + metrics: ['col2'], categoryDisplay: 'default', legendDisplay: 'hide', numberDisplay: 'percent', diff --git a/x-pack/test/alerting_api_integration/common/lib/wait_for_execution_count.ts b/x-pack/test/alerting_api_integration/common/lib/wait_for_execution_count.ts index 76d8a9afb7098..6ad0dab431d81 100644 --- a/x-pack/test/alerting_api_integration/common/lib/wait_for_execution_count.ts +++ b/x-pack/test/alerting_api_integration/common/lib/wait_for_execution_count.ts @@ -28,13 +28,13 @@ export function createWaitForExecutionCount( const prefix = spaceId ? getUrlPrefix(spaceId) : ''; const getResponse = await st.get(`${prefix}/internal/alerting/rule/${id}`); expect(getResponse.status).to.eql(200); - if (getResponse.body.monitoring.execution.history.length >= count) { + if (getResponse.body.monitoring.run.history.length >= count) { attempts = 0; return true; } // eslint-disable-next-line no-console console.log( - `found ${getResponse.body.monitoring.execution.history.length} and looking for ${count}, waiting 3s then retrying` + `found ${getResponse.body.monitoring.run.history.length} and looking for ${count}, waiting 3s then retrying` ); await delay(delayMs); return waitForExecutionCount(count, id); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts index f775b3607fade..cebfe68e279b0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/create.ts @@ -124,10 +124,15 @@ export default function createAlertTests({ getService }: FtrProviderContext) { mute_all: false, muted_alert_ids: [], execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(typeof response.body.scheduled_task_id).to.be('string'); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } const taskRecord = await getScheduledTask(response.body.scheduled_task_id); expect(taskRecord.type).to.eql('task'); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts index 484d2c9cdac82..b8460e84202c9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts @@ -84,6 +84,8 @@ const findTestUtils = ( mute_all: false, muted_alert_ids: [], execution_status: match.execution_status, + ...(match.next_run ? { next_run: match.next_run } : {}), + ...(match.last_run ? { last_run: match.last_run } : {}), ...(describeType === 'internal' ? { monitoring: match.monitoring, @@ -291,6 +293,8 @@ const findTestUtils = ( created_at: match.created_at, updated_at: match.updated_at, execution_status: match.execution_status, + ...(match.next_run ? { next_run: match.next_run } : {}), + ...(match.last_run ? { last_run: match.last_run } : {}), ...(describeType === 'internal' ? { monitoring: match.monitoring, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts index 4b2afe01d7a86..b0900f74993cb 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts @@ -81,6 +81,8 @@ const getTestUtils = ( mute_all: false, muted_alert_ids: [], execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), ...(describeType === 'internal' ? { monitoring: response.body.monitoring, @@ -91,6 +93,9 @@ const getTestUtils = ( }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } break; default: throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts index c49fa62c606b6..430d69274041a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts @@ -132,12 +132,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan( Date.parse(response.body.created_at) ); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } // Ensure AAD isn't broken await checkAAD({ supertest, @@ -216,12 +221,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan( Date.parse(response.body.created_at) ); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } // Ensure AAD isn't broken await checkAAD({ supertest, @@ -311,12 +321,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan( Date.parse(response.body.created_at) ); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } // Ensure AAD isn't broken await checkAAD({ supertest, @@ -406,12 +421,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan( Date.parse(response.body.created_at) ); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } // Ensure AAD isn't broken await checkAAD({ supertest, @@ -499,12 +519,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan( Date.parse(response.body.created_at) ); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } // Ensure AAD isn't broken await checkAAD({ supertest, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts index 4424175e36953..ff24b25d89fa2 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts @@ -37,6 +37,11 @@ export default function createAggregateTests({ getService }: FtrProviderContext) unknown: 0, warning: 0, }, + rule_last_run_outcome: { + succeeded: 0, + warning: 0, + failed: 0, + }, rule_muted_status: { muted: 0, unmuted: 0, @@ -116,6 +121,11 @@ export default function createAggregateTests({ getService }: FtrProviderContext) unknown: 0, warning: 0, }, + rule_last_run_outcome: { + succeeded: 5, + warning: 0, + failed: 2, + }, rule_muted_status: { muted: 0, unmuted: 7, @@ -200,6 +210,11 @@ export default function createAggregateTests({ getService }: FtrProviderContext) disabled: 0, enabled: 7, }, + ruleLastRunOutcome: { + succeeded: 5, + warning: 0, + failed: 2, + }, ruleMutedStatus: { muted: 0, unmuted: 7, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index 7860bf15dc8e5..5cc7316f1c6e3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -94,11 +94,15 @@ export default function createAlertTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.eql(Date.parse(response.body.created_at)); - + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } expect(typeof response.body.scheduled_task_id).to.be('string'); const taskRecord = await getScheduledTask(response.body.scheduled_task_id); expect(taskRecord.type).to.eql('task'); @@ -190,8 +194,14 @@ export default function createAlertTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } + const esResponse = await es.get>( { index: '.kibana', @@ -485,11 +495,15 @@ export default function createAlertTests({ getService }: FtrProviderContext) { createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, executionStatus: response.body.executionStatus, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); expect(Date.parse(response.body.updatedAt)).to.eql(Date.parse(response.body.createdAt)); - + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } expect(typeof response.body.scheduledTaskId).to.be('string'); const taskRecord = await getScheduledTask(response.body.scheduledTaskId); expect(taskRecord.type).to.eql('task'); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts index 23dcc1abaea44..d10518aca575f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts @@ -75,6 +75,8 @@ const findTestUtils = ( created_at: match.created_at, updated_at: match.updated_at, execution_status: match.execution_status, + ...(match.next_run ? { next_run: match.next_run } : {}), + ...(match.last_run ? { last_run: match.last_run } : {}), ...(describeType === 'internal' ? { monitoring: match.monitoring, @@ -142,13 +144,13 @@ const findTestUtils = ( const response = await supertest.get( `${getUrlPrefix(Spaces.space1.id)}/${ describeType === 'public' ? 'api' : 'internal' - }/alerting/rules/_find?filter=alert.attributes.monitoring.execution.calculated_metrics.success_ratio>50` + }/alerting/rules/_find?filter=alert.attributes.monitoring.run.calculated_metrics.success_ratio>50` ); expect(response.status).to.eql(describeType === 'internal' ? 200 : 400); if (describeType === 'public') { expect(response.body.message).to.eql( - 'Error find rules: Filter is not supported on this field alert.attributes.monitoring.execution.calculated_metrics.success_ratio' + 'Error find rules: Filter is not supported on this field alert.attributes.monitoring.run.calculated_metrics.success_ratio' ); } }); @@ -159,13 +161,13 @@ const findTestUtils = ( const response = await supertest.get( `${getUrlPrefix(Spaces.space1.id)}/${ describeType === 'public' ? 'api' : 'internal' - }/alerting/rules/_find?sort_field=monitoring.execution.calculated_metrics.success_ratio` + }/alerting/rules/_find?sort_field=monitoring.run.calculated_metrics.success_ratio` ); expect(response.status).to.eql(describeType === 'internal' ? 200 : 400); if (describeType === 'public') { expect(response.body.message).to.eql( - 'Error find rules: Sort is not supported on this field monitoring.execution.calculated_metrics.success_ratio' + 'Error find rules: Sort is not supported on this field monitoring.run.calculated_metrics.success_ratio' ); } }); @@ -176,13 +178,13 @@ const findTestUtils = ( const response = await supertest.get( `${getUrlPrefix(Spaces.space1.id)}/${ describeType === 'public' ? 'api' : 'internal' - }/alerting/rules/_find?search_fields=monitoring.execution.calculated_metrics.success_ratio&search=50` + }/alerting/rules/_find?search_fields=monitoring.run.calculated_metrics.success_ratio&search=50` ); expect(response.status).to.eql(describeType === 'internal' ? 200 : 400); if (describeType === 'public') { expect(response.body.message).to.eql( - 'Error find rules: Search field monitoring.execution.calculated_metrics.success_ratio not supported' + 'Error find rules: Search field monitoring.run.calculated_metrics.success_ratio not supported' ); } }); @@ -325,6 +327,8 @@ export default function createFindTests({ getService }: FtrProviderContext) { createdAt: match.createdAt, updatedAt: match.updatedAt, executionStatus: match.executionStatus, + ...(match.nextRun ? { nextRun: match.nextRun } : {}), + ...(match.lastRun ? { lastRun: match.lastRun } : {}), }); expect(Date.parse(match.createdAt)).to.be.greaterThan(0); expect(Date.parse(match.updatedAt)).to.be.greaterThan(0); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts index 7a94198c4ea57..c91467f698dc1 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts @@ -54,6 +54,8 @@ const getTestUtils = ( created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), ...(describeType === 'internal' ? { monitoring: response.body.monitoring, @@ -64,6 +66,9 @@ const getTestUtils = ( }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } }); it(`shouldn't find alert from another space`, async () => { @@ -149,9 +154,14 @@ export default function createGetTests({ getService }: FtrProviderContext) { createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, executionStatus: response.body.executionStatus, + ...(response.body.nextRun ? { nextRun: response.body.nextRun } : {}), + ...(response.body.lastRun ? { lastRun: response.body.lastRun } : {}), }); expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); + if (response.body.nextRun) { + expect(Date.parse(response.body.nextRun)).to.be.greaterThan(0); + } }); }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index 9edb414e39c77..7765c266f5a74 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -521,5 +521,65 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(response.statusCode).to.eql(200); expect(response.body._source?.alert?.params?.esQuery).to.eql('{"query":}'); }); + + it('8.6.0 migrates executionStatus and monitoring', async () => { + const response = await es.get<{ alert: RawRule }>( + { + index: '.kibana', + id: 'alert:8370ffd2-f2db-49dc-9741-92c657189b9b', + }, + { meta: true } + ); + const alert = response.body._source?.alert; + + expect(alert?.monitoring).to.eql({ + run: { + history: [ + { + duration: 60000, + success: true, + timestamp: '2022-08-24T19:05:49.817Z', + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 60000, + p99: 60000, + }, + last_run: { + timestamp: '2022-08-24T19:05:49.817Z', + metrics: { + duration: 60000, + }, + }, + }, + }); + + expect(alert?.lastRun).to.eql({ + outcome: 'succeeded', + outcomeMsg: null, + warning: null, + alertsCount: {}, + }); + + expect(alert?.nextRun).to.eql(undefined); + }); + + it('8.6 migrates executionStatus warnings and errors', async () => { + const response = await es.get<{ alert: RawRule }>( + { + index: '.kibana', + id: 'alert:c87707ac-7328-47f7-b212-2cb40a4fc9b9', + }, + { meta: true } + ); + + const alert = response.body._source?.alert; + + expect(alert?.lastRun?.outcome).to.eql('warning'); + expect(alert?.lastRun?.warning).to.eql('warning reason'); + expect(alert?.lastRun?.outcomeMsg).to.eql('warning message'); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/monitoring.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/monitoring.ts index c08a28b3c3ca3..38506eb54a4bc 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/monitoring.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/monitoring.ts @@ -35,9 +35,9 @@ export default function monitoringAlertTests({ getService }: FtrProviderContext) ); expect(getResponse.status).to.eql(200); - expect(getResponse.body.monitoring.execution.history.length).to.be(1); - expect(getResponse.body.monitoring.execution.history[0].success).to.be(true); - expect(getResponse.body.monitoring.execution.calculated_metrics.success_ratio).to.be(1); + expect(getResponse.body.monitoring.run.history.length).to.be(1); + expect(getResponse.body.monitoring.run.history[0].success).to.be(true); + expect(getResponse.body.monitoring.run.calculated_metrics.success_ratio).to.be(1); }); it('should return an accurate history for multiple success', async () => { @@ -56,11 +56,11 @@ export default function monitoringAlertTests({ getService }: FtrProviderContext) ); expect(getResponse.status).to.eql(200); - expect(getResponse.body.monitoring.execution.history.length).to.be(3); - expect(getResponse.body.monitoring.execution.history[0].success).to.be(true); - expect(getResponse.body.monitoring.execution.history[1].success).to.be(true); - expect(getResponse.body.monitoring.execution.history[2].success).to.be(true); - expect(getResponse.body.monitoring.execution.calculated_metrics.success_ratio).to.be(1); + expect(getResponse.body.monitoring.run.history.length).to.be(3); + expect(getResponse.body.monitoring.run.history[0].success).to.be(true); + expect(getResponse.body.monitoring.run.history[1].success).to.be(true); + expect(getResponse.body.monitoring.run.history[2].success).to.be(true); + expect(getResponse.body.monitoring.run.calculated_metrics.success_ratio).to.be(1); }); it('should return an accurate history for some successes and some failures', async () => { @@ -88,13 +88,13 @@ export default function monitoringAlertTests({ getService }: FtrProviderContext) ); expect(getResponse.status).to.eql(200); - expect(getResponse.body.monitoring.execution.history.length).to.be(5); - expect(getResponse.body.monitoring.execution.history[0].success).to.be(true); - expect(getResponse.body.monitoring.execution.history[1].success).to.be(true); - expect(getResponse.body.monitoring.execution.history[2].success).to.be(true); - expect(getResponse.body.monitoring.execution.history[3].success).to.be(false); - expect(getResponse.body.monitoring.execution.history[4].success).to.be(false); - expect(getResponse.body.monitoring.execution.calculated_metrics.success_ratio).to.be(0.6); + expect(getResponse.body.monitoring.run.history.length).to.be(5); + expect(getResponse.body.monitoring.run.history[0].success).to.be(true); + expect(getResponse.body.monitoring.run.history[1].success).to.be(true); + expect(getResponse.body.monitoring.run.history[2].success).to.be(true); + expect(getResponse.body.monitoring.run.history[3].success).to.be(false); + expect(getResponse.body.monitoring.run.history[4].success).to.be(false); + expect(getResponse.body.monitoring.run.calculated_metrics.success_ratio).to.be(0.6); }); it('should populate rule objects with the calculated percentiles', async () => { @@ -118,7 +118,7 @@ export default function monitoringAlertTests({ getService }: FtrProviderContext) ); expect(getResponse.status).to.eql(200); - getResponse.body.monitoring.execution.history.forEach((history: any) => { + getResponse.body.monitoring.run.history.forEach((history: any) => { expect(history.duration).to.be.a('number'); }); }); @@ -135,13 +135,13 @@ export default function monitoringAlertTests({ getService }: FtrProviderContext) `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${id}` ); expect(getResponse.status).to.eql(200); - if (getResponse.body.monitoring.execution.history.length >= count) { + if (getResponse.body.monitoring.run.history.length >= count) { attempts = 0; return true; } // eslint-disable-next-line no-console console.log( - `found ${getResponse.body.monitoring.execution.history.length} and looking for ${count}, waiting 3s then retrying` + `found ${getResponse.body.monitoring.run.history.length} and looking for ${count}, waiting 3s then retrying` ); await delay(3000); return waitForExecutionCount(count, id); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index c5a9c93d45e81..4c740b3be9b97 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -63,12 +63,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { created_at: response.body.created_at, updated_at: response.body.updated_at, execution_status: response.body.execution_status, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), }); expect(Date.parse(response.body.created_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan(0); expect(Date.parse(response.body.updated_at)).to.be.greaterThan( Date.parse(response.body.created_at) ); + if (response.body.next_run) { + expect(Date.parse(response.body.next_run)).to.be.greaterThan(0); + } response = await supertest.get( `${getUrlPrefix( @@ -163,12 +168,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, executionStatus: response.body.executionStatus, + ...(response.body.nextRun ? { nextRun: response.body.nextRun } : {}), + ...(response.body.lastRun ? { lastRun: response.body.lastRun } : {}), }); expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); expect(Date.parse(response.body.updatedAt)).to.be.greaterThan( Date.parse(response.body.createdAt) ); + if (response.body.nextRun) { + expect(Date.parse(response.body.nextRun)).to.be.greaterThan(0); + } // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/api_integration/apis/guided_onboarding/get_state.ts b/x-pack/test/api_integration/apis/guided_onboarding/get_state.ts deleted file mode 100644 index 86aa20abe9cd1..0000000000000 --- a/x-pack/test/api_integration/apis/guided_onboarding/get_state.ts +++ /dev/null @@ -1,69 +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 expect from '@kbn/expect'; -import { - testGuideStep1ActiveState, - testGuideNotActiveState, -} from '@kbn/guided-onboarding-plugin/public/services/api.mocks'; -import { guidedSetupSavedObjectsType } from '@kbn/guided-onboarding-plugin/server/saved_objects/guided_setup'; -import type { GuideState } from '@kbn/guided-onboarding'; -import type { FtrProviderContext } from '../../ftr_provider_context'; - -const mockSearchGuideNotActiveState: GuideState = { - ...testGuideNotActiveState, - guideId: 'search', -}; - -export default function testGetState({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); - - describe('GET /api/guided_onboarding/state', () => { - afterEach(async () => { - // Clean up saved objects - await kibanaServer.savedObjects.clean({ types: [guidedSetupSavedObjectsType] }); - }); - - const createGuides = async (guides: GuideState[]) => { - for (const guide of guides) { - await kibanaServer.savedObjects.create({ - type: guidedSetupSavedObjectsType, - id: guide.guideId, - overwrite: true, - attributes: guide, - }); - } - }; - - it('should return the state for all guides', async () => { - // Create two guides to return - await createGuides([testGuideStep1ActiveState, mockSearchGuideNotActiveState]); - - const response = await supertest.get('/api/guided_onboarding/state').expect(200); - expect(response.body.state.length).to.eql(2); - expect(response.body).to.eql({ - state: [testGuideStep1ActiveState, mockSearchGuideNotActiveState], - }); - }); - - it('should return the state for the active guide with query param `active=true`', async () => { - await createGuides([testGuideStep1ActiveState, mockSearchGuideNotActiveState]); - - const response = await supertest - .get('/api/guided_onboarding/state') - .query({ active: true }) - .expect(200); - expect(response.body).to.eql({ state: [testGuideStep1ActiveState] }); - }); - - it("should return an empty array if saved object doesn't exist", async () => { - const response = await supertest.get('/api/guided_onboarding/state').expect(200); - expect(response.body).to.eql({ state: [] }); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/guided_onboarding/put_state.ts b/x-pack/test/api_integration/apis/guided_onboarding/put_state.ts deleted file mode 100644 index 1e0385e846cf7..0000000000000 --- a/x-pack/test/api_integration/apis/guided_onboarding/put_state.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { - testGuideStep1ActiveState, - testGuideNotActiveState, -} from '@kbn/guided-onboarding-plugin/public/services/api.mocks'; -import { guidedSetupSavedObjectsType } from '@kbn/guided-onboarding-plugin/server/saved_objects/guided_setup'; -import type { GuideState } from '@kbn/guided-onboarding'; -import type { FtrProviderContext } from '../../ftr_provider_context'; - -const mockSearchGuideNotActiveState: GuideState = { - ...testGuideNotActiveState, - guideId: 'search', -}; - -export default function testPutState({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); - - describe('PUT /api/guided_onboarding/state', () => { - afterEach(async () => { - // Clean up saved objects - await kibanaServer.savedObjects.clean({ types: [guidedSetupSavedObjectsType] }); - }); - - it('should update a guide that has an existing saved object', async () => { - // Create a saved object for the guide - await kibanaServer.savedObjects.create({ - type: guidedSetupSavedObjectsType, - id: testGuideStep1ActiveState.guideId, - overwrite: true, - attributes: testGuideStep1ActiveState, - }); - - // Update the state of the guide - await supertest - .put(`/api/guided_onboarding/state`) - .set('kbn-xsrf', 'true') - .send({ - ...testGuideStep1ActiveState, - status: 'complete', - }) - .expect(200); - - // Check that the guide was updated - const response = await supertest.get('/api/guided_onboarding/state').expect(200); - const [updatedGuide] = response.body.state; - expect(updatedGuide.status).to.eql('complete'); - }); - - it('should update a guide that does not have a saved object', async () => { - await supertest - .put(`/api/guided_onboarding/state`) - .set('kbn-xsrf', 'true') - .send({ - ...testGuideStep1ActiveState, - status: 'ready_to_complete', - }) - .expect(200); - - // Check that the guide was updated - const response = await supertest.get('/api/guided_onboarding/state').expect(200); - const [updatedGuide] = response.body.state; - expect(updatedGuide.status).to.eql('ready_to_complete'); - }); - - it('should update any existing active guides to inactive', async () => { - // Create an active guide - await supertest - .put(`/api/guided_onboarding/state`) - .set('kbn-xsrf', 'true') - .send({ - ...testGuideStep1ActiveState, - isActive: true, - }) - .expect(200); - - // Create an inactive guide - await supertest - .put(`/api/guided_onboarding/state`) - .set('kbn-xsrf', 'true') - .send({ - ...mockSearchGuideNotActiveState, - isActive: false, - }) - .expect(200); - - // Create a new guide with isActive: true - await supertest - .put(`/api/guided_onboarding/state`) - .set('kbn-xsrf', 'true') - .send({ - guideId: 'observability', - isActive: true, - status: 'in_progress', - steps: [ - { - id: 'step1', - status: 'active', - }, - { - id: 'step2', - status: 'inactive', - }, - { - id: 'step3', - status: 'inactive', - }, - ], - }) - .expect(200); - - // Check that the active guide was updated - const response = await supertest.get('/api/guided_onboarding/state').expect(200); - const guides = response.body.state; - expect(guides.length).to.eql(3); - const activeGuides = guides.filter((guide: { isActive: boolean }) => guide.isActive); - expect(activeGuides.length).to.eql(1); - expect(activeGuides[0].guideId).to.eql('observability'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/index.ts b/x-pack/test/api_integration/apis/index.ts index 77675da1b6aaf..0278c439c5995 100644 --- a/x-pack/test/api_integration/apis/index.ts +++ b/x-pack/test/api_integration/apis/index.ts @@ -39,6 +39,5 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./cases')); loadTestFile(require.resolve('./monitoring_collection')); loadTestFile(require.resolve('./cloud_security_posture')); - loadTestFile(require.resolve('./guided_onboarding')); }); } diff --git a/x-pack/test/functional/es_archives/alerts/data.json b/x-pack/test/functional/es_archives/alerts/data.json index 0129bb2f4729c..8d7be46b6c749 100644 --- a/x-pack/test/functional/es_archives/alerts/data.json +++ b/x-pack/test/functional/es_archives/alerts/data.json @@ -1232,3 +1232,123 @@ } } } + +{ + "type": "doc", + "value": { + "id": "alert:8370ffd2-f2db-49dc-9741-92c657189b9b", + "index": ".kibana_1", + "source": { + "alert": { + "alertTypeId": "example.always-firing", + "apiKey": null, + "apiKeyOwner": null, + "consumer": "alerts", + "createdAt": "2022-08-24T19:02:30.889Z", + "createdBy": "elastic", + "enabled": false, + "muteAll": false, + "mutedInstanceIds": [], + "name": "Test rule migration with successful execution status and monitoring", + "params": {}, + "schedule": { + "interval": "1m" + }, + "scheduledTaskId": null, + "tags": [], + "throttle": null, + "updatedBy": "elastic", + "isSnoozedUntil": "2022-08-24T19:05:49.817Z", + "executionStatus": { + "status": "ok", + "lastExecutionDate": "2022-08-24T19:05:49.817Z", + "lastDuration": 60000 + }, + "monitoring": { + "execution": { + "history": [{ + "duration": 60000, + "success": true, + "timestamp": "2022-08-24T19:05:49.817Z" + }], + "calculated_metrics": { + "success_ratio": 1, + "p50": 0, + "p95": 60000, + "p99": 60000 + } + } + } + }, + "migrationVersion": { + "alert": "8.0.1" + }, + "references": [ + ], + "type": "alert", + "updated_at": "2022-11-01T19:05:50.159Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "alert:c87707ac-7328-47f7-b212-2cb40a4fc9b9", + "index": ".kibana_1", + "source": { + "alert": { + "alertTypeId": "example.always-firing", + "apiKey": null, + "apiKeyOwner": null, + "consumer": "alerts", + "createdAt": "2022-08-24T19:02:30.889Z", + "createdBy": "elastic", + "enabled": false, + "muteAll": false, + "mutedInstanceIds": [], + "name": "Test rule migration with warning execution status", + "params": {}, + "schedule": { + "interval": "1m" + }, + "scheduledTaskId": null, + "tags": [], + "throttle": null, + "updatedBy": "elastic", + "isSnoozedUntil": "2022-08-24T19:05:49.817Z", + "executionStatus": { + "status": "warning", + "lastExecutionDate": "2022-08-24T19:05:49.817Z", + "lastDuration": 60000, + "warning": { + "reason": "warning reason", + "message": "warning message" + } + }, + "monitoring": { + "execution": { + "history": [{ + "duration": 60000, + "success": true, + "timestamp": "2022-08-24T19:05:49.817Z" + }], + "calculated_metrics": { + "success_ratio": 1, + "p50": 0, + "p95": 60000, + "p99": 60000 + } + } + } + }, + "migrationVersion": { + "alert": "8.0.1" + }, + "references": [ + ], + "type": "alert", + "updated_at": "2022-11-01T19:05:50.159Z" + } + } +} diff --git a/x-pack/test/functional/es_archives/alerts/mappings.json b/x-pack/test/functional/es_archives/alerts/mappings.json index 0da2b51499517..2e64004634fa6 100644 --- a/x-pack/test/functional/es_archives/alerts/mappings.json +++ b/x-pack/test/functional/es_archives/alerts/mappings.json @@ -175,6 +175,79 @@ }, "isSnoozedUntil": { "type": "date" + }, + "monitoring": { + "properties": { + "execution": { + "properties": { + "history": { + "properties": { + "duration": { + "type": "long" + }, + "success": { + "type": "boolean" + }, + "timestamp": { + "type": "date" + } + } + }, + "calculated_metrics": { + "properties": { + "p50": { + "type": "long" + }, + "p95": { + "type": "long" + }, + "p99": { + "type": "long" + }, + "success_ratio": { + "type": "float" + } + } + } + } + } + } + }, + "executionStatus": { + "properties": { + "numberOfTriggeredActions": { + "type": "long" + }, + "status": { + "type": "keyword" + }, + "lastExecutionDate": { + "type": "date" + }, + "lastDuration": { + "type": "long" + }, + "error": { + "properties": { + "reason": { + "type": "keyword" + }, + "message": { + "type": "keyword" + } + } + }, + "warning": { + "properties": { + "reason": { + "type": "keyword" + }, + "message": { + "type": "keyword" + } + } + } + } } } }, diff --git a/x-pack/test/functional/es_archives/security_solution/legacy_actions/data.json b/x-pack/test/functional/es_archives/security_solution/legacy_actions/data.json index 25aa371e02144..f0c883c6b3756 100644 --- a/x-pack/test/functional/es_archives/security_solution/legacy_actions/data.json +++ b/x-pack/test/functional/es_archives/security_solution/legacy_actions/data.json @@ -776,7 +776,7 @@ "scheduledTaskId" : null, "legacyId" : "29ba2fa0-b076-11ec-bb3f-1f063f8e06cf", "monitoring" : { - "execution" : { + "run" : { "history" : [ { "duration" : 111, diff --git a/x-pack/test/upgrade/apps/logs/logs_smoke_tests.ts b/x-pack/test/upgrade/apps/logs/logs_smoke_tests.ts index 8d9964f25422e..2cde8de43b62a 100644 --- a/x-pack/test/upgrade/apps/logs/logs_smoke_tests.ts +++ b/x-pack/test/upgrade/apps/logs/logs_smoke_tests.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const PageObjects = getPageObjects(['common', 'header', 'home']); + const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker']); const logsUi = getService('logsUi'); describe('upgrade logs smoke tests', function describeIndexTests() { @@ -27,6 +27,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.home.launchSampleLogs('logs'); await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.timePicker.setCommonlyUsedTime('Last_1 year'); }); it('should show log streams', async () => { diff --git a/yarn.lock b/yarn.lock index 8169a937a3540..36458e35ff746 100644 --- a/yarn.lock +++ b/yarn.lock @@ -86,10 +86,10 @@ dependencies: "@babel/highlight" "^7.18.6" -"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.19.3", "@babel/compat-data@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.4.tgz#95c86de137bf0317f3a570e1b6e996b427299747" - integrity sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw== +"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.0", "@babel/compat-data@^7.20.1": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.1.tgz#f2e6ef7790d8c8dbf03d379502dcc246dcce0b30" + integrity sha512-EWZ4mE2diW3QALKvDMiXnbZpRvlj+nayZ112nK93SnhqOtpdsbVD4W+2tEoT3YNBAG9RBR0ISY758ZkOgsn6pQ== "@babel/core@7.12.9": version "7.12.9" @@ -113,21 +113,21 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/core@^7.1.0", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.19.6", "@babel/core@^7.7.2", "@babel/core@^7.7.5", "@babel/core@^7.8.0": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.6.tgz#7122ae4f5c5a37c0946c066149abd8e75f81540f" - integrity sha512-D2Ue4KHpc6Ys2+AxpIx1BZ8+UegLLLE2p3KJEuJRKmokHOtl49jQ5ny1773KsGLZs8MQvBidAF6yWUJxRqtKtg== +"@babel/core@^7.1.0", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.20.2", "@babel/core@^7.7.2", "@babel/core@^7.7.5", "@babel/core@^7.8.0": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.2.tgz#8dc9b1620a673f92d3624bd926dc49a52cf25b92" + integrity sha512-w7DbG8DtMrJcFOi4VrLm+8QM4az8Mo+PuLBKLp2zrYRCow8W/f9xiXm5sN53C8HksCyDQwCKha9JiDoIyPjT2g== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.19.6" - "@babel/helper-compilation-targets" "^7.19.3" - "@babel/helper-module-transforms" "^7.19.6" - "@babel/helpers" "^7.19.4" - "@babel/parser" "^7.19.6" + "@babel/generator" "^7.20.2" + "@babel/helper-compilation-targets" "^7.20.0" + "@babel/helper-module-transforms" "^7.20.2" + "@babel/helpers" "^7.20.1" + "@babel/parser" "^7.20.2" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.6" - "@babel/types" "^7.19.4" + "@babel/traverse" "^7.20.1" + "@babel/types" "^7.20.2" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -150,12 +150,12 @@ dependencies: eslint-rule-composer "^0.3.0" -"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.19.6", "@babel/generator@^7.20.1", "@babel/generator@^7.7.2": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.1.tgz#ef32ecd426222624cbd94871a7024639cf61a9fa" - integrity sha512-u1dMdBUmA7Z0rBB97xh8pIhviK7oItYOkjbsCxTWMknyvbQRBwX7/gn4JXurRdirWMFh+ZtYARqkA6ydogVZpg== +"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.20.1", "@babel/generator@^7.20.2", "@babel/generator@^7.20.3", "@babel/generator@^7.7.2": + version "7.20.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.4.tgz#4d9f8f0c30be75fd90a0562099a26e5839602ab8" + integrity sha512-luCf7yk/cm7yab6CAW1aiFnmEfBJplb/JojV56MYEK7ziWfGmFlTfmL9Ehwfy4gFhbjBfWO1wj7/TuSbVNEEtA== dependencies: - "@babel/types" "^7.20.0" + "@babel/types" "^7.20.2" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -174,12 +174,12 @@ "@babel/helper-explode-assignable-expression" "^7.18.6" "@babel/types" "^7.18.9" -"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.19.0", "@babel/helper-compilation-targets@^7.19.3": - version "7.19.3" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz#a10a04588125675d7c7ae299af86fa1b2ee038ca" - integrity sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg== +"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.0": + version "7.20.0" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz#6bf5374d424e1b3922822f1d9bdaa43b1a139d0a" + integrity sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ== dependencies: - "@babel/compat-data" "^7.19.3" + "@babel/compat-data" "^7.20.0" "@babel/helper-validator-option" "^7.18.6" browserslist "^4.21.3" semver "^6.3.0" @@ -272,19 +272,19 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.0", "@babel/helper-module-transforms@^7.19.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.6.tgz#6c52cc3ac63b70952d33ee987cbee1c9368b533f" - integrity sha512-fCmcfQo/KYr/VXXDIyd3CBGZ6AFhPFy1TfSEJ+PilGVlQT6jcbqtHAM4C1EciRqMza7/TpOUZliuSH+U6HAhJw== +"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.6", "@babel/helper-module-transforms@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz#ac53da669501edd37e658602a21ba14c08748712" + integrity sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA== dependencies: "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-simple-access" "^7.19.4" + "@babel/helper-simple-access" "^7.20.2" "@babel/helper-split-export-declaration" "^7.18.6" "@babel/helper-validator-identifier" "^7.19.1" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.6" - "@babel/types" "^7.19.4" + "@babel/traverse" "^7.20.1" + "@babel/types" "^7.20.2" "@babel/helper-optimise-call-expression@^7.18.6": version "7.18.6" @@ -298,10 +298,10 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf" - integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz#d1b9000752b18d0877cff85a5c376ce5c3121629" + integrity sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ== "@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9": version "7.18.9" @@ -313,23 +313,23 @@ "@babel/helper-wrap-function" "^7.18.9" "@babel/types" "^7.18.9" -"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6" - integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ== +"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.18.9", "@babel/helper-replace-supers@^7.19.1": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz#e1592a9b4b368aa6bdb8784a711e0bcbf0612b78" + integrity sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw== dependencies: "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-member-expression-to-functions" "^7.18.9" "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" + "@babel/traverse" "^7.19.1" + "@babel/types" "^7.19.0" -"@babel/helper-simple-access@^7.18.6", "@babel/helper-simple-access@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.19.4.tgz#be553f4951ac6352df2567f7daa19a0ee15668e7" - integrity sha512-f9Xq6WqBFqaDfbCzn2w85hwklswz5qsKlh7f08w4Y9yhJHpnNC0QemtSkK5YyOY8kPGvyiwdzZksGUhnGdaUIg== +"@babel/helper-simple-access@^7.19.4", "@babel/helper-simple-access@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz#0ab452687fe0c2cfb1e2b9e0015de07fc2d62dd9" + integrity sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA== dependencies: - "@babel/types" "^7.19.4" + "@babel/types" "^7.20.2" "@babel/helper-skip-transparent-expression-wrappers@^7.18.9": version "7.18.9" @@ -370,14 +370,14 @@ "@babel/traverse" "^7.18.9" "@babel/types" "^7.18.9" -"@babel/helpers@^7.12.5", "@babel/helpers@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.4.tgz#42154945f87b8148df7203a25c31ba9a73be46c5" - integrity sha512-G+z3aOx2nfDHwX/kyVii5fJq+bgscg89/dJNWpYeKeBv3v9xX8EIabmx1k6u9LS04H7nROFVRVK+e3k0VHp+sw== +"@babel/helpers@^7.12.5", "@babel/helpers@^7.20.1": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.20.1.tgz#2ab7a0fcb0a03b5bf76629196ed63c2d7311f4c9" + integrity sha512-J77mUVaDTUJFZ5BpP6mMn6OIl3rEWymk2ZxDBQJUG3P+PbmyMcF3bYWvz0ma69Af1oobDqT/iAsvzhB58xhQUg== dependencies: "@babel/template" "^7.18.10" - "@babel/traverse" "^7.19.4" - "@babel/types" "^7.19.4" + "@babel/traverse" "^7.20.1" + "@babel/types" "^7.20.0" "@babel/highlight@^7.10.4", "@babel/highlight@^7.18.6": version "7.18.6" @@ -388,10 +388,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.10.3", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.19.6", "@babel/parser@^7.20.1": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.1.tgz#3e045a92f7b4623cafc2425eddcb8cf2e54f9cc5" - integrity sha512-hp0AYxaZJhxULfM1zyp7Wgr+pSUKBcP3M+PHnSzWGdXOzg/kHWIgiUWARvubhUKGOEw3xqY4x+lyZ9ytBVcELw== +"@babel/parser@^7.1.0", "@babel/parser@^7.10.3", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.20.1", "@babel/parser@^7.20.2", "@babel/parser@^7.20.3": + version "7.20.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.3.tgz#5358cf62e380cf69efcb87a7bb922ff88bfac6e2" + integrity sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -409,10 +409,10 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" "@babel/plugin-proposal-optional-chaining" "^7.18.9" -"@babel/plugin-proposal-async-generator-functions@^7.19.1": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz#34f6f5174b688529342288cd264f80c9ea9fb4a7" - integrity sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q== +"@babel/plugin-proposal-async-generator-functions@^7.20.1": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.1.tgz#352f02baa5d69f4e7529bdac39aaa02d41146af9" + integrity sha512-Gh5rchzSwE4kC+o/6T8waD0WHEQIsDmjltY8WnWRXHUdH8axZhuH86Ov9M72YhJfDrZseQwuuWaaIT/TmePp3g== dependencies: "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-plugin-utils" "^7.19.0" @@ -510,16 +510,16 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-transform-parameters" "^7.12.1" -"@babel/plugin-proposal-object-rest-spread@^7.12.1", "@babel/plugin-proposal-object-rest-spread@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.19.4.tgz#a8fc86e8180ff57290c91a75d83fe658189b642d" - integrity sha512-wHmj6LDxVDnL+3WhXteUBaoM1aVILZODAUjg11kHqG4cOlfgMQGxw6aCgvrXrmaJR3Bn14oZhImyCPZzRpC93Q== +"@babel/plugin-proposal-object-rest-spread@^7.12.1", "@babel/plugin-proposal-object-rest-spread@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.2.tgz#a556f59d555f06961df1e572bb5eca864c84022d" + integrity sha512-Ks6uej9WFK+fvIMesSqbAto5dD8Dz4VuuFvGJFKgIGSkJuRGcrwGECPA1fDgQK3/DbExBJpEkTeYeB8geIFCSQ== dependencies: - "@babel/compat-data" "^7.19.4" - "@babel/helper-compilation-targets" "^7.19.3" - "@babel/helper-plugin-utils" "^7.19.0" + "@babel/compat-data" "^7.20.1" + "@babel/helper-compilation-targets" "^7.20.0" + "@babel/helper-plugin-utils" "^7.20.2" "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-transform-parameters" "^7.18.8" + "@babel/plugin-transform-parameters" "^7.20.1" "@babel/plugin-proposal-optional-catch-binding@^7.18.6": version "7.18.6" @@ -627,12 +627,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-syntax-import-assertions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz#cd6190500a4fa2fe31990a963ffab4b63e4505e4" - integrity sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ== +"@babel/plugin-syntax-import-assertions@^7.20.0": + version "7.20.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz#bb50e0d4bea0957235390641209394e87bdb9cc4" + integrity sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ== dependencies: - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-plugin-utils" "^7.19.0" "@babel/plugin-syntax-import-meta@^7.8.3": version "7.10.4" @@ -748,25 +748,25 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-block-scoping@^7.12.12", "@babel/plugin-transform-block-scoping@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.19.4.tgz#315d70f68ce64426db379a3d830e7ac30be02e9b" - integrity sha512-934S2VLLlt2hRJwPf4MczaOr4hYF0z+VKPwqTNxyKX7NthTiPfhuKFWQZHXRM0vh/wo/VyXB3s4bZUNA08l+tQ== +"@babel/plugin-transform-block-scoping@^7.12.12", "@babel/plugin-transform-block-scoping@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.20.2.tgz#f59b1767e6385c663fd0bce655db6ca9c8b236ed" + integrity sha512-y5V15+04ry69OV2wULmwhEA6jwSWXO1TwAtIwiPXcvHcoOQUqpyMVd2bDsQJMW8AurjulIyUV8kDqtjSwHy1uQ== dependencies: - "@babel/helper-plugin-utils" "^7.19.0" + "@babel/helper-plugin-utils" "^7.20.2" -"@babel/plugin-transform-classes@^7.12.1", "@babel/plugin-transform-classes@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz#0e61ec257fba409c41372175e7c1e606dc79bb20" - integrity sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A== +"@babel/plugin-transform-classes@^7.12.1", "@babel/plugin-transform-classes@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.20.2.tgz#c0033cf1916ccf78202d04be4281d161f6709bb2" + integrity sha512-9rbPp0lCVVoagvtEyQKSo5L8oo0nQS/iif+lwlAz29MccX2642vWDlSZK+2T2buxbopotId2ld7zZAzRfz9j1g== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-compilation-targets" "^7.19.0" + "@babel/helper-compilation-targets" "^7.20.0" "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-function-name" "^7.19.0" "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-plugin-utils" "^7.19.0" - "@babel/helper-replace-supers" "^7.18.9" + "@babel/helper-plugin-utils" "^7.20.2" + "@babel/helper-replace-supers" "^7.19.1" "@babel/helper-split-export-declaration" "^7.18.6" globals "^11.1.0" @@ -777,12 +777,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.9" -"@babel/plugin-transform-destructuring@^7.12.1", "@babel/plugin-transform-destructuring@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.19.4.tgz#46890722687b9b89e1369ad0bd8dc6c5a3b4319d" - integrity sha512-t0j0Hgidqf0aM86dF8U+vXYReUgJnlv4bZLsyoPnwZNrGY+7/38o8YjaELrvHeVfTZao15kjR0PVv0nju2iduA== +"@babel/plugin-transform-destructuring@^7.12.1", "@babel/plugin-transform-destructuring@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.20.2.tgz#c23741cfa44ddd35f5e53896e88c75331b8b2792" + integrity sha512-mENM+ZHrvEgxLTBXUiQ621rRXZes3KWUv6NdQlrnr1TkWVw+hUjQBZuP2X32qKlrlG2BzgR95gkuCRSkJl8vIw== dependencies: - "@babel/helper-plugin-utils" "^7.19.0" + "@babel/helper-plugin-utils" "^7.20.2" "@babel/plugin-transform-dotall-regex@^7.18.6", "@babel/plugin-transform-dotall-regex@^7.4.4": version "7.18.6" @@ -845,35 +845,32 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-modules-amd@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz#8c91f8c5115d2202f277549848874027d7172d21" - integrity sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg== +"@babel/plugin-transform-modules-amd@^7.19.6": + version "7.19.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.19.6.tgz#aca391801ae55d19c4d8d2ebfeaa33df5f2a2cbd" + integrity sha512-uG3od2mXvAtIFQIh0xrpLH6r5fpSQN04gIVovl+ODLdUMANokxQLZnPBHcjmv3GxRjnqwLuHvppjjcelqUFZvg== dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" + "@babel/helper-module-transforms" "^7.19.6" + "@babel/helper-plugin-utils" "^7.19.0" -"@babel/plugin-transform-modules-commonjs@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz#afd243afba166cca69892e24a8fd8c9f2ca87883" - integrity sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q== +"@babel/plugin-transform-modules-commonjs@^7.19.6": + version "7.19.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.19.6.tgz#25b32feef24df8038fc1ec56038917eacb0b730c" + integrity sha512-8PIa1ym4XRTKuSsOUXqDG0YaOlEuTVvHMe5JCfgBMOtHvJKw/4NGovEGN33viISshG/rZNVrACiBmPQLvWN8xQ== dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-simple-access" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" + "@babel/helper-module-transforms" "^7.19.6" + "@babel/helper-plugin-utils" "^7.19.0" + "@babel/helper-simple-access" "^7.19.4" -"@babel/plugin-transform-modules-systemjs@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz#5f20b471284430f02d9c5059d9b9a16d4b085a1f" - integrity sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A== +"@babel/plugin-transform-modules-systemjs@^7.19.6": + version "7.19.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.6.tgz#59e2a84064b5736a4471b1aa7b13d4431d327e0d" + integrity sha512-fqGLBepcc3kErfR9R3DnVpURmckXP7gj7bAlrTQyBxrigFqszZCkFkcoxzCp2v32XmwXLvbw+8Yq9/b+QqksjQ== dependencies: "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-module-transforms" "^7.19.0" + "@babel/helper-module-transforms" "^7.19.6" "@babel/helper-plugin-utils" "^7.19.0" - "@babel/helper-validator-identifier" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" + "@babel/helper-validator-identifier" "^7.19.1" "@babel/plugin-transform-modules-umd@^7.18.6": version "7.18.6" @@ -906,12 +903,12 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/helper-replace-supers" "^7.18.6" -"@babel/plugin-transform-parameters@^7.12.1", "@babel/plugin-transform-parameters@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz#ee9f1a0ce6d78af58d0956a9378ea3427cccb48a" - integrity sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg== +"@babel/plugin-transform-parameters@^7.12.1", "@babel/plugin-transform-parameters@^7.20.1": + version "7.20.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.20.3.tgz#7b3468d70c3c5b62e46be0a47b6045d8590fb748" + integrity sha512-oZg/Fpx0YDrj13KsLyO8I/CX3Zdw7z0O9qOd95SqcoIzuqy/WTGWvePeHAnZCN54SfdyjHcb1S30gc8zlzlHcA== dependencies: - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-plugin-utils" "^7.20.2" "@babel/plugin-transform-property-literals@^7.18.6": version "7.18.6" @@ -1040,18 +1037,18 @@ "@babel/helper-create-regexp-features-plugin" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/preset-env@^7.12.11", "@babel/preset-env@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.19.4.tgz#4c91ce2e1f994f717efb4237891c3ad2d808c94b" - integrity sha512-5QVOTXUdqTCjQuh2GGtdd7YEhoRXBMVGROAtsBeLGIbIz3obCBIfRMT1I3ZKkMgNzwkyCkftDXSSkHxnfVf4qg== +"@babel/preset-env@^7.12.11", "@babel/preset-env@^7.20.2": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.20.2.tgz#9b1642aa47bb9f43a86f9630011780dab7f86506" + integrity sha512-1G0efQEWR1EHkKvKHqbG+IN/QdgwfByUpM5V5QroDzGV2t3S/WXNQd693cHiHTlCFMpr9B6FkPFXDA2lQcKoDg== dependencies: - "@babel/compat-data" "^7.19.4" - "@babel/helper-compilation-targets" "^7.19.3" - "@babel/helper-plugin-utils" "^7.19.0" + "@babel/compat-data" "^7.20.1" + "@babel/helper-compilation-targets" "^7.20.0" + "@babel/helper-plugin-utils" "^7.20.2" "@babel/helper-validator-option" "^7.18.6" "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6" "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-async-generator-functions" "^7.19.1" + "@babel/plugin-proposal-async-generator-functions" "^7.20.1" "@babel/plugin-proposal-class-properties" "^7.18.6" "@babel/plugin-proposal-class-static-block" "^7.18.6" "@babel/plugin-proposal-dynamic-import" "^7.18.6" @@ -1060,7 +1057,7 @@ "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9" "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6" "@babel/plugin-proposal-numeric-separator" "^7.18.6" - "@babel/plugin-proposal-object-rest-spread" "^7.19.4" + "@babel/plugin-proposal-object-rest-spread" "^7.20.2" "@babel/plugin-proposal-optional-catch-binding" "^7.18.6" "@babel/plugin-proposal-optional-chaining" "^7.18.9" "@babel/plugin-proposal-private-methods" "^7.18.6" @@ -1071,7 +1068,7 @@ "@babel/plugin-syntax-class-static-block" "^7.14.5" "@babel/plugin-syntax-dynamic-import" "^7.8.3" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - "@babel/plugin-syntax-import-assertions" "^7.18.6" + "@babel/plugin-syntax-import-assertions" "^7.20.0" "@babel/plugin-syntax-json-strings" "^7.8.3" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" @@ -1084,10 +1081,10 @@ "@babel/plugin-transform-arrow-functions" "^7.18.6" "@babel/plugin-transform-async-to-generator" "^7.18.6" "@babel/plugin-transform-block-scoped-functions" "^7.18.6" - "@babel/plugin-transform-block-scoping" "^7.19.4" - "@babel/plugin-transform-classes" "^7.19.0" + "@babel/plugin-transform-block-scoping" "^7.20.2" + "@babel/plugin-transform-classes" "^7.20.2" "@babel/plugin-transform-computed-properties" "^7.18.9" - "@babel/plugin-transform-destructuring" "^7.19.4" + "@babel/plugin-transform-destructuring" "^7.20.2" "@babel/plugin-transform-dotall-regex" "^7.18.6" "@babel/plugin-transform-duplicate-keys" "^7.18.9" "@babel/plugin-transform-exponentiation-operator" "^7.18.6" @@ -1095,14 +1092,14 @@ "@babel/plugin-transform-function-name" "^7.18.9" "@babel/plugin-transform-literals" "^7.18.9" "@babel/plugin-transform-member-expression-literals" "^7.18.6" - "@babel/plugin-transform-modules-amd" "^7.18.6" - "@babel/plugin-transform-modules-commonjs" "^7.18.6" - "@babel/plugin-transform-modules-systemjs" "^7.19.0" + "@babel/plugin-transform-modules-amd" "^7.19.6" + "@babel/plugin-transform-modules-commonjs" "^7.19.6" + "@babel/plugin-transform-modules-systemjs" "^7.19.6" "@babel/plugin-transform-modules-umd" "^7.18.6" "@babel/plugin-transform-named-capturing-groups-regex" "^7.19.1" "@babel/plugin-transform-new-target" "^7.18.6" "@babel/plugin-transform-object-super" "^7.18.6" - "@babel/plugin-transform-parameters" "^7.18.8" + "@babel/plugin-transform-parameters" "^7.20.1" "@babel/plugin-transform-property-literals" "^7.18.6" "@babel/plugin-transform-regenerator" "^7.18.6" "@babel/plugin-transform-reserved-words" "^7.18.6" @@ -1114,7 +1111,7 @@ "@babel/plugin-transform-unicode-escapes" "^7.18.10" "@babel/plugin-transform-unicode-regex" "^7.18.6" "@babel/preset-modules" "^0.1.5" - "@babel/types" "^7.19.4" + "@babel/types" "^7.20.2" babel-plugin-polyfill-corejs2 "^0.3.3" babel-plugin-polyfill-corejs3 "^0.6.0" babel-plugin-polyfill-regenerator "^0.4.1" @@ -1196,7 +1193,7 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.10.3", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.9", "@babel/traverse@^7.19.4", "@babel/traverse@^7.19.6", "@babel/traverse@^7.20.1", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.10.3", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.9", "@babel/traverse@^7.19.1", "@babel/traverse@^7.20.1", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.2": version "7.20.1" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.1.tgz#9b15ccbf882f6d107eeeecf263fbcdd208777ec8" integrity sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA== @@ -1212,10 +1209,10 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.10.3", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.19.4", "@babel/types@^7.20.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.0.tgz#52c94cf8a7e24e89d2a194c25c35b17a64871479" - integrity sha512-Jlgt3H0TajCW164wkTOTzHkZb075tMQMULzrLUoUeKmO7eFL96GgDxf7/Axhc5CAuKE3KFyVW1p6ysKsi2oXAg== +"@babel/types@^7.0.0", "@babel/types@^7.10.3", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.20.2" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.2.tgz#67ac09266606190f496322dbaff360fdaa5e7842" + integrity sha512-FnnvsNWgZCr232sqtXggapvlkk/tuwR/qhGzcmxI0GXLCjmPYQPzio2FbdlWuY6y1sHFfQKk+rRbUZ9VStQMog== dependencies: "@babel/helper-string-parser" "^7.19.4" "@babel/helper-validator-identifier" "^7.19.1" @@ -9103,16 +9100,7 @@ axios@^0.27.2: follow-redirects "^1.14.9" form-data "^4.0.0" -axios@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.2.tgz#8b6f6c540abf44ab98d9904e8daf55351ca4a331" - integrity sha512-bznQyETwElsXl2RK7HLLwb5GPpOLlycxHCtrpDR/4RqqBzjARaOTo3jz4IgtntWUYee7Ne4S8UHd92VCuzPaWA== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -axios@^1.1.3: +axios@^1.1.2, axios@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35" integrity sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA== @@ -9168,13 +9156,6 @@ babel-plugin-apply-mdx-type-prop@1.6.22: "@babel/helper-plugin-utils" "7.10.4" "@mdx-js/util" "1.6.22" -babel-plugin-dynamic-import-node@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" - integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ== - dependencies: - object.assign "^4.1.0" - babel-plugin-emotion@^10.0.27: version "10.0.33" resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.0.33.tgz#ce1155dcd1783bbb9286051efee53f4e2be63e03" @@ -10817,11 +10798,6 @@ constate@^3.3.2: resolved "https://registry.yarnpkg.com/constate/-/constate-3.3.2.tgz#a6cd2f3c203da2cb863f47d22a330b833936c449" integrity sha512-ZnEWiwU6QUTil41D5EGpA7pbqAPGvnR9kBjko8DzVIxpC60mdNKrP568tT5WLJPAxAOtJqJw60+h79ot/Uz1+Q== -container-info@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/container-info/-/container-info-1.0.1.tgz#6b383cb5e197c8d921e88983388facb04124b56b" - integrity sha512-wk/+uJvPHOFG+JSwQS+fw6H6yw3Oyc8Kw9L4O2MN817uA90OqJ59nlZbbLPqDudsjJ7Tetee3pwExdKpd2ahjQ== - content-disposition@0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -12632,23 +12608,7 @@ ejs@^3.1.6, ejs@^3.1.8: dependencies: jake "^10.8.5" -elastic-apm-http-client@11.0.1, elastic-apm-http-client@^11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.1.tgz#15dbe99d56d62b3f732d1bd2b51bef6094b78801" - integrity sha512-5AOWlhs2WlZpI+DfgGqY/8Rk7KF8WeevaO8R961eBylavU6GWhLRNiJncohn5jsvrqhmeT19azBvy/oYRN7bJw== - dependencies: - agentkeepalive "^4.2.1" - breadth-filter "^2.0.0" - container-info "^1.0.1" - end-of-stream "^1.4.4" - fast-safe-stringify "^2.0.7" - fast-stream-to-buffer "^1.0.0" - object-filter-sequence "^1.0.0" - readable-stream "^3.4.0" - semver "^6.3.0" - stream-chopper "^3.0.1" - -elastic-apm-http-client@11.0.2: +elastic-apm-http-client@11.0.2, elastic-apm-http-client@^11.0.1: version "11.0.2" resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.2.tgz#576521443d4f3c733b5220ae8175bf5538870cf5" integrity sha512-Wiqwi4lnhjkILtP54wIbdY0X3Lv+x9JID42zYBI3g7BGRWUu4pPcTjJStWT/muMW57cdimHUektD3tOMFogprQ== @@ -12663,45 +12623,7 @@ elastic-apm-http-client@11.0.2: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.38.0: - version "3.39.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.39.0.tgz#51ca1dfc11e6b48b53967518461a959ac1623da1" - integrity sha512-aNRLDMQreZ+u23HStmppdDNtfS7Z651MWf3wLjw72haCNpGczuXsb4EuBRfJOk0IXWXTYgX1cDy2hiy4PAxlSQ== - dependencies: - "@elastic/ecs-pino-format" "^1.2.0" - "@opentelemetry/api" "^1.1.0" - after-all-results "^2.0.0" - async-cache "^1.1.0" - async-value-promise "^1.1.1" - basic-auth "^2.0.1" - cookie "^0.5.0" - core-util-is "^1.0.2" - elastic-apm-http-client "11.0.1" - end-of-stream "^1.4.4" - error-callsites "^2.0.4" - error-stack-parser "^2.0.6" - escape-string-regexp "^4.0.0" - fast-safe-stringify "^2.0.7" - http-headers "^3.0.2" - is-native "^1.0.1" - lru-cache "^6.0.0" - measured-reporting "^1.51.1" - monitor-event-loop-delay "^1.0.0" - object-filter-sequence "^1.0.0" - object-identity-map "^1.0.2" - original-url "^1.2.3" - pino "^6.11.2" - relative-microtime "^2.0.0" - require-in-the-middle "^5.2.0" - semver "^6.3.0" - set-cookie-serde "^1.0.0" - shallow-clone-shim "^2.0.0" - source-map "^0.8.0-beta.0" - sql-summary "^1.0.1" - traverse "^0.6.6" - unicode-byte-truncate "^1.0.0" - -elastic-apm-node@^3.40.0: +elastic-apm-node@^3.38.0, elastic-apm-node@^3.40.0: version "3.40.0" resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.40.0.tgz#ed805ec817db7687ba9a77bcc0db6131e8cbc8cf" integrity sha512-gs9Z7boZW2o3ZMVbdjoJKXv4F2AcfMh52DW1WxEE/FSFa6lymj6GmCEFywuP8SqdpRZbh6yohJoGOpl7sheNJg==