diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 5cb8398fb9adf..3cdca29fcb49d 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -333,7 +333,8 @@ In general this plugin provides: |{kib-repo}blob/{branch}/src/plugins/unified_histogram/README.md[unifiedHistogram] -|The unifiedHistogram plugin provides UI components to create a layout including a resizable histogram and a main display. +|Unified Histogram is a UX Building Block including a layout with a resizable histogram and a main display. +It manages its own state and data fetching, and can easily be dropped into pages with minimal setup. |{kib-repo}blob/{branch}/src/plugins/unified_search/README.md[unifiedSearch] diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 683722674a631..e397d30dff1e2 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -175,6 +175,9 @@ { "label": "Contributors Newsletters", "items": [ + { + "id": "kibJanuary2023ContributorNewsletter" + }, { "id": "kibNovember2022ContributorNewsletter" }, diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 6b30af3f39097..5903bd136ed1f 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -241,6 +241,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { sum: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-sum-aggregation.html`, top_hits: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-hits-aggregation.html`, top_metrics: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-metrics.html`, + change_point: `${ELASTICSEARCH_DOCS}search-aggregations-change-point-aggregation.html`, }, runtimeFields: { overview: `${ELASTICSEARCH_DOCS}runtime.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index a6284078d3d8d..f7b4019eb599a 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -218,6 +218,7 @@ export interface DocLinks { readonly std_dev: string; readonly sum: string; readonly top_hits: string; + readonly change_point: string; }; readonly runtimeFields: { readonly overview: string; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx index f18b5c0dd31df..213ddaebb7fa9 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx @@ -15,7 +15,7 @@ import * as i18n from '../../translations'; interface MetaInfoDetailsProps { label: string; lastUpdate: JSX.Element | string; - lastUpdateValue: string; + lastUpdateValue?: string; dataTestSubj?: string; } @@ -42,20 +42,24 @@ export const MetaInfoDetails = memo( {lastUpdate} - - - {i18n.EXCEPTION_ITEM_CARD_META_BY} - - - - + {lastUpdateValue != null && ( + <> - - {lastUpdateValue} - + + {i18n.EXCEPTION_ITEM_CARD_META_BY} + - - + + + + + {lastUpdateValue} + + + + + + )} ); } diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx index 1d7d6a3568380..2fffe08b5b091 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx @@ -44,6 +44,12 @@ export const ExceptionItemCardMetaInfo = memo( }), [dataTestSubj, rules, securityLinkAnchorComponent] ); + + const isExpired = useMemo( + () => (item.expire_time ? new Date(item.expire_time) <= new Date() : false), + [item] + ); + return ( {FormattedDateComponent !== null && ( @@ -77,6 +83,27 @@ export const ExceptionItemCardMetaInfo = memo( dataTestSubj={`${dataTestSubj || ''}UpdatedBy`} /> + {item.expire_time != null && ( + <> + + + } + dataTestSubj={`${dataTestSubj || ''}ExpireTime`} + /> + + + )} )} diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/translations.ts b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/translations.ts index 2fa7524291025..915311db19f4a 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/translations.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/translations.ts @@ -34,6 +34,20 @@ export const EXCEPTION_ITEM_CARD_UPDATED_LABEL = i18n.translate( } ); +export const EXCEPTION_ITEM_CARD_EXPIRES_LABEL = i18n.translate( + 'exceptionList-components.exceptions.exceptionItem.card.expiresLabel', + { + defaultMessage: 'Expires at', + } +); + +export const EXCEPTION_ITEM_CARD_EXPIRED_LABEL = i18n.translate( + 'exceptionList-components.exceptions.exceptionItem.card.expiredLabel', + { + defaultMessage: 'Expired at', + } +); + export const EXCEPTION_ITEM_CARD_META_BY = i18n.translate( 'exceptionList-components.exceptions.exceptionItem.card.metaDetailsBy', { diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx index 95546ee84eaf5..5bd90189c14bc 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx @@ -29,9 +29,9 @@ interface ExceptionListHeaderComponentProps { canUserEditList?: boolean; securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common onEditListDetails: (listDetails: ListDetails) => void; - onExportList: () => void; onDeleteList: () => void; onManageRules: () => void; + onExportList: () => void; } export interface BackOptions { @@ -51,9 +51,9 @@ const ExceptionListHeaderComponent: FC = ({ backOptions, canUserEditList = true, onEditListDetails, - onExportList, onDeleteList, onManageRules, + onExportList, }) => { const { isModalVisible, listDetails, onEdit, onSave, onCancel } = useExceptionListHeader({ name, @@ -97,9 +97,9 @@ const ExceptionListHeaderComponent: FC = ({ isReadonly={isReadonly} canUserEditList={canUserEditList} securityLinkAnchorComponent={securityLinkAnchorComponent} - onExportList={onExportList} onDeleteList={onDeleteList} onManageRules={onManageRules} + onExportList={onExportList} />, ]} breadcrumbs={[ diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx index 5897361d3df30..14aa823046ff4 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx @@ -18,9 +18,9 @@ interface MenuItemsProps { linkedRules: Rule[]; canUserEditList?: boolean; securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - onExportList: () => void; onDeleteList: () => void; onManageRules: () => void; + onExportList: () => void; } const MenuItemsComponent: FC = ({ @@ -29,9 +29,9 @@ const MenuItemsComponent: FC = ({ securityLinkAnchorComponent, isReadonly, canUserEditList = true, - onExportList, onDeleteList, onManageRules, + onExportList, }) => { const referencedLinks = useMemo( () => @@ -78,7 +78,7 @@ const MenuItemsComponent: FC = ({ data-test-subj={`${dataTestSubj || ''}ManageRulesButton`} fill onClick={() => { - if (typeof onExportList === 'function') onManageRules(); + if (typeof onManageRules === 'function') onManageRules(); }} > {i18n.EXCEPTION_LIST_HEADER_MANAGE_RULES_BUTTON} diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx index 9e8935ba4deea..2efbb36998424 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx @@ -108,7 +108,7 @@ describe('MenuItems', () => { fireEvent.click(wrapper.getByTestId('ManageRulesButton')); expect(onManageRules).toHaveBeenCalled(); }); - it('should call onExportList', () => { + it('should call onExportModalOpen', () => { const wrapper = render( ; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/include_expired_exceptions/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/include_expired_exceptions/index.ts new file mode 100644 index 0000000000000..12b2f2be7331f --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/include_expired_exceptions/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import * as t from 'io-ts'; + +export const include_expired_exceptions = t.keyof({ true: null, false: null }); +export const includeExpiredExceptionsOrUndefined = t.union([ + include_expired_exceptions, + t.undefined, +]); +export type IncludeExpiredExceptionsOrUndefined = t.TypeOf< + typeof includeExpiredExceptionsOrUndefined +>; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts index 7f92649fd07d8..e50500f87f61a 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/index.ts @@ -28,6 +28,7 @@ export * from './entry_nested'; export * from './exception_export_details'; export * from './exception_list'; export * from './exception_list_item_type'; +export * from './expire_time'; export * from './filter'; export * from './id'; export * from './immutable'; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/create_endpoint_list_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/create_endpoint_list_item_schema/index.ts index 8c8e1d3e0db4b..0262e16539e9f 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/create_endpoint_list_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/create_endpoint_list_item_schema/index.ts @@ -22,6 +22,7 @@ import { description } from '../../common/description'; import { name } from '../../common/name'; import { meta } from '../../common/meta'; import { tags } from '../../common/tags'; +import { ExpireTimeOrUndefined, expireTimeOrUndefined } from '../../common'; export const createEndpointListItemSchema = t.intersection([ t.exact( @@ -39,6 +40,7 @@ export const createEndpointListItemSchema = t.intersection([ meta, // defaults to undefined if not set during decode os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode + expire_time: expireTimeOrUndefined, // defaults to undefined if not set during decode }) ), ]); @@ -48,11 +50,12 @@ export type CreateEndpointListItemSchema = t.OutputOf>, - 'tags' | 'item_id' | 'entries' | 'comments' | 'os_types' + 'tags' | 'item_id' | 'entries' | 'comments' | 'os_types' | 'expire_time' > & { comments: CreateCommentsArray; tags: Tags; item_id: ItemId; entries: EntriesArray; os_types: OsTypeArray; + expire_time: ExpireTimeOrUndefined; }; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/create_exception_list_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/create_exception_list_item_schema/index.ts index e4f92c2819664..a97280056da73 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/create_exception_list_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/create_exception_list_item_schema/index.ts @@ -25,6 +25,7 @@ import { meta } from '../../common/meta'; import { namespace_type } from '../../common/namespace_type'; import { tags } from '../../common/tags'; import { nonEmptyEntriesArray } from '../../common/non_empty_entries_array'; +import { ExpireTimeOrUndefined, expireTimeOrUndefined } from '../../common'; export const createExceptionListItemSchema = t.intersection([ t.exact( @@ -39,6 +40,7 @@ export const createExceptionListItemSchema = t.intersection([ t.exact( t.partial({ comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode + expire_time: expireTimeOrUndefined, item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode @@ -53,9 +55,10 @@ export type CreateExceptionListItemSchema = t.OutputOf>, - 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' + 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' | 'expire_time' > & { comments: CreateCommentsArray; + expire_time: ExpireTimeOrUndefined; tags: Tags; item_id: ItemId; entries: EntriesArray; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/create_rule_exception_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/create_rule_exception_item_schema/index.ts index b710ffde0a6d8..e231d3978cde3 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/create_rule_exception_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/create_rule_exception_item_schema/index.ts @@ -25,6 +25,8 @@ import { Tags, tags, name, + ExpireTimeOrUndefined, + expireTimeOrUndefined, } from '../../common'; import { RequiredKeepUndefined } from '../../common/required_keep_undefined'; @@ -46,6 +48,7 @@ export const createRuleExceptionListItemSchema = t.intersection([ namespace_type: namespaceType, // defaults to 'single' if not set during decode os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode + expire_time: expireTimeOrUndefined, }) ), ]); @@ -57,7 +60,7 @@ export type CreateRuleExceptionListItemSchema = t.OutputOf< // This type is used after a decode since some things are defaults after a decode. export type CreateRuleExceptionListItemSchemaDecoded = Omit< RequiredKeepUndefined>, - 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' + 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' | 'expire_time' > & { comments: CreateCommentsArray; tags: Tags; @@ -65,4 +68,5 @@ export type CreateRuleExceptionListItemSchemaDecoded = Omit< entries: EntriesArray; namespace_type: NamespaceType; os_types: OsTypeArray; + expire_time: ExpireTimeOrUndefined; }; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.mock.ts index a5542d99b007c..863429b5b5340 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.mock.ts @@ -14,4 +14,5 @@ export const getExportExceptionListQuerySchemaMock = (): ExportExceptionListQuer id: ID, list_id: LIST_ID, namespace_type: NAMESPACE_TYPE, + include_expired_exceptions: 'true', }); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.test.ts index 3d1fcba3f7dbf..382fab3f7b604 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.test.ts @@ -45,6 +45,7 @@ describe('export_exception_list_schema', () => { expect(message.schema).toEqual({ id: 'uuid_here', + include_expired_exceptions: 'true', list_id: 'some-list-id', namespace_type: 'single', }); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.ts index ce6dc9a561293..bcdcc46dadb39 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/export_exception_list_query_schema/index.ts @@ -9,6 +9,7 @@ import * as t from 'io-ts'; import { id } from '../../common/id'; +import { includeExpiredExceptionsOrUndefined } from '../../common/include_expired_exceptions'; import { list_id } from '../../common/list_id'; import { namespace_type } from '../../common/namespace_type'; @@ -17,6 +18,7 @@ export const exportExceptionListQuerySchema = t.exact( id, list_id, namespace_type, + include_expired_exceptions: includeExpiredExceptionsOrUndefined, // TODO: Add file_name here with a default value }) ); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.mock.ts index 03ec225351e6d..2ed82b20efd7c 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.mock.ts @@ -31,4 +31,5 @@ export const getImportExceptionsListItemSchemaDecodedMock = ( namespace_type: 'single', os_types: [], tags: [], + expire_time: undefined, }); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts index dc5a48597211e..6b1590397b585 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/import_exception_item_schema/index.ts @@ -30,7 +30,7 @@ import { exceptionListItemType } from '../../common/exception_list_item_type'; import { ItemId } from '../../common/item_id'; import { EntriesArray } from '../../common/entries'; import { DefaultImportCommentsArray } from '../../common/default_import_comments_array'; -import { ImportCommentsArray } from '../../common'; +import { ExpireTimeOrUndefined, expireTimeOrUndefined, ImportCommentsArray } from '../../common'; /** * Differences from this and the createExceptionsListItemSchema are @@ -67,6 +67,7 @@ export const importExceptionListItemSchema = t.intersection([ namespace_type, // defaults to 'single' if not set during decode os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode + expire_time: expireTimeOrUndefined, }) ), ]); @@ -76,7 +77,7 @@ export type ImportExceptionListItemSchema = t.OutputOf & { comments: ImportCommentsArray; tags: Tags; @@ -84,4 +85,5 @@ export type ImportExceptionListItemSchemaDecoded = Omit< entries: EntriesArray; namespace_type: NamespaceType; os_types: OsTypeArray; + expire_time: ExpireTimeOrUndefined; }; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/update_endpoint_list_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/update_endpoint_list_item_schema/index.ts index 8e5aa41e1fad2..b0669b05463cf 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/update_endpoint_list_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/update_endpoint_list_item_schema/index.ts @@ -21,6 +21,7 @@ import { Tags, tags } from '../../common/tags'; import { RequiredKeepUndefined } from '../../common/required_keep_undefined'; import { UpdateCommentsArray } from '../../common/update_comment'; import { EntriesArray } from '../../common/entries'; +import { ExpireTimeOrUndefined, expireTimeOrUndefined } from '../../common'; export const updateEndpointListItemSchema = t.intersection([ t.exact( @@ -40,6 +41,7 @@ export const updateEndpointListItemSchema = t.intersection([ meta, // defaults to undefined if not set during decode os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode + expire_time: expireTimeOrUndefined, }) ), ]); @@ -49,10 +51,11 @@ export type UpdateEndpointListItemSchema = t.OutputOf>, - 'tags' | 'entries' | 'comments' + 'tags' | 'entries' | 'comments' | 'expire_time' > & { comments: UpdateCommentsArray; tags: Tags; entries: EntriesArray; os_types: OsTypeArray; + expire_time: ExpireTimeOrUndefined; }; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/update_exception_list_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/update_exception_list_item_schema/index.ts index 2624f36329562..bb6ae55c0f037 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/update_exception_list_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/update_exception_list_item_schema/index.ts @@ -22,6 +22,7 @@ import { _version } from '../../common/underscore_version'; import { id } from '../../common/id'; import { meta } from '../../common/meta'; import { namespace_type } from '../../common/namespace_type'; +import { ExpireTimeOrUndefined, expireTimeOrUndefined } from '../../common'; export const updateExceptionListItemSchema = t.intersection([ t.exact( @@ -36,6 +37,7 @@ export const updateExceptionListItemSchema = t.intersection([ t.partial({ _version, // defaults to undefined if not set during decode comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode + expire_time: expireTimeOrUndefined, id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), meta, // defaults to undefined if not set during decode @@ -51,11 +53,12 @@ export type UpdateExceptionListItemSchema = t.OutputOf>, - 'tags' | 'entries' | 'namespace_type' | 'comments' | 'os_types' + 'tags' | 'entries' | 'namespace_type' | 'comments' | 'os_types' | 'expire_time' > & { comments: UpdateCommentsArray; tags: Tags; entries: EntriesArray; namespace_type: NamespaceType; os_types: OsTypeArray; + expire_time: ExpireTimeOrUndefined; }; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.mock.ts index c06a5439d11d8..b57d00eed29ee 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.mock.ts @@ -34,6 +34,7 @@ export const getExceptionListItemSchemaMock = ( created_by: USER, description: DESCRIPTION, entries: ENTRIES, + expire_time: undefined, id: '1', item_id: 'endpoint_list_item', list_id: 'endpoint_list_id', diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.ts index d2fc52b38f95b..d4b5478551901 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/response/exception_list_item_schema/index.ts @@ -26,6 +26,7 @@ import { commentsArray } from '../../common/comment'; import { entriesArray } from '../../common/entries'; import { item_id } from '../../common/item_id'; import { exceptionListItemType } from '../../common/exception_list_item_type'; +import { expireTimeOrUndefined } from '../../common/expire_time'; export const exceptionListItemSchema = t.exact( t.type({ @@ -35,6 +36,7 @@ export const exceptionListItemSchema = t.exact( created_by, description, entries: entriesArray, + expire_time: expireTimeOrUndefined, id, item_id, list_id, diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts index 63711c8a036bd..d3e10619b15cb 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts @@ -77,6 +77,7 @@ export interface ApiCallMemoProps { // remove unnecessary validation checks export interface ApiListExportProps { id: string; + includeExpiredExceptions: boolean; listId: string; namespaceType: NamespaceType; onError: (err: Error) => void; @@ -133,6 +134,7 @@ export interface ExportExceptionListProps { id: string; listId: string; namespaceType: NamespaceType; + includeExpiredExceptions: boolean; signal: AbortSignal; } diff --git a/packages/kbn-securitysolution-list-api/src/api/index.ts b/packages/kbn-securitysolution-list-api/src/api/index.ts index 440217ff65167..ea324f0dd033b 100644 --- a/packages/kbn-securitysolution-list-api/src/api/index.ts +++ b/packages/kbn-securitysolution-list-api/src/api/index.ts @@ -532,10 +532,11 @@ const addEndpointExceptionListWithValidation = async ({ export { addEndpointExceptionListWithValidation as addEndpointExceptionList }; /** - * Fetch an ExceptionList by providing a ExceptionList ID + * Export an ExceptionList by providing a ExceptionList ID * * @param http Kibana http service * @param id ExceptionList ID (not list_id) + * @param includeExpiredExceptions boolean for including expired exceptions * @param listId ExceptionList LIST_ID (not id) * @param namespaceType ExceptionList namespace_type * @param signal to cancel request @@ -545,13 +546,19 @@ export { addEndpointExceptionListWithValidation as addEndpointExceptionList }; export const exportExceptionList = async ({ http, id, + includeExpiredExceptions, listId, namespaceType, signal, }: ExportExceptionListProps): Promise => http.fetch(`${EXCEPTION_LIST_URL}/_export`, { method: 'POST', - query: { id, list_id: listId, namespace_type: namespaceType }, + query: { + id, + list_id: listId, + namespace_type: namespaceType, + include_expired_exceptions: includeExpiredExceptions, + }, signal, }); diff --git a/packages/kbn-securitysolution-list-hooks/src/mocks/response/exception_list_item_schema.mock.ts b/packages/kbn-securitysolution-list-hooks/src/mocks/response/exception_list_item_schema.mock.ts index 4ba6066564d0d..dab3b67ee31ee 100644 --- a/packages/kbn-securitysolution-list-hooks/src/mocks/response/exception_list_item_schema.mock.ts +++ b/packages/kbn-securitysolution-list-hooks/src/mocks/response/exception_list_item_schema.mock.ts @@ -34,6 +34,7 @@ export const getExceptionListItemSchemaMock = ( created_by: USER, description: DESCRIPTION, entries: ENTRIES, + expire_time: undefined, id: '1', item_id: 'endpoint_list_item', list_id: 'endpoint_list_id', diff --git a/packages/kbn-securitysolution-list-hooks/src/use_api/index.ts b/packages/kbn-securitysolution-list-hooks/src/use_api/index.ts index 30664df147252..2d3321bfa72f9 100644 --- a/packages/kbn-securitysolution-list-hooks/src/use_api/index.ts +++ b/packages/kbn-securitysolution-list-hooks/src/use_api/index.ts @@ -112,6 +112,7 @@ export const useApi = (http: HttpStart): ExceptionsApi => { }, async exportExceptionList({ id, + includeExpiredExceptions, listId, namespaceType, onError, @@ -123,6 +124,7 @@ export const useApi = (http: HttpStart): ExceptionsApi => { const blob = await Api.exportExceptionList({ http, id, + includeExpiredExceptions, listId, namespaceType, signal: abortCtrl.signal, diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index 38a41393b867c..403b07c0ba058 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -60,6 +60,7 @@ import { ExceptionsBuilderReturnExceptionItem, FormattedBuilderEntry, OperatorOption, + SavedObjectType, } from '../types'; export const isEntryNested = (item: BuilderEntry): item is EntryNested => { @@ -914,6 +915,21 @@ export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ export const containsValueListEntry = (items: ExceptionsBuilderExceptionItem[]): boolean => items.some((item) => item.entries.some(({ type }) => type === OperatorTypeEnum.LIST)); +export const buildShowActiveExceptionsFilter = (savedObjectPrefix: SavedObjectType[]): string => { + const now = new Date().toISOString(); + const filters = savedObjectPrefix.map( + (prefix) => + `${prefix}.attributes.expire_time > "${now}" OR NOT ${prefix}.attributes.expire_time: *` + ); + return filters.join(','); +}; + +export const buildShowExpiredExceptionsFilter = (savedObjectPrefix: SavedObjectType[]): string => { + const now = new Date().toISOString(); + const filters = savedObjectPrefix.map((prefix) => `${prefix}.attributes.expire_time <= "${now}"`); + return filters.join(','); +}; + const getIndexGroupName = (indexName: string): string => { // Check whether it is a Data Stream index const dataStreamExp = /.ds-(.*?)-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[0-9]{6}/; 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 bafc64776b6dd..d5f7c58abc07f 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 @@ -87,8 +87,8 @@ describe('checking migration metadata changes on all registered SO types', () => "epm-packages": "1922a722ea42ab4953a96037fabb81a9ded8e240", "epm-packages-assets": "00c8b5e5bf059627ffc9fbde920e1ac75926c5f6", "event_loop_delays_daily": "ef49e7f15649b551b458c7ea170f3ed17f89abd0", - "exception-list": "aae42e8f19017277d194d37d4898ed6598c03e9a", - "exception-list-agnostic": "2634ee4219d27663a5755536fc06cbf3bb4beba5", + "exception-list": "38181294f64fc406c15f20d85ca306c8a4feb3c0", + "exception-list-agnostic": "d527ce9d12b134cb163150057b87529043a8ec77", "file": "d12998f49bc82da596a9e6c8397999930187ec6a", "file-upload-usage-collection-telemetry": "c6fcb9a7efcf19b2bb66ca6e005bfee8961f6073", "fileShare": "f07d346acbb724eacf139a0fb781c38dc5280115", diff --git a/src/plugins/discover/public/__mocks__/data_view.ts b/src/plugins/discover/public/__mocks__/data_view.ts index b63321674ce7b..bb57d9eb932ed 100644 --- a/src/plugins/discover/public/__mocks__/data_view.ts +++ b/src/plugins/discover/public/__mocks__/data_view.ts @@ -109,6 +109,9 @@ export const buildDataViewMock = ({ getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), isTimeNanosBased: () => false, isPersisted: () => true, + getTimeField: () => { + return dataViewFields.find((field) => field.name === timeFieldName); + }, } as unknown as DataView; dataView.isTimeBased = () => !!timeFieldName; diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 648ba828489bf..8c1b10f236e9d 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -51,6 +51,10 @@ export function createDiscoverServicesMock(): DiscoverServices { dataPlugin.query.getState = jest.fn(() => ({ query: { query: '', language: 'lucene' }, filters: [], + time: { + from: 'now-15m', + to: 'now', + }, })); dataPlugin.dataViews = createDiscoverDataViewsMock(); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx index 765e8edfcdbaf..2396325ac82cc 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx @@ -26,18 +26,15 @@ import { buildDataTableRecord } from '../../../../utils/build_data_record'; import { DiscoverHistogramLayout, DiscoverHistogramLayoutProps } from './discover_histogram_layout'; import { SavedSearch, VIEW_MODE } from '@kbn/saved-search-plugin/public'; import { CoreTheme } from '@kbn/core/public'; -import { act } from 'react-dom/test-utils'; import { Storage } from '@kbn/kibana-utils-plugin/public'; -import { LocalStorageMock } from '../../../../__mocks__/local_storage_mock'; -import { HISTOGRAM_HEIGHT_KEY } from './use_discover_histogram'; import { createSearchSessionMock } from '../../../../__mocks__/search_session'; import { RequestAdapter } from '@kbn/inspector-plugin/public'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; -import { UnifiedHistogramLayout } from '@kbn/unified-histogram-plugin/public'; import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mocks'; import { ResetSearchButton } from './reset_search_button'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { DiscoverMainProvider } from '../../services/discover_state_provider'; +import { act } from 'react-dom/test-utils'; function getStateContainer() { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); @@ -52,18 +49,19 @@ function getStateContainer() { return stateContainer; } -const mountComponent = ({ +const mountComponent = async ({ isPlainRecord = false, - isTimeBased = true, storage, savedSearch = savedSearchMock, resetSavedSearch = jest.fn(), + searchSessionId = '123', }: { isPlainRecord?: boolean; isTimeBased?: boolean; storage?: Storage; savedSearch?: SavedSearch; resetSavedSearch?(): void; + searchSessionId?: string | null; } = {}) => { let services = discoverServiceMock; services.data.query.timefilter.timefilter.getAbsoluteTime = () => { @@ -114,7 +112,7 @@ const mountComponent = ({ const session = getSessionServiceMock(); - session.getSession$.mockReturnValue(new BehaviorSubject('123')); + session.getSession$.mockReturnValue(new BehaviorSubject(searchSessionId ?? undefined)); const stateContainer = getStateContainer(); stateContainer.dataState.data$ = savedSearchData$; @@ -131,7 +129,6 @@ const mountComponent = ({ viewMode: VIEW_MODE.DOCUMENT_LEVEL, onAddFilter: jest.fn(), resetSavedSearch, - isTimeBased, resizeRef: { current: null }, searchSessionManager: createSearchSessionMock(session).searchSessionManager, inspectorAdapters: { requests: new RequestAdapter() }, @@ -149,65 +146,39 @@ const mountComponent = ({ ); + // wait for lazy modules + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + component.update(); + return component; }; describe('Discover histogram layout component', () => { - describe('topPanelHeight persistence', () => { - it('should try to get the initial topPanelHeight for UnifiedHistogramLayout from storage', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - const originalGet = storage.get; - storage.get = jest.fn().mockImplementation(originalGet); - mountComponent({ storage }); - expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY); + describe('render', () => { + it('should render null if there is no search session', async () => { + const component = await mountComponent({ searchSessionId: null }); + expect(component.isEmptyRender()).toBe(true); }); - it('should pass undefined to UnifiedHistogramLayout if no value is found in storage', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - const originalGet = storage.get; - storage.get = jest.fn().mockImplementation(originalGet); - const component = mountComponent({ storage }); - expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY); - expect(storage.get).toHaveReturnedWith(null); - expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).toBe(undefined); + it('should not render null if there is a search session', async () => { + const component = await mountComponent(); + expect(component.isEmptyRender()).toBe(false); }); - it('should pass the stored topPanelHeight to UnifiedHistogramLayout if a value is found in storage', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - const topPanelHeight = 123; - storage.get = jest.fn().mockImplementation(() => topPanelHeight); - const component = mountComponent({ storage }); - expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY); - expect(storage.get).toHaveReturnedWith(topPanelHeight); - expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).toBe(topPanelHeight); - }); - - it('should update the topPanelHeight in storage and pass the new value to UnifiedHistogramLayout when the topPanelHeight changes', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - const originalSet = storage.set; - storage.set = jest.fn().mockImplementation(originalSet); - const component = mountComponent({ storage }); - const newTopPanelHeight = 123; - expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).not.toBe( - newTopPanelHeight - ); - act(() => { - component.find(UnifiedHistogramLayout).prop('onTopPanelHeightChange')!(newTopPanelHeight); - }); - component.update(); - expect(storage.set).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY, newTopPanelHeight); - expect(component.find(UnifiedHistogramLayout).prop('topPanelHeight')).toBe(newTopPanelHeight); + it('should not render null if there is no search session, but isPlainRecord is true', async () => { + const component = await mountComponent({ isPlainRecord: true }); + expect(component.isEmptyRender()).toBe(false); }); }); describe('reset search button', () => { it('renders the button when there is a saved search', async () => { - const component = mountComponent(); + const component = await mountComponent(); expect(component.find(ResetSearchButton).exists()).toBe(true); }); it('does not render the button when there is no saved search', async () => { - const component = mountComponent({ + const component = await mountComponent({ savedSearch: { ...savedSearchMock, id: undefined }, }); expect(component.find(ResetSearchButton).exists()).toBe(false); @@ -215,7 +186,7 @@ describe('Discover histogram layout component', () => { it('should call resetSavedSearch when clicked', async () => { const resetSavedSearch = jest.fn(); - const component = mountComponent({ resetSavedSearch }); + const component = await mountComponent({ resetSavedSearch }); component.find(ResetSearchButton).find('button').simulate('click'); expect(resetSavedSearch).toHaveBeenCalled(); }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx index 28a6d6c051787..68a5d8e1c7ee9 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx @@ -7,9 +7,9 @@ */ import React, { RefObject } from 'react'; -import { UnifiedHistogramLayout } from '@kbn/unified-histogram-plugin/public'; +import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public'; import { css } from '@emotion/react'; -import { useDiscoverServices } from '../../../../hooks/use_discover_services'; +import useObservable from 'react-use/lib/useObservable'; import { useDiscoverHistogram } from './use_discover_histogram'; import type { DiscoverSearchSessionManager } from '../../services/discover_search_session'; import type { InspectorAdapters } from '../../hooks/use_inspector'; @@ -18,68 +18,65 @@ import { ResetSearchButton } from './reset_search_button'; export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps { resetSavedSearch: () => void; - isTimeBased: boolean; resizeRef: RefObject; inspectorAdapters: InspectorAdapters; searchSessionManager: DiscoverSearchSessionManager; } +const histogramLayoutCss = css` + height: 100%; +`; + export const DiscoverHistogramLayout = ({ isPlainRecord, dataView, resetSavedSearch, savedSearch, stateContainer, - isTimeBased, resizeRef, inspectorAdapters, searchSessionManager, ...mainContentProps }: DiscoverHistogramLayoutProps) => { - const services = useDiscoverServices(); - const commonProps = { dataView, - isPlainRecord, stateContainer, - savedSearch, savedSearchData$: stateContainer.dataState.data$, }; - const histogramProps = useDiscoverHistogram({ - isTimeBased, + const searchSessionId = useObservable(searchSessionManager.searchSessionId$); + + const { hideChart, setUnifiedHistogramApi } = useDiscoverHistogram({ inspectorAdapters, - searchSessionManager, savedSearchFetch$: stateContainer.dataState.fetch$, + searchSessionId, ...commonProps, }); - if (!histogramProps) { + // Initialized when the first search has been requested or + // when in text-based mode since search sessions are not supported + if (!searchSessionId && !isPlainRecord) { return null; } - const histogramLayoutCss = css` - height: 100%; - `; - return ( - : undefined } css={histogramLayoutCss} - {...histogramProps} > - + ); }; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx index 13994b401b044..f14d89ead5b81 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx @@ -40,10 +40,11 @@ import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock' import { createSearchSessionMock } from '../../../../__mocks__/search_session'; import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mocks'; import { DiscoverMainProvider } from '../../services/discover_state_provider'; +import { act } from 'react-dom/test-utils'; setHeaderActionMenuMounter(jest.fn()); -function mountComponent( +async function mountComponent( dataView: DataView, prevSidebarClosed?: boolean, mountOptions: { attachTo?: HTMLElement } = {}, @@ -57,13 +58,17 @@ function mountComponent( [SIDEBAR_CLOSED_KEY]: prevSidebarClosed, }) as unknown as Storage, } as unknown as DiscoverServices; - services.data.query.timefilter.timefilter.getTime = () => { - return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; - }; + const time = { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; + services.data.query.timefilter.timefilter.getTime = () => time; (services.data.query.queryString.getDefaultQuery as jest.Mock).mockReturnValue({ language: 'kuery', query: '', }); + (services.data.query.getState as jest.Mock).mockReturnValue({ + filters: [], + query, + time, + }); (searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation( jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: 2 } } })) ); @@ -132,13 +137,19 @@ function mountComponent( mountOptions ); + // wait for lazy modules + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + component.update(); + return component; } describe('Discover component', () => { test('selected data view without time field displays no chart toggle', async () => { const container = document.createElement('div'); - mountComponent(dataViewMock, undefined, { attachTo: container }); + await mountComponent(dataViewMock, undefined, { attachTo: container }); expect( container.querySelector('[data-test-subj="unifiedHistogramChartOptionsToggle"]') ).toBeNull(); @@ -146,7 +157,7 @@ describe('Discover component', () => { test('selected data view with time field displays chart toggle', async () => { const container = document.createElement('div'); - mountComponent(dataViewWithTimefieldMock, undefined, { attachTo: container }); + await mountComponent(dataViewWithTimefieldMock, undefined, { attachTo: container }); expect( container.querySelector('[data-test-subj="unifiedHistogramChartOptionsToggle"]') ).not.toBeNull(); @@ -154,7 +165,7 @@ describe('Discover component', () => { test('sql query displays no chart toggle', async () => { const container = document.createElement('div'); - mountComponent( + await mountComponent( dataViewWithTimefieldMock, false, { attachTo: container }, @@ -169,7 +180,7 @@ describe('Discover component', () => { test('the saved search title h1 gains focus on navigate', async () => { const container = document.createElement('div'); document.body.appendChild(container); - const component = mountComponent(dataViewWithTimefieldMock, undefined, { + const component = await mountComponent(dataViewWithTimefieldMock, undefined, { attachTo: container, }); expect( @@ -179,17 +190,17 @@ describe('Discover component', () => { describe('sidebar', () => { test('should be opened if discover:sidebarClosed was not set', async () => { - const component = mountComponent(dataViewWithTimefieldMock, undefined); + const component = await mountComponent(dataViewWithTimefieldMock, undefined); expect(component.find(DiscoverSidebar).length).toBe(1); }, 10000); test('should be opened if discover:sidebarClosed is false', async () => { - const component = mountComponent(dataViewWithTimefieldMock, false); + const component = await mountComponent(dataViewWithTimefieldMock, false); expect(component.find(DiscoverSidebar).length).toBe(1); }, 10000); test('should be closed if discover:sidebarClosed is true', async () => { - const component = mountComponent(dataViewWithTimefieldMock, true); + const component = await mountComponent(dataViewWithTimefieldMock, true); expect(component.find(DiscoverSidebar).length).toBe(0); }, 10000); }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index 2eec55daf742d..cb7f9c64ff625 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -239,7 +239,6 @@ export function DiscoverLayout({ setExpandedDoc={setExpandedDoc} savedSearch={savedSearch} stateContainer={stateContainer} - isTimeBased={isTimeBased} columns={currentColumns} viewMode={viewMode} onAddFilter={onAddFilter as DocViewFilterFn} diff --git a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx index b075e613b6a55..8463b380efc8f 100644 --- a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import React, { ReactElement } from 'react'; import { buildDataTableRecord } from '../../../../utils/build_data_record'; import { esHits } from '../../../../__mocks__/es_hits'; @@ -20,51 +21,42 @@ import { RecordRawType, } from '../../services/discover_data_state_container'; import type { DiscoverStateContainer } from '../../services/discover_state'; -import { savedSearchMock } from '../../../../__mocks__/saved_search'; -import type { Storage } from '@kbn/kibana-utils-plugin/public'; -import { LocalStorageMock } from '../../../../__mocks__/local_storage_mock'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield'; -import { - CHART_HIDDEN_KEY, - HISTOGRAM_HEIGHT_KEY, - useDiscoverHistogram, - UseDiscoverHistogramProps, -} from './use_discover_histogram'; +import { useDiscoverHistogram, UseDiscoverHistogramProps } from './use_discover_histogram'; import { setTimeout } from 'timers/promises'; -import { calculateBounds } from '@kbn/data-plugin/public'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { DiscoverMainProvider } from '../../services/discover_state_provider'; -import { createSearchSessionMock } from '../../../../__mocks__/search_session'; import { RequestAdapter } from '@kbn/inspector-plugin/public'; -import { getSessionServiceMock } from '@kbn/data-plugin/public/search/session/mocks'; -import { UnifiedHistogramFetchStatus } from '@kbn/unified-histogram-plugin/public'; +import { + UnifiedHistogramFetchStatus, + UnifiedHistogramInitializeOptions, + UnifiedHistogramState, +} from '@kbn/unified-histogram-plugin/public'; +import { createMockUnifiedHistogramApi } from '@kbn/unified-histogram-plugin/public/mocks'; import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages'; import type { InspectorAdapters } from '../../hooks/use_inspector'; -import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; -import { DiscoverSearchSessionManager } from '../../services/discover_search_session'; const mockData = dataPluginMock.createStartContract(); - -mockData.query.timefilter.timefilter.getTime = () => { - return { from: '1991-03-29T08:04:00.694Z', to: '2021-03-29T07:04:00.695Z' }; -}; -mockData.query.timefilter.timefilter.calculateBounds = (timeRange) => { - return calculateBounds(timeRange); -}; - -const mockLens = { - navigateToPrefilledEditor: jest.fn(), +const mockQueryState = { + query: { + query: 'query', + language: 'kuery', + }, + filters: [], + time: { + from: 'now-15m', + to: 'now', + }, }; -let mockStorage = new LocalStorageMock({}) as unknown as Storage; -let mockCanVisualize = true; +mockData.query.getState = () => mockQueryState; jest.mock('../../../../hooks/use_discover_services', () => { const originalModule = jest.requireActual('../../../../hooks/use_discover_services'); return { ...originalModule, - useDiscoverServices: () => ({ storage: mockStorage, data: mockData, lens: mockLens }), + useDiscoverServices: () => ({ data: mockData }), }; }); @@ -72,34 +64,14 @@ jest.mock('@kbn/unified-field-list-plugin/public', () => { const originalModule = jest.requireActual('@kbn/unified-field-list-plugin/public'); return { ...originalModule, - getVisualizeInformation: jest.fn(() => Promise.resolve(mockCanVisualize)), useQuerySubscriber: jest.fn(() => ({ - query: { - query: 'query', - language: 'kuery', - }, - filters: [], + ...mockQueryState, fromDate: 'now-15m', toDate: 'now', })), }; }); -function getStateContainer() { - const stateContainer = getDiscoverStateMock({ isTimeBased: true }); - - stateContainer.setAppState({ - interval: 'auto', - hideChart: false, - breakdownField: 'extension', - }); - - const wrappedStateContainer = Object.create(stateContainer); - wrappedStateContainer.setAppState = jest.fn((newState) => stateContainer.setAppState(newState)); - - return wrappedStateContainer; -} - jest.mock('../../hooks/use_saved_search_messages', () => { const originalModule = jest.requireActual('../../hooks/use_saved_search_messages'); return { @@ -112,13 +84,20 @@ jest.mock('../../hooks/use_saved_search_messages', () => { const mockCheckHitCount = checkHitCount as jest.MockedFunction; describe('useDiscoverHistogram', () => { + const getStateContainer = () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.setAppState({ + interval: 'auto', + hideChart: false, + breakdownField: 'extension', + }); + const wrappedStateContainer = Object.create(stateContainer); + wrappedStateContainer.setAppState = jest.fn((newState) => stateContainer.setAppState(newState)); + return wrappedStateContainer; + }; + const renderUseDiscoverHistogram = async ({ - isPlainRecord = false, - isTimeBased = true, - canVisualize = true, - storage = new LocalStorageMock({}) as unknown as Storage, stateContainer = getStateContainer(), - searchSessionManager, searchSessionId = '123', inspectorAdapters = { requests: new RequestAdapter() }, totalHits$ = new BehaviorSubject({ @@ -127,26 +106,18 @@ describe('useDiscoverHistogram', () => { }) as DataTotalHits$, main$ = new BehaviorSubject({ fetchStatus: FetchStatus.COMPLETE, - recordRawType: isPlainRecord ? RecordRawType.PLAIN : RecordRawType.DOCUMENT, + recordRawType: RecordRawType.DOCUMENT, foundDocuments: true, }) as DataMain$, savedSearchFetch$ = new Subject() as DataFetch$, }: { - isPlainRecord?: boolean; - isTimeBased?: boolean; - canVisualize?: boolean; - storage?: Storage; stateContainer?: DiscoverStateContainer; - searchSessionManager?: DiscoverSearchSessionManager; - searchSessionId?: string | null; + searchSessionId?: string; inspectorAdapters?: InspectorAdapters; totalHits$?: DataTotalHits$; main$?: DataMain$; savedSearchFetch$?: DataFetch$; } = {}) => { - mockStorage = storage; - mockCanVisualize = canVisualize; - const documents$ = new BehaviorSubject({ fetchStatus: FetchStatus.COMPLETE, result: esHits.map((esHit) => buildDataTableRecord(esHit, dataViewWithTimefieldMock)), @@ -164,22 +135,13 @@ describe('useDiscoverHistogram', () => { availableFields$, }; - if (!searchSessionManager) { - const session = getSessionServiceMock(); - session.getSession$.mockReturnValue(new BehaviorSubject(searchSessionId ?? undefined)); - searchSessionManager = createSearchSessionMock(session).searchSessionManager; - } - const initialProps = { stateContainer, savedSearchData$, savedSearchFetch$, dataView: dataViewWithTimefieldMock, - savedSearch: savedSearchMock, - isTimeBased, - isPlainRecord, inspectorAdapters, - searchSessionManager: searchSessionManager!, + searchSessionId, }; const Wrapper: WrapperComponent = ({ children }) => ( @@ -201,262 +163,188 @@ describe('useDiscoverHistogram', () => { return { hook, initialProps }; }; - describe('result', () => { - it('should return undefined if there is no search session', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ searchSessionId: null }); - expect(result.current).toBeUndefined(); - }); - - it('it should not return undefined if there is no search session, but isPlainRecord is true', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ searchSessionId: null, isPlainRecord: true }); - expect(result.current).toBeDefined(); - }); - }); - - describe('contexts', () => { - it('should output the correct hits context', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram(); - expect(result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.complete); - expect(result.current?.hits?.total).toEqual(esHits.length); - }); - - it('should output the correct chart context', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram(); - expect(result.current?.chart?.hidden).toBe(false); - expect(result.current?.chart?.timeInterval).toBe('auto'); - }); - - it('should output the correct breakdown context', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram(); - expect(result.current?.breakdown?.field?.name).toBe('extension'); - }); - - it('should output the correct request context', async () => { - const requestAdapter = new RequestAdapter(); - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ - searchSessionId: '321', - inspectorAdapters: { requests: requestAdapter }, - }); - expect(result.current?.request.adapter).toBe(requestAdapter); - expect(result.current?.request.searchSessionId).toBe('321'); - }); - - it('should output undefined for hits and chart and breakdown if isPlainRecord is true', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ isPlainRecord: true }); - expect(result.current?.hits).toBeUndefined(); - expect(result.current?.chart).toBeUndefined(); - expect(result.current?.breakdown).toBeUndefined(); - }); - - it('should output undefined for chart and breakdown if isTimeBased is false', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ isTimeBased: false }); - expect(result.current?.hits).not.toBeUndefined(); - expect(result.current?.chart).toBeUndefined(); - expect(result.current?.breakdown).toBeUndefined(); - }); - - it('should clear lensRequests when chart is undefined', async () => { - const inspectorAdapters = { - requests: new RequestAdapter(), - lensRequests: new RequestAdapter(), - }; - const { hook, initialProps } = await renderUseDiscoverHistogram({ - inspectorAdapters, - }); - expect(inspectorAdapters.lensRequests).toBeDefined(); - hook.rerender({ ...initialProps, isPlainRecord: true }); - expect(inspectorAdapters.lensRequests).toBeUndefined(); - }); - }); - - describe('search params', () => { - it('should return the correct query, filters, and timeRange', async () => { + describe('initialization', () => { + it('should pass the expected parameters to initialize', async () => { const { hook } = await renderUseDiscoverHistogram(); - expect(hook.result.current?.query).toEqual({ - query: 'query', - language: 'kuery', - }); - expect(hook.result.current?.filters).toEqual([]); - expect(hook.result.current?.timeRange).toEqual({ - from: 'now-15m', - to: 'now', + const api = createMockUnifiedHistogramApi(); + let params: UnifiedHistogramInitializeOptions | undefined; + api.initialize = jest.fn((p) => { + params = p; }); - }); - }); - - describe('onEditVisualization', () => { - it('returns a callback for onEditVisualization when the data view can be visualized', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram(); - expect(result.current?.onEditVisualization).toBeDefined(); - }); - - it('returns undefined for onEditVisualization when the data view cannot be visualized', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ canVisualize: false }); - expect(result.current?.onEditVisualization).toBeUndefined(); - }); - - it('should call lens.navigateToPrefilledEditor when onEditVisualization is called', async () => { - const { - hook: { result }, - } = await renderUseDiscoverHistogram(); - const attributes = { title: 'test' } as TypedLensByValueInput['attributes']; - result.current?.onEditVisualization!(attributes); - expect(mockLens.navigateToPrefilledEditor).toHaveBeenCalledWith({ - id: '', - timeRange: mockData.query.timefilter.timefilter.getTime(), - attributes, + act(() => { + hook.result.current.setUnifiedHistogramApi(api); }); + expect(api.initialize).toHaveBeenCalled(); + expect(params?.localStorageKeyPrefix).toBe('discover'); + expect(params?.disableAutoFetching).toBe(true); + expect(Object.keys(params?.initialState ?? {})).toEqual([ + 'dataView', + 'query', + 'filters', + 'timeRange', + 'chartHidden', + 'timeInterval', + 'breakdownField', + 'searchSessionId', + 'totalHitsStatus', + 'totalHitsResult', + 'requestAdapter', + ]); }); }); - describe('topPanelHeight', () => { - it('should try to get the topPanelHeight from storage', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - storage.get = jest.fn(() => 100); - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ storage }); - expect(storage.get).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY); - expect(result.current?.topPanelHeight).toBe(100); - }); - - it('should update topPanelHeight when onTopPanelHeightChange is called', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - storage.get = jest.fn(() => 100); - storage.set = jest.fn(); - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ storage }); - expect(result.current?.topPanelHeight).toBe(100); + describe('state', () => { + it('should subscribe to state changes', async () => { + const { hook } = await renderUseDiscoverHistogram(); + const api = createMockUnifiedHistogramApi({ initialized: true }); + jest.spyOn(api.state$, 'subscribe'); act(() => { - result.current?.onTopPanelHeightChange(200); + hook.result.current.setUnifiedHistogramApi(api); }); - expect(storage.set).toHaveBeenCalledWith(HISTOGRAM_HEIGHT_KEY, 200); - expect(result.current?.topPanelHeight).toBe(200); + expect(api.state$.subscribe).toHaveBeenCalledTimes(2); }); - }); - describe('callbacks', () => { - it('should update chartHidden when onChartHiddenChange is called', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - storage.set = jest.fn(); + it('should sync Unified Histogram state with the state container', async () => { const stateContainer = getStateContainer(); - const session = getSessionServiceMock(); - const session$ = new BehaviorSubject('123'); - session.getSession$.mockReturnValue(session$); - const inspectorAdapters = { - requests: new RequestAdapter(), - lensRequests: new RequestAdapter(), - }; - const { hook } = await renderUseDiscoverHistogram({ - storage, - stateContainer, - searchSessionManager: createSearchSessionMock(session).searchSessionManager, - inspectorAdapters, - }); + const inspectorAdapters = { requests: new RequestAdapter(), lensRequests: undefined }; + const { hook } = await renderUseDiscoverHistogram({ stateContainer, inspectorAdapters }); + const lensRequestAdapter = new RequestAdapter(); + const state = { + timeInterval: '1m', + chartHidden: true, + breakdownField: 'test', + totalHitsStatus: UnifiedHistogramFetchStatus.loading, + totalHitsResult: undefined, + } as unknown as UnifiedHistogramState; + const api = createMockUnifiedHistogramApi({ initialized: true }); + api.state$ = new BehaviorSubject({ ...state, lensRequestAdapter }); act(() => { - hook.result.current?.onChartHiddenChange(false); + hook.result.current.setUnifiedHistogramApi(api); }); - expect(inspectorAdapters.lensRequests).toBeDefined(); - expect(storage.set).toHaveBeenCalledWith(CHART_HIDDEN_KEY, false); - expect(stateContainer.setAppState).toHaveBeenCalledWith({ hideChart: false }); - act(() => { - hook.result.current?.onChartHiddenChange(true); - session$.next('321'); + expect(inspectorAdapters.lensRequests).toBe(lensRequestAdapter); + expect(stateContainer.setAppState).toHaveBeenCalledWith({ + interval: state.timeInterval, + hideChart: state.chartHidden, + breakdownField: state.breakdownField, }); - hook.rerender(); - expect(inspectorAdapters.lensRequests).toBeUndefined(); - expect(storage.set).toHaveBeenCalledWith(CHART_HIDDEN_KEY, true); - expect(stateContainer.setAppState).toHaveBeenCalledWith({ hideChart: true }); }); - it('should set lensRequests when onChartLoad is called', async () => { - const lensRequests = new RequestAdapter(); - const inspectorAdapters = { - requests: new RequestAdapter(), - lensRequests: undefined as RequestAdapter | undefined, - }; - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ inspectorAdapters }); - expect(inspectorAdapters.lensRequests).toBeUndefined(); + it('should not sync Unified Histogram state with the state container if there are no changes', async () => { + const stateContainer = getStateContainer(); + const { hook } = await renderUseDiscoverHistogram({ stateContainer }); + const containerState = stateContainer.appState.getState(); + const state = { + timeInterval: containerState.interval, + chartHidden: containerState.hideChart, + breakdownField: containerState.breakdownField, + totalHitsStatus: UnifiedHistogramFetchStatus.loading, + totalHitsResult: undefined, + } as unknown as UnifiedHistogramState; + const api = createMockUnifiedHistogramApi({ initialized: true }); + api.state$ = new BehaviorSubject(state); act(() => { - result.current?.onChartLoad({ adapters: { requests: lensRequests } }); + hook.result.current.setUnifiedHistogramApi(api); }); - expect(inspectorAdapters.lensRequests).toBeDefined(); + expect(stateContainer.setAppState).not.toHaveBeenCalled(); }); - it('should update chart hidden when onChartHiddenChange is called', async () => { - const storage = new LocalStorageMock({}) as unknown as Storage; - storage.set = jest.fn(); + it('should sync the state container state with Unified Histogram', async () => { const stateContainer = getStateContainer(); - const inspectorAdapters = { - requests: new RequestAdapter(), - lensRequests: new RequestAdapter(), - }; - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ - storage, - stateContainer, - inspectorAdapters, + const { hook } = await renderUseDiscoverHistogram({ stateContainer }); + const api = createMockUnifiedHistogramApi({ initialized: true }); + let params: Partial = {}; + api.setRequestParams = jest.fn((p) => { + params = { ...params, ...p }; }); - act(() => { - result.current?.onChartHiddenChange(true); + api.setTotalHits = jest.fn((p) => { + params = { ...params, ...p }; }); - expect(storage.set).toHaveBeenCalledWith(CHART_HIDDEN_KEY, true); - expect(stateContainer.setAppState).toHaveBeenCalledWith({ hideChart: true }); - }); - - it('should update interval when onTimeIntervalChange is called', async () => { - const stateContainer = getStateContainer(); - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ - stateContainer, + api.setChartHidden = jest.fn((chartHidden) => { + params = { ...params, chartHidden }; + }); + api.setTimeInterval = jest.fn((timeInterval) => { + params = { ...params, timeInterval }; + }); + api.setBreakdownField = jest.fn((breakdownField) => { + params = { ...params, breakdownField }; }); act(() => { - result.current?.onTimeIntervalChange('auto'); + hook.result.current.setUnifiedHistogramApi(api); }); - expect(stateContainer.setAppState).toHaveBeenCalledWith({ interval: 'auto' }); + expect(api.setRequestParams).toHaveBeenCalled(); + expect(api.setTotalHits).toHaveBeenCalled(); + expect(api.setChartHidden).toHaveBeenCalled(); + expect(api.setTimeInterval).toHaveBeenCalled(); + expect(api.setBreakdownField).toHaveBeenCalled(); + expect(Object.keys(params ?? {})).toEqual([ + 'dataView', + 'query', + 'filters', + 'timeRange', + 'searchSessionId', + 'requestAdapter', + 'totalHitsStatus', + 'totalHitsResult', + 'chartHidden', + 'timeInterval', + 'breakdownField', + ]); }); - it('should update breakdownField when onBreakdownFieldChange is called', async () => { + it('should exclude totalHitsStatus and totalHitsResult from Unified Histogram state updates after the first load', async () => { const stateContainer = getStateContainer(); - const { - hook: { result }, - } = await renderUseDiscoverHistogram({ - stateContainer, + const { hook, initialProps } = await renderUseDiscoverHistogram({ stateContainer }); + const containerState = stateContainer.appState.getState(); + const state = { + timeInterval: containerState.interval, + chartHidden: containerState.hideChart, + breakdownField: containerState.breakdownField, + totalHitsStatus: UnifiedHistogramFetchStatus.loading, + totalHitsResult: undefined, + } as unknown as UnifiedHistogramState; + const api = createMockUnifiedHistogramApi({ initialized: true }); + let params: Partial = {}; + api.setRequestParams = jest.fn((p) => { + params = { ...params, ...p }; + }); + api.setTotalHits = jest.fn((p) => { + params = { ...params, ...p }; }); + const subject$ = new BehaviorSubject(state); + api.state$ = subject$; act(() => { - result.current?.onBreakdownFieldChange( - dataViewWithTimefieldMock.getFieldByName('extension') - ); + hook.result.current.setUnifiedHistogramApi(api); }); - expect(stateContainer.setAppState).toHaveBeenCalledWith({ breakdownField: 'extension' }); + expect(Object.keys(params ?? {})).toEqual([ + 'dataView', + 'query', + 'filters', + 'timeRange', + 'searchSessionId', + 'requestAdapter', + 'totalHitsStatus', + 'totalHitsResult', + ]); + params = {}; + hook.rerender({ ...initialProps, searchSessionId: '321' }); + act(() => { + subject$.next({ + ...state, + totalHitsStatus: UnifiedHistogramFetchStatus.complete, + totalHitsResult: 100, + }); + }); + expect(Object.keys(params ?? {})).toEqual([ + 'dataView', + 'query', + 'filters', + 'timeRange', + 'searchSessionId', + 'requestAdapter', + ]); }); - it('should update total hits when onTotalHitsChange is called', async () => { + it('should update total hits when the total hits state changes', async () => { mockCheckHitCount.mockClear(); const totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.LOADING, @@ -467,13 +355,25 @@ describe('useDiscoverHistogram', () => { recordRawType: RecordRawType.DOCUMENT, foundDocuments: true, }) as DataMain$; - const { hook } = await renderUseDiscoverHistogram({ totalHits$, main$ }); + const stateContainer = getStateContainer(); + const { hook } = await renderUseDiscoverHistogram({ stateContainer, totalHits$, main$ }); + const containerState = stateContainer.appState.getState(); + const state = { + timeInterval: containerState.interval, + chartHidden: containerState.hideChart, + breakdownField: containerState.breakdownField, + totalHitsStatus: UnifiedHistogramFetchStatus.loading, + totalHitsResult: undefined, + } as unknown as UnifiedHistogramState; + const api = createMockUnifiedHistogramApi({ initialized: true }); + api.state$ = new BehaviorSubject({ + ...state, + totalHitsStatus: UnifiedHistogramFetchStatus.complete, + totalHitsResult: 100, + }); act(() => { - hook.result.current?.onTotalHitsChange(UnifiedHistogramFetchStatus.complete, 100); + hook.result.current.setUnifiedHistogramApi(api); }); - hook.rerender(); - expect(hook.result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.complete); - expect(hook.result.current?.hits?.total).toBe(100); expect(totalHits$.value).toEqual({ fetchStatus: FetchStatus.COMPLETE, result: 100, @@ -481,105 +381,80 @@ describe('useDiscoverHistogram', () => { expect(mockCheckHitCount).toHaveBeenCalledWith(main$, 100); }); - it('should not update total hits when onTotalHitsChange is called with an error', async () => { + it('should not update total hits when the total hits state changes to an error', async () => { mockCheckHitCount.mockClear(); const totalHits$ = new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED, result: undefined, }) as DataTotalHits$; - const { hook } = await renderUseDiscoverHistogram({ totalHits$ }); + const stateContainer = getStateContainer(); + const { hook } = await renderUseDiscoverHistogram({ stateContainer, totalHits$ }); + const containerState = stateContainer.appState.getState(); const error = new Error('test'); + const state = { + timeInterval: containerState.interval, + chartHidden: containerState.hideChart, + breakdownField: containerState.breakdownField, + totalHitsStatus: UnifiedHistogramFetchStatus.loading, + totalHitsResult: undefined, + } as unknown as UnifiedHistogramState; + const api = createMockUnifiedHistogramApi({ initialized: true }); + api.state$ = new BehaviorSubject({ + ...state, + totalHitsStatus: UnifiedHistogramFetchStatus.error, + totalHitsResult: error, + }); act(() => { - hook.result.current?.onTotalHitsChange(UnifiedHistogramFetchStatus.error, error); + hook.result.current.setUnifiedHistogramApi(api); }); - hook.rerender(); expect(sendErrorTo).toHaveBeenCalledWith(mockData, totalHits$); - expect(hook.result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.error); - expect(hook.result.current?.hits?.total).toBeUndefined(); expect(totalHits$.value).toEqual({ fetchStatus: FetchStatus.ERROR, error, }); expect(mockCheckHitCount).not.toHaveBeenCalled(); }); - - it('should not update total hits when onTotalHitsChange is called with a loading status while totalHits$ has a partial status', async () => { - mockCheckHitCount.mockClear(); - const totalHits$ = new BehaviorSubject({ - fetchStatus: FetchStatus.PARTIAL, - result: undefined, - }) as DataTotalHits$; - const { hook } = await renderUseDiscoverHistogram({ totalHits$ }); - act(() => { - hook.result.current?.onTotalHitsChange(UnifiedHistogramFetchStatus.loading, undefined); - }); - hook.rerender(); - expect(hook.result.current?.hits?.status).toBe(UnifiedHistogramFetchStatus.partial); - expect(hook.result.current?.hits?.total).toBeUndefined(); - expect(totalHits$.value).toEqual({ - fetchStatus: FetchStatus.PARTIAL, - result: undefined, - }); - expect(mockCheckHitCount).not.toHaveBeenCalled(); - }); }); describe('refetching', () => { - it("should call input$.next({ type: 'refetch' }) when savedSearchFetch$ is triggered", async () => { - const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' }); + it('should call refetch when savedSearchFetch$ is triggered', async () => { + const savedSearchFetch$ = new Subject<{ + reset: boolean; + searchSessionId: string; + }>(); const { hook } = await renderUseDiscoverHistogram({ savedSearchFetch$ }); - const onRefetch = jest.fn(); - hook.result.current?.input$.subscribe(onRefetch); + const api = createMockUnifiedHistogramApi({ initialized: true }); act(() => { - savedSearchFetch$.next({ reset: false, searchSessionId: '1234' }); - }); - expect(onRefetch).toHaveBeenCalledWith({ type: 'refetch' }); - }); - - it("should not call input$.next({ type: 'refetch' }) when searchSessionId is not set", async () => { - const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' }); - const { hook } = await renderUseDiscoverHistogram({ - savedSearchFetch$, - searchSessionId: null, + hook.result.current.setUnifiedHistogramApi(api); }); - const onRefetch = jest.fn(); - hook.result.current?.input$.subscribe(onRefetch); + expect(api.refetch).not.toHaveBeenCalled(); act(() => { savedSearchFetch$.next({ reset: false, searchSessionId: '1234' }); }); - expect(onRefetch).not.toHaveBeenCalled(); + expect(api.refetch).toHaveBeenCalled(); }); - it("should call input$.next({ type: 'refetch' }) when searchSessionId is not set and isPlainRecord is true", async () => { - const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' }); - const { hook } = await renderUseDiscoverHistogram({ - savedSearchFetch$, - searchSessionId: null, - isPlainRecord: true, - }); - const onRefetch = jest.fn(); - hook.result.current?.input$.subscribe(onRefetch); + it('should skip the next refetch when hideChart changes from true to false', async () => { + const stateContainer = getStateContainer(); + const savedSearchFetch$ = new Subject<{ + reset: boolean; + searchSessionId: string; + }>(); + const { hook } = await renderUseDiscoverHistogram({ stateContainer, savedSearchFetch$ }); + const api = createMockUnifiedHistogramApi({ initialized: true }); act(() => { - savedSearchFetch$.next({ reset: false, searchSessionId: '1234' }); + hook.result.current.setUnifiedHistogramApi(api); }); - expect(onRefetch).toHaveBeenCalledWith({ type: 'refetch' }); - }); - - it('should skip the next refetch when state.hideChart changes from true to false', async () => { - const savedSearchFetch$ = new BehaviorSubject({ reset: false, searchSessionId: '1234' }); - const { hook } = await renderUseDiscoverHistogram({ savedSearchFetch$ }); - const onRefetch = jest.fn(); - hook.result.current?.input$.subscribe(onRefetch); act(() => { - hook.result.current?.onChartHiddenChange(true); + stateContainer.setAppState({ hideChart: true }); }); act(() => { - hook.result.current?.onChartHiddenChange(false); + stateContainer.setAppState({ hideChart: false }); }); act(() => { savedSearchFetch$.next({ reset: false, searchSessionId: '1234' }); }); - expect(onRefetch).not.toHaveBeenCalled(); + expect(api.refetch).not.toHaveBeenCalled(); }); }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts index ad676b1a4247d..d259252f18686 100644 --- a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts +++ b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts @@ -6,277 +6,258 @@ * Side Public License, v 1. */ -import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import type { SavedSearch } from '@kbn/saved-search-plugin/public'; -import { getVisualizeInformation, useQuerySubscriber } from '@kbn/unified-field-list-plugin/public'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { useQuerySubscriber } from '@kbn/unified-field-list-plugin/public'; import { + UnifiedHistogramApi, UnifiedHistogramFetchStatus, - UnifiedHistogramHitsContext, - UnifiedHistogramInputMessage, + UnifiedHistogramInitializedApi, + UnifiedHistogramState, } from '@kbn/unified-histogram-plugin/public'; -import type { UnifiedHistogramChartLoadEvent } from '@kbn/unified-histogram-plugin/public'; -import useObservable from 'react-use/lib/useObservable'; -import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; -import { Subject } from 'rxjs'; -import { useAppStateSelector } from '../../services/discover_app_state_container'; -import { getUiActions } from '../../../../kibana_services'; +import { isEqual } from 'lodash'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { distinctUntilChanged, map, Observable } from 'rxjs'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; -import { useDataState } from '../../hooks/use_data_state'; -import type { DataFetch$, SavedSearchData } from '../../services/discover_data_state_container'; -import type { DiscoverStateContainer } from '../../services/discover_state'; +import { getUiActions } from '../../../../kibana_services'; import { FetchStatus } from '../../../types'; -import type { DiscoverSearchSessionManager } from '../../services/discover_search_session'; +import { useDataState } from '../../hooks/use_data_state'; import type { InspectorAdapters } from '../../hooks/use_inspector'; +import type { DataFetch$, SavedSearchData } from '../../services/discover_data_state_container'; import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages'; - -export const CHART_HIDDEN_KEY = 'discover:chartHidden'; -export const HISTOGRAM_HEIGHT_KEY = 'discover:histogramHeight'; -export const HISTOGRAM_BREAKDOWN_FIELD_KEY = 'discover:histogramBreakdownField'; +import { useAppStateSelector } from '../../services/discover_app_state_container'; +import type { DiscoverStateContainer } from '../../services/discover_state'; export interface UseDiscoverHistogramProps { stateContainer: DiscoverStateContainer; savedSearchData$: SavedSearchData; dataView: DataView; - savedSearch: SavedSearch; - isTimeBased: boolean; - isPlainRecord: boolean; inspectorAdapters: InspectorAdapters; - searchSessionManager: DiscoverSearchSessionManager; savedSearchFetch$: DataFetch$; + searchSessionId: string | undefined; } export const useDiscoverHistogram = ({ stateContainer, savedSearchData$, dataView, - savedSearch, - isTimeBased, - isPlainRecord, inspectorAdapters, - searchSessionManager, savedSearchFetch$, + searchSessionId, }: UseDiscoverHistogramProps) => { - const { storage, data, lens } = useDiscoverServices(); - const [hideChart, interval, breakdownField] = useAppStateSelector((state) => [ - state.hideChart, - state.interval, - state.breakdownField, - ]); + const services = useDiscoverServices(); + const timefilter = services.data.query.timefilter.timefilter; /** - * Visualize + * API initialization */ - const timeField = dataView.timeFieldName && dataView.getFieldByName(dataView.timeFieldName); - const [canVisualize, setCanVisualize] = useState(false); + const [unifiedHistogram, setUnifiedHistogram] = useState(); - useEffect(() => { - if (!timeField) { - return; - } - getVisualizeInformation( - getUiActions(), - timeField, - dataView, - savedSearch.columns || [], - [] - ).then((info) => { - setCanVisualize(Boolean(info)); - }); - }, [dataView, savedSearch.columns, timeField]); - - const onEditVisualization = useCallback( - (lensAttributes: TypedLensByValueInput['attributes']) => { - if (!timeField) { + const setUnifiedHistogramApi = useCallback( + (api: UnifiedHistogramApi | null) => { + if (!api) { return; } - lens.navigateToPrefilledEditor({ - id: '', - timeRange: data.query.timefilter.timefilter.getTime(), - attributes: lensAttributes, - }); + + if (api.initialized) { + setUnifiedHistogram(api); + } else { + const { + hideChart: chartHidden, + interval: timeInterval, + breakdownField, + } = stateContainer.appState.getState(); + + const { fetchStatus: totalHitsStatus, result: totalHitsResult } = + savedSearchData$.totalHits$.getValue(); + + const { query, filters, time: timeRange } = services.data.query.getState(); + + api.initialize({ + services: { ...services, uiActions: getUiActions() }, + localStorageKeyPrefix: 'discover', + disableAutoFetching: true, + getRelativeTimeRange: timefilter.getTime, + initialState: { + dataView, + query, + filters, + timeRange, + chartHidden, + timeInterval, + breakdownField, + searchSessionId, + totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus, + totalHitsResult, + requestAdapter: inspectorAdapters.requests, + }, + }); + } }, - [data.query.timefilter.timefilter, lens, timeField] + [ + dataView, + inspectorAdapters.requests, + savedSearchData$.totalHits$, + searchSessionId, + services, + stateContainer.appState, + timefilter.getTime, + ] ); /** - * Height + * Sync Unified Histogram state with Discover state */ - const [topPanelHeight, setTopPanelHeight] = useState(() => { - const storedHeight = storage.get(HISTOGRAM_HEIGHT_KEY); - return storedHeight ? Number(storedHeight) : undefined; - }); + useEffect(() => { + const subscription = createStateSyncObservable(unifiedHistogram?.state$)?.subscribe((state) => { + inspectorAdapters.lensRequests = state.lensRequestAdapter; + + const { hideChart, interval, breakdownField } = stateContainer.appState.getState(); + const oldState = { hideChart, interval, breakdownField }; + const newState = { + hideChart: state.chartHidden, + interval: state.timeInterval, + breakdownField: state.breakdownField, + }; + + if (!isEqual(oldState, newState)) { + stateContainer.setAppState(newState); + } + }); - const onTopPanelHeightChange = useCallback( - (newTopPanelHeight: number | undefined) => { - storage.set(HISTOGRAM_HEIGHT_KEY, newTopPanelHeight); - setTopPanelHeight(newTopPanelHeight); - }, - [storage] - ); + return () => { + subscription?.unsubscribe(); + }; + }, [inspectorAdapters, stateContainer, unifiedHistogram]); /** - * Time interval + * Update Unified Histgoram request params */ - const onTimeIntervalChange = useCallback( - (newInterval: string) => { - stateContainer.setAppState({ interval: newInterval }); - }, - [stateContainer] + const { + query, + filters, + fromDate: from, + toDate: to, + } = useQuerySubscriber({ data: services.data }); + + const timeRange = useMemo( + () => (from && to ? { from, to } : timefilter.getTimeDefaults()), + [timefilter, from, to] ); + useEffect(() => { + unifiedHistogram?.setRequestParams({ + dataView, + query, + filters, + timeRange, + searchSessionId, + requestAdapter: inspectorAdapters.requests, + }); + }, [ + dataView, + filters, + inspectorAdapters.requests, + query, + searchSessionId, + timeRange, + unifiedHistogram, + ]); + /** - * Total hits + * Override Unified Histgoram total hits with Discover partial results */ - const [localHitsContext, setLocalHitsContext] = useState(); - - const onTotalHitsChange = useCallback( - (status: UnifiedHistogramFetchStatus, result?: number | Error) => { - if (result instanceof Error) { - // Display the error and set totalHits$ to an error state - sendErrorTo(data, savedSearchData$.totalHits$)(result); - return; - } - - const { fetchStatus, recordRawType } = savedSearchData$.totalHits$.getValue(); - - // If we have a partial result already, we don't want to update the total hits back to loading - if (fetchStatus === FetchStatus.PARTIAL && status === UnifiedHistogramFetchStatus.loading) { - return; - } - - // Set a local copy of the hits context to pass to unified histogram - setLocalHitsContext({ status, total: result }); + const firstLoadComplete = useRef(false); - // Sync the totalHits$ observable with the unified histogram state - savedSearchData$.totalHits$.next({ - fetchStatus: status.toString() as FetchStatus, - result, - recordRawType, - }); - - // Check the hits count to set a partial or no results state - if (status === UnifiedHistogramFetchStatus.complete && typeof result === 'number') { - checkHitCount(savedSearchData$.main$, result); - } - }, - [data, savedSearchData$.main$, savedSearchData$.totalHits$] - ); - - // We only rely on the totalHits$ observable if we don't have a local hits context yet, - // since we only want to show the partial results on the first load, or there will be - // a flickering effect as the loading spinner is quickly shown and hidden again on fetches - const { fetchStatus: hitsFetchStatus, result: hitsTotal } = useDataState( + const { fetchStatus: totalHitsStatus, result: totalHitsResult } = useDataState( savedSearchData$.totalHits$ ); - const hits = useMemo( - () => - isPlainRecord - ? undefined - : localHitsContext ?? { - status: hitsFetchStatus.toString() as UnifiedHistogramFetchStatus, - total: hitsTotal, - }, - [hitsFetchStatus, hitsTotal, isPlainRecord, localHitsContext] - ); + useEffect(() => { + // We only want to show the partial results on the first load, + // or there will be a flickering effect as the loading spinner + // is quickly shown and hidden again on fetches + if (!firstLoadComplete.current) { + unifiedHistogram?.setTotalHits({ + totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus, + totalHitsResult, + }); + } + }, [totalHitsResult, totalHitsStatus, unifiedHistogram]); /** - * Chart + * Sync URL query params with Unified Histogram */ - const onChartHiddenChange = useCallback( - (chartHidden: boolean) => { - storage.set(CHART_HIDDEN_KEY, chartHidden); - stateContainer.setAppState({ hideChart: chartHidden }); - }, - [stateContainer, storage] - ); + const hideChart = useAppStateSelector((state) => state.hideChart); - const onChartLoad = useCallback( - (event: UnifiedHistogramChartLoadEvent) => { - // We need to store the Lens request adapter in order to inspect its requests - inspectorAdapters.lensRequests = event.adapters.requests; - }, - [inspectorAdapters] - ); - - const chart = useMemo( - () => - isPlainRecord || !isTimeBased - ? undefined - : { - hidden: hideChart, - timeInterval: interval, - }, - [hideChart, interval, isPlainRecord, isTimeBased] - ); - - // Clear the Lens request adapter when the chart is hidden useEffect(() => { - if (hideChart || !chart) { - inspectorAdapters.lensRequests = undefined; + if (typeof hideChart === 'boolean') { + unifiedHistogram?.setChartHidden(hideChart); } - }, [chart, hideChart, inspectorAdapters]); + }, [hideChart, unifiedHistogram]); - /** - * Breakdown - */ + const timeInterval = useAppStateSelector((state) => state.interval); - const onBreakdownFieldChange = useCallback( - (newBreakdownField: DataViewField | undefined) => { - stateContainer.setAppState({ breakdownField: newBreakdownField?.name }); - }, - [stateContainer] - ); + useEffect(() => { + if (timeInterval) { + unifiedHistogram?.setTimeInterval(timeInterval); + } + }, [timeInterval, unifiedHistogram]); - const field = useMemo( - () => (breakdownField ? dataView.getFieldByName(breakdownField) : undefined), - [dataView, breakdownField] - ); + const breakdownField = useAppStateSelector((state) => state.breakdownField); - const breakdown = useMemo( - () => (isPlainRecord || !isTimeBased ? undefined : { field }), - [field, isPlainRecord, isTimeBased] - ); + useEffect(() => { + unifiedHistogram?.setBreakdownField(breakdownField); + }, [breakdownField, unifiedHistogram]); /** - * Search params + * Total hits */ - const { query, filters, fromDate: from, toDate: to } = useQuerySubscriber({ data }); - const timeRange = useMemo( - () => (from && to ? { from, to } : data.query.timefilter.timefilter.getTimeDefaults()), - [data.query.timefilter.timefilter, from, to] - ); + useEffect(() => { + const subscription = createTotalHitsObservable(unifiedHistogram?.state$)?.subscribe( + ({ status, result }) => { + if (result instanceof Error) { + // Display the error and set totalHits$ to an error state + sendErrorTo(services.data, savedSearchData$.totalHits$)(result); + return; + } + + const { recordRawType } = savedSearchData$.totalHits$.getValue(); + + // Sync the totalHits$ observable with the unified histogram state + savedSearchData$.totalHits$.next({ + fetchStatus: status.toString() as FetchStatus, + result, + recordRawType, + }); + + if (status !== UnifiedHistogramFetchStatus.complete || typeof result !== 'number') { + return; + } + + // Check the hits count to set a partial or no results state + checkHitCount(savedSearchData$.main$, result); - /** - * Request - */ + // Indicate the first load has completed so we don't show + // partial results on subsequent fetches + firstLoadComplete.current = true; + } + ); - // The searchSessionId will be updated whenever a new search is started - const searchSessionId = useObservable(searchSessionManager.searchSessionId$); - const request = useMemo( - () => ({ - searchSessionId, - adapter: inspectorAdapters.requests, - }), - [inspectorAdapters.requests, searchSessionId] - ); + return () => { + subscription?.unsubscribe(); + }; + }, [savedSearchData$.main$, savedSearchData$.totalHits$, services.data, unifiedHistogram]); /** * Data fetching */ - const input$ = useMemo(() => new Subject(), []); - - // Initialized when the first search has been requested or - // when in SQL mode since search sessions are not supported - const isInitialized = Boolean(searchSessionId) || isPlainRecord; const skipRefetch = useRef(); // Skip refetching when showing the chart since Lens will @@ -292,37 +273,41 @@ export const useDiscoverHistogram = ({ // Trigger a unified histogram refetch when savedSearchFetch$ is triggered useEffect(() => { const subscription = savedSearchFetch$.subscribe(() => { - if (isInitialized && !skipRefetch.current) { - input$.next({ type: 'refetch' }); + if (!skipRefetch.current) { + unifiedHistogram?.refetch(); } + skipRefetch.current = false; }); return () => { subscription.unsubscribe(); }; - }, [input$, isInitialized, savedSearchFetch$]); - - // Don't render the unified histogram layout until initialized - return isInitialized - ? { - query, - filters, - timeRange, - topPanelHeight, - request, - hits, - chart, - breakdown, - disableAutoFetching: true, - input$, - onEditVisualization: canVisualize ? onEditVisualization : undefined, - onTopPanelHeightChange, - onChartHiddenChange, - onTimeIntervalChange, - onBreakdownFieldChange, - onTotalHitsChange, - onChartLoad, - } - : undefined; + }, [savedSearchFetch$, unifiedHistogram]); + + return { hideChart, setUnifiedHistogramApi }; +}; + +const createStateSyncObservable = (state$?: Observable) => { + return state$?.pipe( + map(({ lensRequestAdapter, chartHidden, timeInterval, breakdownField }) => ({ + lensRequestAdapter, + chartHidden, + timeInterval, + breakdownField, + })), + distinctUntilChanged((prev, curr) => { + const { lensRequestAdapter: prevLensRequestAdapter, ...prevRest } = prev; + const { lensRequestAdapter: currLensRequestAdapter, ...currRest } = curr; + + return prevLensRequestAdapter === currLensRequestAdapter && isEqual(prevRest, currRest); + }) + ); +}; + +const createTotalHitsObservable = (state$?: Observable) => { + return state$?.pipe( + map((state) => ({ status: state.totalHitsStatus, result: state.totalHitsResult })), + distinctUntilChanged((prev, curr) => prev.status === curr.status && prev.result === curr.result) + ); }; diff --git a/src/plugins/discover/public/application/main/utils/get_state_defaults.ts b/src/plugins/discover/public/application/main/utils/get_state_defaults.ts index 9ea56f8b0174f..677a92cb76a96 100644 --- a/src/plugins/discover/public/application/main/utils/get_state_defaults.ts +++ b/src/plugins/discover/public/application/main/utils/get_state_defaults.ts @@ -9,6 +9,7 @@ import { cloneDeep, isEqual } from 'lodash'; import { IUiSettingsClient } from '@kbn/core/public'; import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { getChartHidden } from '@kbn/unified-histogram-plugin/public'; import { AppState } from '../services/discover_app_state_container'; import { DiscoverServices } from '../../../build_services'; import { getDefaultSort, getSortArray } from '../../../utils/sorting'; @@ -19,8 +20,6 @@ import { SORT_DEFAULT_ORDER_SETTING, } from '../../../../common'; -import { CHART_HIDDEN_KEY } from '../components/layout/use_discover_histogram'; - function getDefaultColumns(savedSearch: SavedSearch, uiSettings: IUiSettingsClient) { if (savedSearch.columns && savedSearch.columns.length > 0) { return [...savedSearch.columns]; @@ -48,7 +47,7 @@ export function getStateDefaults({ const query = searchSource.getField('query') || data.query.queryString.getDefaultQuery(); const sort = getSortArray(savedSearch.sort ?? [], dataView!); const columns = getDefaultColumns(savedSearch, uiSettings); - const chartHidden = storage.get(CHART_HIDDEN_KEY); + const chartHidden = getChartHidden(storage, 'discover'); const defaultState: AppState = { query, diff --git a/src/plugins/unified_histogram/README.md b/src/plugins/unified_histogram/README.md index 301216dfefdbf..459eb88aa6610 100755 --- a/src/plugins/unified_histogram/README.md +++ b/src/plugins/unified_histogram/README.md @@ -1,3 +1,138 @@ # unifiedHistogram -The `unifiedHistogram` plugin provides UI components to create a layout including a resizable histogram and a main display. +Unified Histogram is a UX Building Block including a layout with a resizable histogram and a main display. +It manages its own state and data fetching, and can easily be dropped into pages with minimal setup. + +## Example + +```tsx +// Import the container component and API contract +import { + UnifiedHistogramContainer, + type UnifiedHistogramInitializedApi, +} from '@kbn/unified-histogram-plugin/public'; + +// Import modules required for your application +import { + useServices, + useResizeRef, + useCallbacks, + useRequestParams, + useManualRefetch, + MyLayout, + MyButton, +} from './my-modules'; + +const services = useServices(); +const resizeRef = useResizeRef(); +const { onChartHiddenChange, onLensRequestAdapterChange } = useCallbacks(); +const { + dataView, + query, + filters, + timeRange, + searchSessionId, + requestAdapter, +} = useRequestParams(); + +// Use a state variable instead of a ref to preserve reactivity when the API is updated +const [unifiedHistogram, setUnifiedHistogram] = useState(); + +// Create a callback to set unifiedHistogram, and initialize it if needed +const setUnifiedHistogramApi = useCallback((api: UnifiedHistogramApi | null) => { + // Ignore if the ref is null + if (!api) { + return; + } + + if (api.initialized) { + // Update our local reference to the API + setUnifiedHistogram(api); + } else { + // Initialize if not yet initialized + api.initialize({ + // Pass the required services to Unified Histogram + services, + // Optionally provide a local storage key prefix to save parts of the state, + // such as the chart hidden state and top panel height, to local storage + localStorageKeyPrefix: 'myApp', + // By default Unified Histogram will automatically refetch based on certain + // state changes, such as chart hidden and request params, but this can be + // disabled in favour of manual fetching if preferred. Note that an initial + // request is always triggered when first initialized, and when the chart + // changes from hidden to visible, Lens will automatically trigger a refetch + // regardless of what this property is set to + disableAutoFetching: true, + // If passing an absolute time range, provide a function to get the relative range + getRelativeTimeRange: services.data.query.timefilter.timefilter.getTime, + // At minimum the initial state requires a data view, but additional + // parameters can be passed to further customize the state + initialState: { + dataView, + query, + filters, + timeRange, + searchSessionId, + requestAdapter, + }, + }); + } +}, [...]); + +// Manually refetch if disableAutoFetching is true +useManualRefetch(() => { + unifiedHistogram?.refetch(); +}); + +// Update the Unified Histogram state when our request params change +useEffect(() => { + unifiedHistogram?.setRequestParams({ + dataView, + query, + filters, + timeRange, + searchSessionId, + requestAdapter, + }); +}, [...]); + +// Listen for state changes if your application requires it +useEffect(() => { + const subscription = unifiedHistogram?.state$ + .pipe(map((state) => state.chartHidden), distinctUntilChanged()) + .subscribe(onChartHiddenChange); + + return () => { + subscription?.unsubscribe(); + }; +}, [...]); + +// Currently Lens does not accept a custom request adapter, +// so it will not use the one passed to Unified Histogram. +// Instead you can get access to the one it's using by +// listening for state changes +useEffect(() => { + const subscription = unifiedHistogram?.state$ + .pipe(map((state) => state.lensRequestAdapter), distinctUntilChanged()) + .subscribe(onLensRequestAdapterChange); + + return () => { + subscription?.unsubscribe(); + }; +}, [...]); + +return ( + } + > + + +); +``` diff --git a/src/plugins/unified_histogram/public/__mocks__/data_view.ts b/src/plugins/unified_histogram/public/__mocks__/data_view.ts index e51b9560949ab..34043e8348c4b 100644 --- a/src/plugins/unified_histogram/public/__mocks__/data_view.ts +++ b/src/plugins/unified_histogram/public/__mocks__/data_view.ts @@ -99,6 +99,10 @@ export const buildDataViewMock = ({ getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), isTimeNanosBased: () => false, isPersisted: () => true, + getTimeField: () => { + return dataViewFields.find((field) => field.name === timeFieldName); + }, + toSpec: () => ({}), } as unknown as DataView; dataView.isTimeBased = () => !!timeFieldName; diff --git a/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts b/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts index b0ec2fcf84ebb..3868ed2c70af5 100644 --- a/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts +++ b/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts @@ -25,6 +25,7 @@ const fields = [ filterable: true, aggregatable: true, sortable: true, + visualizable: true, }, { name: 'message', diff --git a/src/plugins/unified_histogram/public/__mocks__/services.ts b/src/plugins/unified_histogram/public/__mocks__/services.ts index 1ce16ad8fae85..e771ab7ea8c1e 100644 --- a/src/plugins/unified_histogram/public/__mocks__/services.ts +++ b/src/plugins/unified_histogram/public/__mocks__/services.ts @@ -17,6 +17,9 @@ dataPlugin.query.filterManager.getFilters = jest.fn(() => []); export const unifiedHistogramServicesMock = { data: dataPlugin, fieldFormats: fieldFormatsMock, + uiActions: { + getTriggerCompatibleActions: jest.fn(() => Promise.resolve([])), + }, uiSettings: { get: jest.fn(), isDefault: jest.fn(() => true), @@ -25,5 +28,11 @@ export const unifiedHistogramServicesMock = { useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme), useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme), }, - lens: { EmbeddableComponent: jest.fn(() => null) }, + lens: { EmbeddableComponent: jest.fn(() => null), navigateToPrefilledEditor: jest.fn() }, + storage: { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }, } as unknown as UnifiedHistogramServices; diff --git a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx index 2b6b8bd7c537f..c0c20a1e1a80e 100644 --- a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx +++ b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx @@ -9,10 +9,10 @@ import { EuiComboBox } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import React from 'react'; -import { UnifiedHistogramBreakdownContext } from '..'; +import { UnifiedHistogramBreakdownContext } from '../types'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { BreakdownFieldSelector } from './breakdown_field_selector'; -import { fieldSupportsBreakdown } from './field_supports_breakdown'; +import { fieldSupportsBreakdown } from './utils/field_supports_breakdown'; describe('BreakdownFieldSelector', () => { it('should pass fields that support breakdown as options to the EuiComboBox', () => { diff --git a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx index ef2a14e4423b6..3e72aaad9d179 100644 --- a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx +++ b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx @@ -12,7 +12,7 @@ import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useState } from 'react'; import { UnifiedHistogramBreakdownContext } from '../types'; -import { fieldSupportsBreakdown } from './field_supports_breakdown'; +import { fieldSupportsBreakdown } from './utils/field_supports_breakdown'; export interface BreakdownFieldSelectorProps { dataView: DataView; diff --git a/src/plugins/unified_histogram/public/chart/chart.test.tsx b/src/plugins/unified_histogram/public/chart/chart.test.tsx index bc4115590b4e2..151b0f2408150 100644 --- a/src/plugins/unified_histogram/public/chart/chart.test.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.test.tsx @@ -20,7 +20,12 @@ import { HitsCounter } from '../hits_counter'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { dataViewMock } from '../__mocks__/data_view'; import { BreakdownFieldSelector } from './breakdown_field_selector'; -import { Histogram } from './histogram'; + +let mockUseEditVisualization: jest.Mock | undefined = jest.fn(); + +jest.mock('./hooks/use_edit_visualization', () => ({ + useEditVisualization: () => mockUseEditVisualization, +})); async function mountComponent({ noChart, @@ -28,7 +33,6 @@ async function mountComponent({ noBreakdown, chartHidden = false, appendHistogram, - onEditVisualization = jest.fn(), dataView = dataViewWithTimefieldMock, }: { noChart?: boolean; @@ -37,7 +41,6 @@ async function mountComponent({ chartHidden?: boolean; appendHistogram?: ReactElement; dataView?: DataView; - onEditVisualization?: null | (() => void); } = {}) { (searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation( jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: noHits ? 0 : 2 } } })) @@ -72,7 +75,6 @@ async function mountComponent({ }, breakdown: noBreakdown ? undefined : { field: undefined }, appendHistogram, - onEditVisualization: onEditVisualization || undefined, onResetChartHeight: jest.fn(), onChartHiddenChange: jest.fn(), onTimeIntervalChange: jest.fn(), @@ -89,6 +91,10 @@ async function mountComponent({ } describe('Chart', () => { + beforeEach(() => { + mockUseEditVisualization = jest.fn(); + }); + test('render when chart is undefined', async () => { const component = await mountComponent({ noChart: true }); expect( @@ -97,7 +103,8 @@ describe('Chart', () => { }); test('render when chart is defined and onEditVisualization is undefined', async () => { - const component = await mountComponent({ onEditVisualization: null }); + mockUseEditVisualization = undefined; + const component = await mountComponent(); expect( component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists() ).toBeTruthy(); @@ -133,16 +140,15 @@ describe('Chart', () => { }); test('triggers onEditVisualization on click', async () => { - const fn = jest.fn(); - const component = await mountComponent({ onEditVisualization: fn }); + expect(mockUseEditVisualization).not.toHaveBeenCalled(); + const component = await mountComponent(); await act(async () => { component .find('[data-test-subj="unifiedHistogramEditVisualization"]') .first() .simulate('click'); }); - const lensAttributes = component.find(Histogram).prop('lensAttributes'); - expect(fn).toHaveBeenCalledWith(lensAttributes); + expect(mockUseEditVisualization).toHaveBeenCalled(); }); it('should render HitsCounter when hits is defined', async () => { diff --git a/src/plugins/unified_histogram/public/chart/chart.tsx b/src/plugins/unified_histogram/public/chart/chart.tsx index ccfadcaa07e97..4c590217d30e0 100644 --- a/src/plugins/unified_histogram/public/chart/chart.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.tsx @@ -18,12 +18,12 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public'; -import type { LensEmbeddableInput, TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type { LensEmbeddableInput } from '@kbn/lens-plugin/public'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { Subject } from 'rxjs'; import { HitsCounter } from '../hits_counter'; import { Histogram } from './histogram'; -import { useChartPanels } from './use_chart_panels'; +import { useChartPanels } from './hooks/use_chart_panels'; import type { UnifiedHistogramBreakdownContext, UnifiedHistogramChartContext, @@ -36,12 +36,13 @@ import type { UnifiedHistogramInputMessage, } from '../types'; import { BreakdownFieldSelector } from './breakdown_field_selector'; -import { useTotalHits } from './use_total_hits'; -import { useRequestParams } from './use_request_params'; -import { useChartStyles } from './use_chart_styles'; -import { useChartActions } from './use_chart_actions'; -import { getLensAttributes } from './get_lens_attributes'; -import { useRefetch } from './use_refetch'; +import { useTotalHits } from './hooks/use_total_hits'; +import { useRequestParams } from './hooks/use_request_params'; +import { useChartStyles } from './hooks/use_chart_styles'; +import { useChartActions } from './hooks/use_chart_actions'; +import { getLensAttributes } from './utils/get_lens_attributes'; +import { useRefetch } from './hooks/use_refetch'; +import { useEditVisualization } from './hooks/use_edit_visualization'; export interface ChartProps { className?: string; @@ -60,7 +61,7 @@ export interface ChartProps { disableTriggers?: LensEmbeddableInput['disableTriggers']; disabledActions?: LensEmbeddableInput['disabledActions']; input$?: UnifiedHistogramInput$; - onEditVisualization?: (lensAttributes: TypedLensByValueInput['attributes']) => void; + getRelativeTimeRange?: () => TimeRange; onResetChartHeight?: () => void; onChartHiddenChange?: (chartHidden: boolean) => void; onTimeIntervalChange?: (timeInterval: string) => void; @@ -90,7 +91,7 @@ export function Chart({ disableTriggers, disabledActions, input$: originalInput$, - onEditVisualization: originalOnEditVisualization, + getRelativeTimeRange: originalGetRelativeTimeRange, onResetChartHeight, onChartHiddenChange, onTimeIntervalChange, @@ -191,16 +192,18 @@ export function Chart({ [breakdown?.field, chart?.timeInterval, chart?.title, dataView, filters, query] ); - const onEditVisualization = useMemo( - () => - originalOnEditVisualization - ? () => { - originalOnEditVisualization(lensAttributes); - } - : undefined, - [lensAttributes, originalOnEditVisualization] + const getRelativeTimeRange = useMemo( + () => originalGetRelativeTimeRange ?? (() => relativeTimeRange), + [originalGetRelativeTimeRange, relativeTimeRange] ); + const onEditVisualization = useEditVisualization({ + services, + dataView, + getRelativeTimeRange, + lensAttributes, + }); + return ( { diff --git a/src/plugins/unified_histogram/public/chart/use_chart_actions.ts b/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.ts similarity index 96% rename from src/plugins/unified_histogram/public/chart/use_chart_actions.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.ts index 85b876e0862c1..168db2ca0c4d9 100644 --- a/src/plugins/unified_histogram/public/chart/use_chart_actions.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.ts @@ -7,7 +7,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { UnifiedHistogramChartContext } from '../types'; +import type { UnifiedHistogramChartContext } from '../../types'; export const useChartActions = ({ chart, diff --git a/src/plugins/unified_histogram/public/chart/use_chart_panels.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.test.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/use_chart_panels.test.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.test.ts diff --git a/src/plugins/unified_histogram/public/chart/use_chart_panels.ts b/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.ts similarity index 98% rename from src/plugins/unified_histogram/public/chart/use_chart_panels.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.ts index 8f2874baa624e..6c6921ba09d6f 100644 --- a/src/plugins/unified_histogram/public/chart/use_chart_panels.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.ts @@ -12,7 +12,7 @@ import type { EuiContextMenuPanelDescriptor, } from '@elastic/eui'; import { search } from '@kbn/data-plugin/public'; -import type { UnifiedHistogramChartContext } from '../types'; +import type { UnifiedHistogramChartContext } from '../../types'; export function useChartPanels({ chart, diff --git a/src/plugins/unified_histogram/public/chart/use_chart_styles.tsx b/src/plugins/unified_histogram/public/chart/hooks/use_chart_styles.tsx similarity index 100% rename from src/plugins/unified_histogram/public/chart/use_chart_styles.tsx rename to src/plugins/unified_histogram/public/chart/hooks/use_chart_styles.tsx diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.test.ts new file mode 100644 index 0000000000000..1d92db77dc376 --- /dev/null +++ b/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.test.ts @@ -0,0 +1,117 @@ +/* + * 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 { DataView } from '@kbn/data-views-plugin/common'; +import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-test-renderer'; +import { setTimeout } from 'timers/promises'; +import { dataViewMock } from '../../__mocks__/data_view'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import { unifiedHistogramServicesMock } from '../../__mocks__/services'; +import { useEditVisualization } from './use_edit_visualization'; + +const getTriggerCompatibleActions = unifiedHistogramServicesMock.uiActions + .getTriggerCompatibleActions as jest.Mock; + +const navigateToPrefilledEditor = unifiedHistogramServicesMock.lens + .navigateToPrefilledEditor as jest.Mock; + +describe('useEditVisualization', () => { + beforeEach(() => { + getTriggerCompatibleActions.mockClear(); + navigateToPrefilledEditor.mockClear(); + }); + + it('should return a function to edit the visualization', async () => { + getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }])); + const relativeTimeRange = { from: 'now-15m', to: 'now' }; + const lensAttributes = { + visualizationType: 'lnsXY', + title: 'test', + } as TypedLensByValueInput['attributes']; + const hook = renderHook(() => + useEditVisualization({ + services: unifiedHistogramServicesMock, + dataView: dataViewWithTimefieldMock, + getRelativeTimeRange: () => relativeTimeRange, + lensAttributes, + }) + ); + await act(() => setTimeout(0)); + expect(hook.result.current).toBeDefined(); + hook.result.current!(); + expect(navigateToPrefilledEditor).toHaveBeenCalledWith({ + id: '', + timeRange: relativeTimeRange, + attributes: lensAttributes, + }); + }); + + it('should return undefined if the data view has no ID', async () => { + getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }])); + const hook = renderHook(() => + useEditVisualization({ + services: unifiedHistogramServicesMock, + dataView: { ...dataViewWithTimefieldMock, id: undefined } as DataView, + getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }), + lensAttributes: {} as unknown as TypedLensByValueInput['attributes'], + }) + ); + await act(() => setTimeout(0)); + expect(hook.result.current).toBeUndefined(); + }); + + it('should return undefined if the data view is not time based', async () => { + getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }])); + const hook = renderHook(() => + useEditVisualization({ + services: unifiedHistogramServicesMock, + dataView: dataViewMock, + getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }), + lensAttributes: {} as unknown as TypedLensByValueInput['attributes'], + }) + ); + await act(() => setTimeout(0)); + expect(hook.result.current).toBeUndefined(); + }); + + it('should return undefined if the time field is not visualizable', async () => { + getTriggerCompatibleActions.mockReturnValue(Promise.resolve([{ id: 'test' }])); + const dataView = { + ...dataViewWithTimefieldMock, + getTimeField: () => { + return { ...dataViewWithTimefieldMock.getTimeField(), visualizable: false }; + }, + } as DataView; + const hook = renderHook(() => + useEditVisualization({ + services: unifiedHistogramServicesMock, + dataView, + getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }), + lensAttributes: {} as unknown as TypedLensByValueInput['attributes'], + }) + ); + await act(() => setTimeout(0)); + expect(hook.result.current).toBeUndefined(); + }); + + it('should return undefined if there are no compatible actions', async () => { + getTriggerCompatibleActions.mockReturnValue(Promise.resolve([])); + const hook = renderHook(() => + useEditVisualization({ + services: unifiedHistogramServicesMock, + dataView: dataViewWithTimefieldMock, + getRelativeTimeRange: () => ({ from: 'now-15m', to: 'now' }), + lensAttributes: {} as unknown as TypedLensByValueInput['attributes'], + }) + ); + await act(() => setTimeout(0)); + expect(hook.result.current).toBeUndefined(); + }); +}); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.ts b/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.ts new file mode 100644 index 0000000000000..ba72f5bc9264e --- /dev/null +++ b/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { TimeRange } from '@kbn/es-query'; +import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type { VISUALIZE_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { UnifiedHistogramServices } from '../..'; + +// Avoid taking a dependency on uiActionsPlugin just for this const +const visualizeFieldTrigger: typeof VISUALIZE_FIELD_TRIGGER = 'VISUALIZE_FIELD_TRIGGER'; + +export const useEditVisualization = ({ + services, + dataView, + getRelativeTimeRange, + lensAttributes, +}: { + services: UnifiedHistogramServices; + dataView: DataView; + getRelativeTimeRange: () => TimeRange; + lensAttributes: TypedLensByValueInput['attributes']; +}) => { + const [canVisualize, setCanVisualize] = useState(false); + + const checkCanVisualize = useCallback(async () => { + if (!dataView.id || !dataView.isTimeBased() || !dataView.getTimeField().visualizable) { + return false; + } + + const compatibleActions = await services.uiActions.getTriggerCompatibleActions( + visualizeFieldTrigger, + { + dataViewSpec: dataView.toSpec(false), + fieldName: dataView.timeFieldName, + } + ); + + return Boolean(compatibleActions.length); + }, [dataView, services.uiActions]); + + const onEditVisualization = useMemo(() => { + if (!canVisualize) { + return undefined; + } + + return () => { + services.lens.navigateToPrefilledEditor({ + id: '', + timeRange: getRelativeTimeRange(), + attributes: lensAttributes, + }); + }; + }, [canVisualize, getRelativeTimeRange, lensAttributes, services.lens]); + + useEffect(() => { + checkCanVisualize().then(setCanVisualize); + }, [checkCanVisualize]); + + return onEditVisualization; +}; diff --git a/src/plugins/unified_histogram/public/chart/use_lens_props.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.test.ts similarity index 92% rename from src/plugins/unified_histogram/public/chart/use_lens_props.test.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_lens_props.test.ts index 289536edd0232..bae6b9ca50b22 100644 --- a/src/plugins/unified_histogram/public/chart/use_lens_props.test.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.test.ts @@ -9,9 +9,9 @@ import { renderHook } from '@testing-library/react-hooks'; import { act } from 'react-test-renderer'; import { Subject } from 'rxjs'; -import type { UnifiedHistogramInputMessage } from '../types'; -import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; -import { getLensAttributes } from './get_lens_attributes'; +import type { UnifiedHistogramInputMessage } from '../../types'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import { getLensAttributes } from '../utils/get_lens_attributes'; import { getLensProps, useLensProps } from './use_lens_props'; describe('useLensProps', () => { diff --git a/src/plugins/unified_histogram/public/chart/use_lens_props.ts b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.ts similarity index 98% rename from src/plugins/unified_histogram/public/chart/use_lens_props.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_lens_props.ts index 976f191dba5c3..cea2f28521efd 100644 --- a/src/plugins/unified_histogram/public/chart/use_lens_props.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.ts @@ -12,7 +12,7 @@ import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common'; import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; import { useCallback, useEffect, useState } from 'react'; import type { Observable } from 'rxjs'; -import type { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext } from '../types'; +import type { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext } from '../../types'; import { useStableCallback } from './use_stable_callback'; export const useLensProps = ({ diff --git a/src/plugins/unified_histogram/public/chart/use_refetch.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_refetch.test.ts similarity index 96% rename from src/plugins/unified_histogram/public/chart/use_refetch.test.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_refetch.test.ts index 39381ea661865..15ddf1f0f0f81 100644 --- a/src/plugins/unified_histogram/public/chart/use_refetch.test.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_refetch.test.ts @@ -16,8 +16,8 @@ import { UnifiedHistogramHitsContext, UnifiedHistogramInput$, UnifiedHistogramRequestContext, -} from '../types'; -import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; +} from '../../types'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; import { Subject } from 'rxjs'; describe('useRefetch', () => { diff --git a/src/plugins/unified_histogram/public/chart/use_refetch.ts b/src/plugins/unified_histogram/public/chart/hooks/use_refetch.ts similarity index 97% rename from src/plugins/unified_histogram/public/chart/use_refetch.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_refetch.ts index 31e08f3d732e5..344526860477f 100644 --- a/src/plugins/unified_histogram/public/chart/use_refetch.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_refetch.ts @@ -17,7 +17,7 @@ import { UnifiedHistogramHitsContext, UnifiedHistogramInput$, UnifiedHistogramRequestContext, -} from '../types'; +} from '../../types'; export const useRefetch = ({ dataView, @@ -48,7 +48,7 @@ export const useRefetch = ({ }) => { const refetchDeps = useRef>(); - // When the unified histogram props change, we must compare the current subset + // When the Unified Histogram props change, we must compare the current subset // that should trigger a histogram refetch against the previous subset. If they // are different, we must refetch the histogram to ensure it's up to date. useEffect(() => { diff --git a/src/plugins/unified_histogram/public/chart/use_request_params.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_request_params.test.ts similarity index 95% rename from src/plugins/unified_histogram/public/chart/use_request_params.test.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_request_params.test.ts index c49bcd4ce195b..f3889d1de6a42 100644 --- a/src/plugins/unified_histogram/public/chart/use_request_params.test.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_request_params.test.ts @@ -7,7 +7,7 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { unifiedHistogramServicesMock } from '../__mocks__/services'; +import { unifiedHistogramServicesMock } from '../../__mocks__/services'; const getUseRequestParams = async () => { jest.doMock('@kbn/data-plugin/common', () => { diff --git a/src/plugins/unified_histogram/public/chart/use_request_params.tsx b/src/plugins/unified_histogram/public/chart/hooks/use_request_params.tsx similarity index 96% rename from src/plugins/unified_histogram/public/chart/use_request_params.tsx rename to src/plugins/unified_histogram/public/chart/hooks/use_request_params.tsx index 8afe803d329af..c5ea702f898f0 100644 --- a/src/plugins/unified_histogram/public/chart/use_request_params.tsx +++ b/src/plugins/unified_histogram/public/chart/hooks/use_request_params.tsx @@ -9,7 +9,7 @@ import { getAbsoluteTimeRange } from '@kbn/data-plugin/common'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { useCallback, useMemo, useRef } from 'react'; -import type { UnifiedHistogramServices } from '../types'; +import type { UnifiedHistogramServices } from '../../types'; import { useStableCallback } from './use_stable_callback'; export const useRequestParams = ({ diff --git a/src/plugins/unified_histogram/public/chart/use_stable_callback.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.test.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/use_stable_callback.test.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.test.ts diff --git a/src/plugins/unified_histogram/public/chart/use_stable_callback.ts b/src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.ts similarity index 83% rename from src/plugins/unified_histogram/public/chart/use_stable_callback.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.ts index 9b81470c5ff5c..2982ca02953b5 100644 --- a/src/plugins/unified_histogram/public/chart/use_stable_callback.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; /** * Accepts a callback and returns a function with a stable identity @@ -19,5 +19,5 @@ export const useStableCallback = any>(fn: T | und ref.current = fn; }, [fn]); - return useCallback((...args: Parameters) => ref.current?.(...args), []); + return useRef((...args: Parameters) => ref.current?.(...args)).current; }; diff --git a/src/plugins/unified_histogram/public/chart/use_time_range.test.tsx b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.test.tsx similarity index 99% rename from src/plugins/unified_histogram/public/chart/use_time_range.test.tsx rename to src/plugins/unified_histogram/public/chart/hooks/use_time_range.test.tsx index 26070db1c7e54..35951d637315c 100644 --- a/src/plugins/unified_histogram/public/chart/use_time_range.test.tsx +++ b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.test.tsx @@ -9,7 +9,7 @@ import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; import { TimeRange } from '@kbn/data-plugin/common'; import { renderHook } from '@testing-library/react-hooks'; -import { UnifiedHistogramBucketInterval } from '../types'; +import { UnifiedHistogramBucketInterval } from '../../types'; import { useTimeRange } from './use_time_range'; jest.mock('@kbn/datemath', () => ({ diff --git a/src/plugins/unified_histogram/public/chart/use_time_range.tsx b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.tsx similarity index 98% rename from src/plugins/unified_histogram/public/chart/use_time_range.tsx rename to src/plugins/unified_histogram/public/chart/hooks/use_time_range.tsx index 539f32251b832..dbf563da17588 100644 --- a/src/plugins/unified_histogram/public/chart/use_time_range.tsx +++ b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; import dateMath from '@kbn/datemath'; import type { TimeRange } from '@kbn/data-plugin/common'; -import type { UnifiedHistogramBucketInterval } from '../types'; +import type { UnifiedHistogramBucketInterval } from '../../types'; export const useTimeRange = ({ uiSettings, diff --git a/src/plugins/unified_histogram/public/chart/use_total_hits.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.test.ts similarity index 98% rename from src/plugins/unified_histogram/public/chart/use_total_hits.test.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_total_hits.test.ts index ea6ab4676e681..fae169c41f2e7 100644 --- a/src/plugins/unified_histogram/public/chart/use_total_hits.test.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.test.ts @@ -7,8 +7,8 @@ */ import { Filter } from '@kbn/es-query'; -import { UnifiedHistogramFetchStatus, UnifiedHistogramInput$ } from '../types'; -import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; +import { UnifiedHistogramFetchStatus, UnifiedHistogramInput$ } from '../../types'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; import { useTotalHits } from './use_total_hits'; import { useEffect as mockUseEffect } from 'react'; import { renderHook } from '@testing-library/react-hooks'; diff --git a/src/plugins/unified_histogram/public/chart/use_total_hits.ts b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts similarity index 99% rename from src/plugins/unified_histogram/public/chart/use_total_hits.ts rename to src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts index e7227edd76684..95916c1f2bc02 100644 --- a/src/plugins/unified_histogram/public/chart/use_total_hits.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts @@ -19,7 +19,7 @@ import { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext, UnifiedHistogramServices, -} from '../types'; +} from '../../types'; import { useStableCallback } from './use_stable_callback'; export const useTotalHits = ({ diff --git a/src/plugins/unified_histogram/public/chart/build_bucket_interval.test.ts b/src/plugins/unified_histogram/public/chart/utils/build_bucket_interval.test.ts similarity index 97% rename from src/plugins/unified_histogram/public/chart/build_bucket_interval.test.ts rename to src/plugins/unified_histogram/public/chart/utils/build_bucket_interval.test.ts index 072f7a811babe..058b403c9159a 100644 --- a/src/plugins/unified_histogram/public/chart/build_bucket_interval.test.ts +++ b/src/plugins/unified_histogram/public/chart/utils/build_bucket_interval.test.ts @@ -7,7 +7,7 @@ */ import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; import { calculateBounds } from '@kbn/data-plugin/public'; import { buildBucketInterval } from './build_bucket_interval'; diff --git a/src/plugins/unified_histogram/public/chart/build_bucket_interval.ts b/src/plugins/unified_histogram/public/chart/utils/build_bucket_interval.ts similarity index 91% rename from src/plugins/unified_histogram/public/chart/build_bucket_interval.ts rename to src/plugins/unified_histogram/public/chart/utils/build_bucket_interval.ts index b3c9671662dbc..91dcd18d6f93b 100644 --- a/src/plugins/unified_histogram/public/chart/build_bucket_interval.ts +++ b/src/plugins/unified_histogram/public/chart/utils/build_bucket_interval.ts @@ -10,12 +10,12 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { DataPublicPluginStart, search, tabifyAggResponse } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { TimeRange } from '@kbn/es-query'; -import type { UnifiedHistogramBucketInterval } from '../types'; +import type { UnifiedHistogramBucketInterval } from '../../types'; import { getChartAggConfigs } from './get_chart_agg_configs'; /** * Convert the response from the chart request into a format that can be used - * by the unified histogram chart. The returned object should be used to update + * by the Unified Histogram chart. The returned object should be used to update * time range interval of histogram. */ export const buildBucketInterval = ({ diff --git a/src/plugins/unified_histogram/public/chart/field_supports_breakdown.test.ts b/src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.test.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/field_supports_breakdown.test.ts rename to src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.test.ts diff --git a/src/plugins/unified_histogram/public/chart/field_supports_breakdown.ts b/src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/field_supports_breakdown.ts rename to src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.ts diff --git a/src/plugins/unified_histogram/public/chart/get_chart_agg_config.test.ts b/src/plugins/unified_histogram/public/chart/utils/get_chart_agg_config.test.ts similarity index 95% rename from src/plugins/unified_histogram/public/chart/get_chart_agg_config.test.ts rename to src/plugins/unified_histogram/public/chart/utils/get_chart_agg_config.test.ts index ef5ce1b677153..bb6263f31586b 100644 --- a/src/plugins/unified_histogram/public/chart/get_chart_agg_config.test.ts +++ b/src/plugins/unified_histogram/public/chart/utils/get_chart_agg_config.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { getChartAggConfigs } from './get_chart_agg_configs'; diff --git a/src/plugins/unified_histogram/public/chart/get_chart_agg_configs.ts b/src/plugins/unified_histogram/public/chart/utils/get_chart_agg_configs.ts similarity index 92% rename from src/plugins/unified_histogram/public/chart/get_chart_agg_configs.ts rename to src/plugins/unified_histogram/public/chart/utils/get_chart_agg_configs.ts index d68330a22a45d..6724514cbc546 100644 --- a/src/plugins/unified_histogram/public/chart/get_chart_agg_configs.ts +++ b/src/plugins/unified_histogram/public/chart/utils/get_chart_agg_configs.ts @@ -11,7 +11,7 @@ import type { DataView } from '@kbn/data-views-plugin/common'; import type { TimeRange } from '@kbn/es-query'; /** - * Helper function to get the agg configs required for the unified histogram chart request + * Helper function to get the agg configs required for the Unified Histogram chart request */ export function getChartAggConfigs({ dataView, diff --git a/src/plugins/unified_histogram/public/chart/get_lens_attributes.test.ts b/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts similarity index 99% rename from src/plugins/unified_histogram/public/chart/get_lens_attributes.test.ts rename to src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts index 3e0ac936a6573..442e3d5fa4318 100644 --- a/src/plugins/unified_histogram/public/chart/get_lens_attributes.test.ts +++ b/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts @@ -9,7 +9,7 @@ import { getLensAttributes } from './get_lens_attributes'; import { AggregateQuery, Filter, FilterStateStore, Query } from '@kbn/es-query'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; describe('getLensAttributes', () => { const dataView: DataView = dataViewWithTimefieldMock; diff --git a/src/plugins/unified_histogram/public/chart/get_lens_attributes.ts b/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/get_lens_attributes.ts rename to src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.ts diff --git a/src/plugins/unified_histogram/public/container/container.test.tsx b/src/plugins/unified_histogram/public/container/container.test.tsx new file mode 100644 index 0000000000000..c9481c4ff4cbb --- /dev/null +++ b/src/plugins/unified_histogram/public/container/container.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { RequestAdapter } from '@kbn/inspector-plugin/common'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { UnifiedHistogramFetchStatus } from '../types'; +import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; +import { unifiedHistogramServicesMock } from '../__mocks__/services'; +import { UnifiedHistogramApi, UnifiedHistogramContainer } from './container'; +import type { UnifiedHistogramState } from './services/state_service'; + +describe('UnifiedHistogramContainer', () => { + const initialState: UnifiedHistogramState = { + breakdownField: 'bytes', + chartHidden: false, + dataView: dataViewWithTimefieldMock, + filters: [], + lensRequestAdapter: new RequestAdapter(), + query: { language: 'kuery', query: '' }, + requestAdapter: new RequestAdapter(), + searchSessionId: '123', + timeInterval: 'auto', + timeRange: { from: 'now-15m', to: 'now' }, + topPanelHeight: 100, + totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, + totalHitsResult: undefined, + }; + + it('should set ref', () => { + let api: UnifiedHistogramApi | undefined; + const setApi = (ref: UnifiedHistogramApi) => { + api = ref; + }; + mountWithIntl(); + expect(api).toBeDefined(); + }); + + it('should return null if not initialized', async () => { + const component = mountWithIntl(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(component.update().isEmptyRender()).toBe(true); + }); + + it('should not return null if initialized', async () => { + const setApi = (api: UnifiedHistogramApi | null) => { + if (!api || api.initialized) { + return; + } + api?.initialize({ + services: unifiedHistogramServicesMock, + initialState, + }); + }; + const component = mountWithIntl( + + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(component.update().isEmptyRender()).toBe(false); + }); + + it('should update initialized property when initialized', async () => { + let api: UnifiedHistogramApi | undefined; + const setApi = (ref: UnifiedHistogramApi) => { + api = ref; + }; + mountWithIntl(); + expect(api?.initialized).toBe(false); + act(() => { + if (!api?.initialized) { + api?.initialize({ + services: unifiedHistogramServicesMock, + initialState, + }); + } + }); + expect(api?.initialized).toBe(true); + }); +}); diff --git a/src/plugins/unified_histogram/public/container/container.tsx b/src/plugins/unified_histogram/public/container/container.tsx new file mode 100644 index 0000000000000..f079b38d179ff --- /dev/null +++ b/src/plugins/unified_histogram/public/container/container.tsx @@ -0,0 +1,172 @@ +/* + * 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, { forwardRef, useImperativeHandle, useMemo, useState } from 'react'; +import { Subject } from 'rxjs'; +import { pick } from 'lodash'; +import { UnifiedHistogramLayout, UnifiedHistogramLayoutProps } from '../layout'; +import type { UnifiedHistogramInputMessage } from '../types'; +import { + createStateService, + UnifiedHistogramStateOptions, + UnifiedHistogramStateService, +} from './services/state_service'; +import { useStateProps } from './hooks/use_state_props'; +import { useStateSelector } from './utils/use_state_selector'; +import { + dataViewSelector, + filtersSelector, + querySelector, + timeRangeSelector, + topPanelHeightSelector, +} from './utils/state_selectors'; + +type LayoutProps = Pick< + UnifiedHistogramLayoutProps, + | 'services' + | 'disableAutoFetching' + | 'disableTriggers' + | 'disabledActions' + | 'getRelativeTimeRange' +>; + +/** + * The props exposed by the container + */ +export type UnifiedHistogramContainerProps = Pick< + UnifiedHistogramLayoutProps, + 'className' | 'resizeRef' | 'appendHitsCounter' | 'children' +>; + +/** + * The options used to initialize the container + */ +export type UnifiedHistogramInitializeOptions = UnifiedHistogramStateOptions & + Omit; + +/** + * The uninitialized API exposed by the container + */ +export interface UnifiedHistogramUninitializedApi { + /** + * Whether the container has been initialized + */ + initialized: false; + /** + * Initialize the container + */ + initialize: (options: UnifiedHistogramInitializeOptions) => void; +} + +/** + * The initialized API exposed by the container + */ +export type UnifiedHistogramInitializedApi = { + /** + * Whether the container has been initialized + */ + initialized: true; + /** + * Manually trigger a refetch of the data + */ + refetch: () => void; +} & Pick< + UnifiedHistogramStateService, + | 'state$' + | 'setChartHidden' + | 'setTopPanelHeight' + | 'setBreakdownField' + | 'setTimeInterval' + | 'setRequestParams' + | 'setTotalHits' +>; + +/** + * The API exposed by the container + */ +export type UnifiedHistogramApi = UnifiedHistogramUninitializedApi | UnifiedHistogramInitializedApi; + +export const UnifiedHistogramContainer = forwardRef< + UnifiedHistogramApi, + UnifiedHistogramContainerProps +>((containerProps, ref) => { + const [initialized, setInitialized] = useState(false); + const [layoutProps, setLayoutProps] = useState(); + const [stateService, setStateService] = useState(); + const [input$] = useState(() => new Subject()); + const api = useMemo( + () => ({ + initialized, + initialize: (options: UnifiedHistogramInitializeOptions) => { + const { + services, + disableAutoFetching, + disableTriggers, + disabledActions, + getRelativeTimeRange, + } = options; + + setLayoutProps({ + services, + disableAutoFetching, + disableTriggers, + disabledActions, + getRelativeTimeRange, + }); + setStateService(createStateService(options)); + setInitialized(true); + }, + refetch: () => { + input$.next({ type: 'refetch' }); + }, + ...pick( + stateService!, + 'state$', + 'setChartHidden', + 'setTopPanelHeight', + 'setBreakdownField', + 'setTimeInterval', + 'setRequestParams', + 'setTotalHits' + ), + }), + [initialized, input$, stateService] + ); + + // Expose the API to the parent component + useImperativeHandle(ref, () => api, [api]); + + const stateProps = useStateProps(stateService); + const dataView = useStateSelector(stateService?.state$, dataViewSelector); + const query = useStateSelector(stateService?.state$, querySelector); + const filters = useStateSelector(stateService?.state$, filtersSelector); + const timeRange = useStateSelector(stateService?.state$, timeRangeSelector); + const topPanelHeight = useStateSelector(stateService?.state$, topPanelHeightSelector); + + // Don't render anything until the container is initialized + if (!layoutProps || !dataView) { + return null; + } + + return ( + + ); +}); + +// eslint-disable-next-line import/no-default-export +export default UnifiedHistogramContainer; diff --git a/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts b/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts new file mode 100644 index 0000000000000..c36e5239bfe29 --- /dev/null +++ b/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts @@ -0,0 +1,265 @@ +/* + * 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 { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/common'; +import { RequestAdapter } from '@kbn/inspector-plugin/common'; +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-test-renderer'; +import { UnifiedHistogramFetchStatus } from '../../types'; +import { dataViewMock } from '../../__mocks__/data_view'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import { unifiedHistogramServicesMock } from '../../__mocks__/services'; +import { + createStateService, + UnifiedHistogramState, + UnifiedHistogramStateOptions, +} from '../services/state_service'; +import { useStateProps } from './use_state_props'; + +describe('useStateProps', () => { + const initialState: UnifiedHistogramState = { + breakdownField: 'bytes', + chartHidden: false, + dataView: dataViewWithTimefieldMock, + filters: [], + lensRequestAdapter: new RequestAdapter(), + query: { language: 'kuery', query: '' }, + requestAdapter: new RequestAdapter(), + searchSessionId: '123', + timeInterval: 'auto', + timeRange: { from: 'now-15m', to: 'now' }, + topPanelHeight: 100, + totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, + totalHitsResult: undefined, + }; + + const getStateService = (options: Omit) => { + const stateService = createStateService({ + ...options, + services: unifiedHistogramServicesMock, + }); + jest.spyOn(stateService, 'setChartHidden'); + jest.spyOn(stateService, 'setTopPanelHeight'); + jest.spyOn(stateService, 'setBreakdownField'); + jest.spyOn(stateService, 'setTimeInterval'); + jest.spyOn(stateService, 'setRequestParams'); + jest.spyOn(stateService, 'setLensRequestAdapter'); + jest.spyOn(stateService, 'setTotalHits'); + return stateService; + }; + + it('should return the correct props', () => { + const stateService = getStateService({ initialState }); + const { result } = renderHook(() => useStateProps(stateService)); + expect(result.current).toMatchInlineSnapshot(` + Object { + "breakdown": Object { + "field": Object { + "aggregatable": true, + "displayName": "bytes", + "filterable": true, + "name": "bytes", + "scripted": false, + "type": "number", + }, + }, + "chart": Object { + "hidden": false, + "timeInterval": "auto", + }, + "hits": Object { + "status": "uninitialized", + "total": undefined, + }, + "onBreakdownFieldChange": [Function], + "onChartHiddenChange": [Function], + "onChartLoad": [Function], + "onTimeIntervalChange": [Function], + "onTopPanelHeightChange": [Function], + "onTotalHitsChange": [Function], + "request": Object { + "adapter": RequestAdapter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + "requests": Map {}, + Symbol(kCapture): false, + }, + "searchSessionId": "123", + }, + } + `); + }); + + it('should return the correct props when an SQL query is used', () => { + const stateService = getStateService({ + initialState: { ...initialState, query: { sql: 'SELECT * FROM index' } }, + }); + const { result } = renderHook(() => useStateProps(stateService)); + expect(result.current).toMatchInlineSnapshot(` + Object { + "breakdown": undefined, + "chart": undefined, + "hits": undefined, + "onBreakdownFieldChange": [Function], + "onChartHiddenChange": [Function], + "onChartLoad": [Function], + "onTimeIntervalChange": [Function], + "onTopPanelHeightChange": [Function], + "onTotalHitsChange": [Function], + "request": Object { + "adapter": RequestAdapter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + "requests": Map {}, + Symbol(kCapture): false, + }, + "searchSessionId": "123", + }, + } + `); + }); + + it('should return the correct props when a rollup data view is used', () => { + const stateService = getStateService({ + initialState: { + ...initialState, + dataView: { + ...dataViewWithTimefieldMock, + type: DataViewType.ROLLUP, + } as DataView, + }, + }); + const { result } = renderHook(() => useStateProps(stateService)); + expect(result.current).toMatchInlineSnapshot(` + Object { + "breakdown": undefined, + "chart": undefined, + "hits": Object { + "status": "uninitialized", + "total": undefined, + }, + "onBreakdownFieldChange": [Function], + "onChartHiddenChange": [Function], + "onChartLoad": [Function], + "onTimeIntervalChange": [Function], + "onTopPanelHeightChange": [Function], + "onTotalHitsChange": [Function], + "request": Object { + "adapter": RequestAdapter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + "requests": Map {}, + Symbol(kCapture): false, + }, + "searchSessionId": "123", + }, + } + `); + }); + + it('should return the correct props when a non time based data view is used', () => { + const stateService = getStateService({ + initialState: { ...initialState, dataView: dataViewMock }, + }); + const { result } = renderHook(() => useStateProps(stateService)); + expect(result.current).toMatchInlineSnapshot(` + Object { + "breakdown": undefined, + "chart": undefined, + "hits": Object { + "status": "uninitialized", + "total": undefined, + }, + "onBreakdownFieldChange": [Function], + "onChartHiddenChange": [Function], + "onChartLoad": [Function], + "onTimeIntervalChange": [Function], + "onTopPanelHeightChange": [Function], + "onTotalHitsChange": [Function], + "request": Object { + "adapter": RequestAdapter { + "_events": Object {}, + "_eventsCount": 0, + "_maxListeners": undefined, + "requests": Map {}, + Symbol(kCapture): false, + }, + "searchSessionId": "123", + }, + } + `); + }); + + it('should execute callbacks correctly', () => { + const stateService = getStateService({ initialState }); + const { result } = renderHook(() => useStateProps(stateService)); + const { + onTopPanelHeightChange, + onTimeIntervalChange, + onTotalHitsChange, + onChartHiddenChange, + onChartLoad, + onBreakdownFieldChange, + } = result.current; + act(() => { + onTopPanelHeightChange(200); + }); + expect(stateService.setTopPanelHeight).toHaveBeenLastCalledWith(200); + act(() => { + onTimeIntervalChange('1d'); + }); + expect(stateService.setTimeInterval).toHaveBeenLastCalledWith('1d'); + act(() => { + onTotalHitsChange(UnifiedHistogramFetchStatus.complete, 100); + }); + expect(stateService.setTotalHits).toHaveBeenLastCalledWith({ + totalHitsStatus: UnifiedHistogramFetchStatus.complete, + totalHitsResult: 100, + }); + act(() => { + onChartHiddenChange(true); + }); + expect(stateService.setChartHidden).toHaveBeenLastCalledWith(true); + const requests = new RequestAdapter(); + act(() => { + onChartLoad({ adapters: { requests } }); + }); + expect(stateService.setLensRequestAdapter).toHaveBeenLastCalledWith(requests); + act(() => { + onBreakdownFieldChange({ name: 'field' } as DataViewField); + }); + expect(stateService.setBreakdownField).toHaveBeenLastCalledWith('field'); + }); + + it('should clear lensRequestAdapter when chart is hidden', () => { + const stateService = getStateService({ initialState }); + const hook = renderHook(() => useStateProps(stateService)); + (stateService.setLensRequestAdapter as jest.Mock).mockClear(); + expect(stateService.setLensRequestAdapter).not.toHaveBeenCalled(); + act(() => { + stateService.setChartHidden(true); + }); + hook.rerender(); + expect(stateService.setLensRequestAdapter).toHaveBeenLastCalledWith(undefined); + }); + + it('should clear lensRequestAdapter when chart is undefined', () => { + const stateService = getStateService({ initialState }); + const hook = renderHook(() => useStateProps(stateService)); + (stateService.setLensRequestAdapter as jest.Mock).mockClear(); + expect(stateService.setLensRequestAdapter).not.toHaveBeenCalled(); + act(() => { + stateService.setRequestParams({ dataView: dataViewMock }); + }); + hook.rerender(); + expect(stateService.setLensRequestAdapter).toHaveBeenLastCalledWith(undefined); + }); +}); diff --git a/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts b/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts new file mode 100644 index 0000000000000..56b2da38ac2fa --- /dev/null +++ b/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts @@ -0,0 +1,162 @@ +/* + * 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 { DataViewField, DataViewType } from '@kbn/data-views-plugin/common'; +import { getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query'; +import { useCallback, useEffect, useMemo } from 'react'; +import { UnifiedHistogramChartLoadEvent, UnifiedHistogramFetchStatus } from '../../types'; +import type { UnifiedHistogramStateService } from '../services/state_service'; +import { + breakdownFieldSelector, + chartHiddenSelector, + dataViewSelector, + querySelector, + requestAdapterSelector, + searchSessionIdSelector, + timeIntervalSelector, + totalHitsResultSelector, + totalHitsStatusSelector, +} from '../utils/state_selectors'; +import { useStateSelector } from '../utils/use_state_selector'; + +export const useStateProps = (stateService: UnifiedHistogramStateService | undefined) => { + const breakdownField = useStateSelector(stateService?.state$, breakdownFieldSelector); + const chartHidden = useStateSelector(stateService?.state$, chartHiddenSelector); + const dataView = useStateSelector(stateService?.state$, dataViewSelector); + const query = useStateSelector(stateService?.state$, querySelector); + const requestAdapter = useStateSelector(stateService?.state$, requestAdapterSelector); + const searchSessionId = useStateSelector(stateService?.state$, searchSessionIdSelector); + const timeInterval = useStateSelector(stateService?.state$, timeIntervalSelector); + const totalHitsResult = useStateSelector(stateService?.state$, totalHitsResultSelector); + const totalHitsStatus = useStateSelector(stateService?.state$, totalHitsStatusSelector); + + /** + * Contexts + */ + + const isPlainRecord = useMemo(() => { + return query && isOfAggregateQueryType(query) && getAggregateQueryMode(query) === 'sql'; + }, [query]); + + const isTimeBased = useMemo(() => { + return dataView && dataView.type !== DataViewType.ROLLUP && dataView.isTimeBased(); + }, [dataView]); + + const hits = useMemo(() => { + if (isPlainRecord || totalHitsResult instanceof Error) { + return undefined; + } + + return { + status: totalHitsStatus, + total: totalHitsResult, + }; + }, [isPlainRecord, totalHitsResult, totalHitsStatus]); + + const chart = useMemo(() => { + if (isPlainRecord || !isTimeBased) { + return undefined; + } + + return { + hidden: chartHidden, + timeInterval, + }; + }, [chartHidden, isPlainRecord, isTimeBased, timeInterval]); + + const breakdown = useMemo(() => { + if (isPlainRecord || !isTimeBased) { + return undefined; + } + + return { + field: breakdownField ? dataView?.getFieldByName(breakdownField) : undefined, + }; + }, [breakdownField, dataView, isPlainRecord, isTimeBased]); + + const request = useMemo(() => { + return { + searchSessionId, + adapter: requestAdapter, + }; + }, [requestAdapter, searchSessionId]); + + /** + * Callbacks + */ + + const onTopPanelHeightChange = useCallback( + (topPanelHeight: number | undefined) => { + stateService?.setTopPanelHeight(topPanelHeight); + }, + [stateService] + ); + + const onTimeIntervalChange = useCallback( + (newTimeInterval: string) => { + stateService?.setTimeInterval(newTimeInterval); + }, + [stateService] + ); + + const onTotalHitsChange = useCallback( + (newTotalHitsStatus: UnifiedHistogramFetchStatus, newTotalHitsResult?: number | Error) => { + stateService?.setTotalHits({ + totalHitsStatus: newTotalHitsStatus, + totalHitsResult: newTotalHitsResult, + }); + }, + [stateService] + ); + + const onChartHiddenChange = useCallback( + (newChartHidden: boolean) => { + stateService?.setChartHidden(newChartHidden); + }, + [stateService] + ); + + const onChartLoad = useCallback( + (event: UnifiedHistogramChartLoadEvent) => { + // We need to store the Lens request adapter in order to inspect its requests + stateService?.setLensRequestAdapter(event.adapters.requests); + }, + [stateService] + ); + + const onBreakdownFieldChange = useCallback( + (newBreakdownField: DataViewField | undefined) => { + stateService?.setBreakdownField(newBreakdownField?.name); + }, + [stateService] + ); + + /** + * Effects + */ + + // Clear the Lens request adapter when the chart is hidden + useEffect(() => { + if (chartHidden || !chart) { + stateService?.setLensRequestAdapter(undefined); + } + }, [chart, chartHidden, stateService]); + + return { + hits, + chart, + breakdown, + request, + onTopPanelHeightChange, + onTimeIntervalChange, + onTotalHitsChange, + onChartHiddenChange, + onChartLoad, + onBreakdownFieldChange, + }; +}; diff --git a/src/plugins/unified_histogram/public/layout/index.tsx b/src/plugins/unified_histogram/public/container/index.tsx similarity index 63% rename from src/plugins/unified_histogram/public/layout/index.tsx rename to src/plugins/unified_histogram/public/container/index.tsx index a729bdff0871c..f692401c4fdbf 100644 --- a/src/plugins/unified_histogram/public/layout/index.tsx +++ b/src/plugins/unified_histogram/public/container/index.tsx @@ -10,9 +10,24 @@ import { EuiDelayRender, EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui'; import { withSuspense } from '@kbn/shared-ux-utility'; import React, { lazy } from 'react'; -export type { UnifiedHistogramLayoutProps } from './layout'; +export type { + UnifiedHistogramUninitializedApi, + UnifiedHistogramInitializedApi, + UnifiedHistogramApi, + UnifiedHistogramContainerProps, + UnifiedHistogramInitializeOptions, +} from './container'; +export type { UnifiedHistogramState, UnifiedHistogramStateOptions } from './services/state_service'; +export { + getChartHidden, + getTopPanelHeight, + getBreakdownField, + setChartHidden, + setTopPanelHeight, + setBreakdownField, +} from './utils/local_storage_utils'; -const LazyUnifiedHistogramLayout = lazy(() => import('./layout')); +const LazyUnifiedHistogramContainer = lazy(() => import('./container')); /** * A resizable layout component with two panels that renders a histogram with a hits @@ -20,8 +35,8 @@ const LazyUnifiedHistogramLayout = lazy(() => import('./layout')); * If all context props are left undefined, the layout will render in a single panel * mode including only the main display. */ -export const UnifiedHistogramLayout = withSuspense( - LazyUnifiedHistogramLayout, +export const UnifiedHistogramContainer = withSuspense( + LazyUnifiedHistogramContainer, { + const originalModule = jest.requireActual('../utils/local_storage_utils'); + return { + ...originalModule, + getChartHidden: jest.fn(originalModule.getChartHidden), + getTopPanelHeight: jest.fn(originalModule.getTopPanelHeight), + getBreakdownField: jest.fn(originalModule.getBreakdownField), + setChartHidden: jest.fn(originalModule.setChartHidden), + setTopPanelHeight: jest.fn(originalModule.setTopPanelHeight), + setBreakdownField: jest.fn(originalModule.setBreakdownField), + }; +}); + +describe('UnifiedHistogramStateService', () => { + beforeEach(() => { + (getChartHidden as jest.Mock).mockClear(); + (getTopPanelHeight as jest.Mock).mockClear(); + (getBreakdownField as jest.Mock).mockClear(); + (setChartHidden as jest.Mock).mockClear(); + (setTopPanelHeight as jest.Mock).mockClear(); + (setBreakdownField as jest.Mock).mockClear(); + }); + + const initialState: UnifiedHistogramState = { + breakdownField: 'bytes', + chartHidden: false, + dataView: dataViewWithTimefieldMock, + filters: [], + lensRequestAdapter: new RequestAdapter(), + query: { language: 'kuery', query: '' }, + requestAdapter: new RequestAdapter(), + searchSessionId: '123', + timeInterval: 'auto', + timeRange: { from: 'now-15m', to: 'now' }, + topPanelHeight: 100, + totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, + totalHitsResult: undefined, + }; + + it('should initialize state with default values', () => { + const stateService = createStateService({ + services: unifiedHistogramServicesMock, + initialState: { + dataView: dataViewWithTimefieldMock, + }, + }); + let state: UnifiedHistogramState | undefined; + stateService.state$.subscribe((s) => (state = s)); + expect(state).toEqual({ + breakdownField: undefined, + chartHidden: false, + dataView: dataViewWithTimefieldMock, + filters: [], + lensRequestAdapter: undefined, + query: unifiedHistogramServicesMock.data.query.queryString.getDefaultQuery(), + requestAdapter: undefined, + searchSessionId: undefined, + timeInterval: 'auto', + timeRange: unifiedHistogramServicesMock.data.query.timefilter.timefilter.getTimeDefaults(), + topPanelHeight: undefined, + totalHitsResult: undefined, + totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, + }); + }); + + it('should initialize state with initial values', () => { + const stateService = createStateService({ + services: unifiedHistogramServicesMock, + initialState, + }); + let state: UnifiedHistogramState | undefined; + stateService.state$.subscribe((s) => (state = s)); + expect(state).toEqual(initialState); + }); + + it('should get values from storage if localStorageKeyPrefix is provided', () => { + const localStorageKeyPrefix = 'test'; + createStateService({ + services: unifiedHistogramServicesMock, + localStorageKeyPrefix, + initialState, + }); + expect(getChartHidden as jest.Mock).toHaveBeenCalledWith( + unifiedHistogramServicesMock.storage, + localStorageKeyPrefix + ); + expect(getTopPanelHeight as jest.Mock).toHaveBeenCalledWith( + unifiedHistogramServicesMock.storage, + localStorageKeyPrefix + ); + expect(getBreakdownField as jest.Mock).toHaveBeenCalledWith( + unifiedHistogramServicesMock.storage, + localStorageKeyPrefix + ); + }); + + it('should not get values from storage if localStorageKeyPrefix is not provided', () => { + createStateService({ + services: unifiedHistogramServicesMock, + initialState, + }); + expect(getChartHidden as jest.Mock).not.toHaveBeenCalled(); + expect(getTopPanelHeight as jest.Mock).not.toHaveBeenCalled(); + expect(getBreakdownField as jest.Mock).not.toHaveBeenCalled(); + }); + + it('should update state', () => { + const stateService = createStateService({ + services: unifiedHistogramServicesMock, + initialState, + }); + let state: UnifiedHistogramState | undefined; + let newState = initialState; + stateService.state$.subscribe((s) => (state = s)); + expect(state).toEqual(newState); + stateService.setChartHidden(true); + newState = { ...newState, chartHidden: true }; + expect(state).toEqual(newState); + stateService.setTopPanelHeight(200); + newState = { ...newState, topPanelHeight: 200 }; + expect(state).toEqual(newState); + stateService.setBreakdownField('test'); + newState = { ...newState, breakdownField: 'test' }; + expect(state).toEqual(newState); + stateService.setTimeInterval('test'); + newState = { ...newState, timeInterval: 'test' }; + expect(state).toEqual(newState); + const requestParams = { + dataView: dataViewMock, + filters: ['test'] as unknown as Filter[], + query: { language: 'kuery', query: 'test' }, + requestAdapter: undefined, + searchSessionId: '321', + timeRange: { from: 'now-30m', to: 'now' }, + }; + stateService.setRequestParams(requestParams); + newState = { ...newState, ...requestParams }; + expect(state).toEqual(newState); + stateService.setLensRequestAdapter(undefined); + newState = { ...newState, lensRequestAdapter: undefined }; + expect(state).toEqual(newState); + stateService.setTotalHits({ + totalHitsStatus: UnifiedHistogramFetchStatus.complete, + totalHitsResult: 100, + }); + newState = { + ...newState, + totalHitsStatus: UnifiedHistogramFetchStatus.complete, + totalHitsResult: 100, + }; + expect(state).toEqual(newState); + }); + + it('should update state and save it to storage if localStorageKeyPrefix is provided', () => { + const localStorageKeyPrefix = 'test'; + const stateService = createStateService({ + services: unifiedHistogramServicesMock, + localStorageKeyPrefix, + initialState, + }); + let state: UnifiedHistogramState | undefined; + stateService.state$.subscribe((s) => (state = s)); + expect(state).toEqual(initialState); + stateService.setChartHidden(true); + stateService.setTopPanelHeight(200); + stateService.setBreakdownField('test'); + expect(state).toEqual({ + ...initialState, + chartHidden: true, + topPanelHeight: 200, + breakdownField: 'test', + }); + expect(setChartHidden as jest.Mock).toHaveBeenCalledWith( + unifiedHistogramServicesMock.storage, + localStorageKeyPrefix, + true + ); + expect(setTopPanelHeight as jest.Mock).toHaveBeenCalledWith( + unifiedHistogramServicesMock.storage, + localStorageKeyPrefix, + 200 + ); + expect(setBreakdownField as jest.Mock).toHaveBeenCalledWith( + unifiedHistogramServicesMock.storage, + localStorageKeyPrefix, + 'test' + ); + }); + + it('should not save state to storage if localStorageKeyPrefix is not provided', () => { + const stateService = createStateService({ + services: unifiedHistogramServicesMock, + initialState, + }); + let state: UnifiedHistogramState | undefined; + stateService.state$.subscribe((s) => (state = s)); + expect(state).toEqual(initialState); + stateService.setChartHidden(true); + stateService.setTopPanelHeight(200); + stateService.setBreakdownField('test'); + expect(state).toEqual({ + ...initialState, + chartHidden: true, + topPanelHeight: 200, + breakdownField: 'test', + }); + expect(setChartHidden as jest.Mock).not.toHaveBeenCalled(); + expect(setTopPanelHeight as jest.Mock).not.toHaveBeenCalled(); + expect(setBreakdownField as jest.Mock).not.toHaveBeenCalled(); + }); + + it('should not update total hits to loading when the current status is partial', () => { + const stateService = createStateService({ + services: unifiedHistogramServicesMock, + initialState: { + ...initialState, + totalHitsStatus: UnifiedHistogramFetchStatus.partial, + }, + }); + let state: UnifiedHistogramState | undefined; + stateService.state$.subscribe((s) => (state = s)); + expect(state).toEqual({ + ...initialState, + totalHitsStatus: UnifiedHistogramFetchStatus.partial, + }); + stateService.setTotalHits({ + totalHitsStatus: UnifiedHistogramFetchStatus.loading, + totalHitsResult: 100, + }); + expect(state).toEqual({ + ...initialState, + totalHitsStatus: UnifiedHistogramFetchStatus.partial, + }); + }); +}); diff --git a/src/plugins/unified_histogram/public/container/services/state_service.ts b/src/plugins/unified_histogram/public/container/services/state_service.ts new file mode 100644 index 0000000000000..5e8ad3b8e8b66 --- /dev/null +++ b/src/plugins/unified_histogram/public/container/services/state_service.ts @@ -0,0 +1,248 @@ +/* + * 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 { DataView } from '@kbn/data-views-plugin/common'; +import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { RequestAdapter } from '@kbn/inspector-plugin/common'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { UnifiedHistogramFetchStatus } from '../..'; +import type { UnifiedHistogramServices } from '../../types'; +import { + getBreakdownField, + getChartHidden, + getTopPanelHeight, + setBreakdownField, + setChartHidden, + setTopPanelHeight, +} from '../utils/local_storage_utils'; + +/** + * The current state of the container + */ +export interface UnifiedHistogramState { + /** + * The current field used for the breakdown + */ + breakdownField: string | undefined; + /** + * Whether or not the chart is hidden + */ + chartHidden: boolean; + /** + * The current data view + */ + dataView: DataView; + /** + * The current filters + */ + filters: Filter[]; + /** + * The current Lens request adapter + */ + lensRequestAdapter: RequestAdapter | undefined; + /** + * The current query + */ + query: Query | AggregateQuery; + /** + * The current request adapter used for non-Lens requests + */ + requestAdapter: RequestAdapter | undefined; + /** + * The current search session ID + */ + searchSessionId: string | undefined; + /** + * The current time interval of the chart + */ + timeInterval: string; + /** + * The current time range + */ + timeRange: TimeRange; + /** + * The current top panel height + */ + topPanelHeight: number | undefined; + /** + * The current fetch status of the hits count request + */ + totalHitsStatus: UnifiedHistogramFetchStatus; + /** + * The current result of the hits count request + */ + totalHitsResult: number | Error | undefined; +} + +/** + * The options used to initialize the comntainer state + */ +export interface UnifiedHistogramStateOptions { + /** + * The services required by the Unified Histogram components + */ + services: UnifiedHistogramServices; + /** + * The prefix for the keys used in local storage -- leave undefined to avoid using local storage + */ + localStorageKeyPrefix?: string; + /** + * The initial state of the container + */ + initialState: Partial & Pick; +} + +/** + * The service used to manage the state of the container + */ +export interface UnifiedHistogramStateService { + /** + * The current state of the container + */ + state$: Observable; + /** + * Sets the current chart hidden state + */ + setChartHidden: (chartHidden: boolean) => void; + /** + * Sets the current top panel height + */ + setTopPanelHeight: (topPanelHeight: number | undefined) => void; + /** + * Sets the current breakdown field + */ + setBreakdownField: (breakdownField: string | undefined) => void; + /** + * Sets the current time interval + */ + setTimeInterval: (timeInterval: string) => void; + /** + * Sets the current request parameters + */ + setRequestParams: (requestParams: { + dataView?: DataView; + filters?: Filter[]; + query?: Query | AggregateQuery; + requestAdapter?: RequestAdapter | undefined; + searchSessionId?: string | undefined; + timeRange?: TimeRange; + }) => void; + /** + * Sets the current Lens request adapter + */ + setLensRequestAdapter: (lensRequestAdapter: RequestAdapter | undefined) => void; + /** + * Sets the current total hits status and result + */ + setTotalHits: (totalHits: { + totalHitsStatus: UnifiedHistogramFetchStatus; + totalHitsResult: number | Error | undefined; + }) => void; +} + +export const createStateService = ( + options: UnifiedHistogramStateOptions +): UnifiedHistogramStateService => { + const { services, localStorageKeyPrefix, initialState } = options; + + let initialChartHidden = false; + let initialTopPanelHeight: number | undefined; + let initialBreakdownField: string | undefined; + + if (localStorageKeyPrefix) { + initialChartHidden = getChartHidden(services.storage, localStorageKeyPrefix) ?? false; + initialTopPanelHeight = getTopPanelHeight(services.storage, localStorageKeyPrefix); + initialBreakdownField = getBreakdownField(services.storage, localStorageKeyPrefix); + } + + const state$ = new BehaviorSubject({ + breakdownField: initialBreakdownField, + chartHidden: initialChartHidden, + filters: [], + lensRequestAdapter: undefined, + query: services.data.query.queryString.getDefaultQuery(), + requestAdapter: undefined, + searchSessionId: undefined, + timeInterval: 'auto', + timeRange: services.data.query.timefilter.timefilter.getTimeDefaults(), + topPanelHeight: initialTopPanelHeight, + totalHitsResult: undefined, + totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, + ...initialState, + }); + + const updateState = (stateUpdate: Partial) => { + state$.next({ + ...state$.getValue(), + ...stateUpdate, + }); + }; + + return { + state$, + + setChartHidden: (chartHidden: boolean) => { + if (localStorageKeyPrefix) { + setChartHidden(services.storage, localStorageKeyPrefix, chartHidden); + } + + updateState({ chartHidden }); + }, + + setTopPanelHeight: (topPanelHeight: number | undefined) => { + if (localStorageKeyPrefix) { + setTopPanelHeight(services.storage, localStorageKeyPrefix, topPanelHeight); + } + + updateState({ topPanelHeight }); + }, + + setBreakdownField: (breakdownField: string | undefined) => { + if (localStorageKeyPrefix) { + setBreakdownField(services.storage, localStorageKeyPrefix, breakdownField); + } + + updateState({ breakdownField }); + }, + + setTimeInterval: (timeInterval: string) => { + updateState({ timeInterval }); + }, + + setRequestParams: (requestParams: { + dataView?: DataView; + filters?: Filter[]; + query?: Query | AggregateQuery; + requestAdapter?: RequestAdapter | undefined; + searchSessionId?: string | undefined; + timeRange?: TimeRange; + }) => { + updateState(requestParams); + }, + + setLensRequestAdapter: (lensRequestAdapter: RequestAdapter | undefined) => { + updateState({ lensRequestAdapter }); + }, + + setTotalHits: (totalHits: { + totalHitsStatus: UnifiedHistogramFetchStatus; + totalHitsResult: number | Error | undefined; + }) => { + // If we have a partial result already, we don't + // want to update the total hits back to loading + if ( + state$.getValue().totalHitsStatus === UnifiedHistogramFetchStatus.partial && + totalHits.totalHitsStatus === UnifiedHistogramFetchStatus.loading + ) { + return; + } + + updateState(totalHits); + }, + }; +}; diff --git a/src/plugins/unified_histogram/public/container/utils/local_storage_utils.test.ts b/src/plugins/unified_histogram/public/container/utils/local_storage_utils.test.ts new file mode 100644 index 0000000000000..5b0c8a7363a0a --- /dev/null +++ b/src/plugins/unified_histogram/public/container/utils/local_storage_utils.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { + CHART_HIDDEN_KEY, + getBreakdownField, + getChartHidden, + getTopPanelHeight, + HISTOGRAM_BREAKDOWN_FIELD_KEY, + HISTOGRAM_HEIGHT_KEY, + setBreakdownField, + setChartHidden, + setTopPanelHeight, +} from './local_storage_utils'; + +describe('local storage utils', () => { + const localStorageKeyPrefix = 'testPrefix'; + const mockStorage = { + get: jest.fn((key: string) => { + switch (key) { + case `${localStorageKeyPrefix}:${CHART_HIDDEN_KEY}`: + return true; + case `${localStorageKeyPrefix}:${HISTOGRAM_HEIGHT_KEY}`: + return 100; + case `${localStorageKeyPrefix}:${HISTOGRAM_BREAKDOWN_FIELD_KEY}`: + return 'testField'; + default: + return undefined; + } + }), + set: jest.fn(), + }; + const storage = mockStorage as unknown as Storage; + + it('should execute get functions correctly', () => { + expect(getChartHidden(storage, localStorageKeyPrefix)).toEqual(true); + expect(mockStorage.get).toHaveBeenLastCalledWith( + `${localStorageKeyPrefix}:${CHART_HIDDEN_KEY}` + ); + expect(getTopPanelHeight(storage, localStorageKeyPrefix)).toEqual(100); + expect(mockStorage.get).toHaveBeenLastCalledWith( + `${localStorageKeyPrefix}:${HISTOGRAM_HEIGHT_KEY}` + ); + expect(getBreakdownField(storage, localStorageKeyPrefix)).toEqual('testField'); + expect(mockStorage.get).toHaveBeenLastCalledWith( + `${localStorageKeyPrefix}:${HISTOGRAM_BREAKDOWN_FIELD_KEY}` + ); + }); + + it('should execute set functions correctly', () => { + setChartHidden(storage, localStorageKeyPrefix, false); + expect(mockStorage.set).toHaveBeenLastCalledWith( + `${localStorageKeyPrefix}:${CHART_HIDDEN_KEY}`, + false + ); + setTopPanelHeight(storage, localStorageKeyPrefix, 200); + expect(mockStorage.set).toHaveBeenLastCalledWith( + `${localStorageKeyPrefix}:${HISTOGRAM_HEIGHT_KEY}`, + 200 + ); + setBreakdownField(storage, localStorageKeyPrefix, 'testField2'); + expect(mockStorage.set).toHaveBeenLastCalledWith( + `${localStorageKeyPrefix}:${HISTOGRAM_BREAKDOWN_FIELD_KEY}`, + 'testField2' + ); + }); +}); diff --git a/src/plugins/unified_histogram/public/container/utils/local_storage_utils.ts b/src/plugins/unified_histogram/public/container/utils/local_storage_utils.ts new file mode 100644 index 0000000000000..3b5f89e7d057e --- /dev/null +++ b/src/plugins/unified_histogram/public/container/utils/local_storage_utils.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Storage } from '@kbn/kibana-utils-plugin/public'; + +export const CHART_HIDDEN_KEY = 'chartHidden'; +export const HISTOGRAM_HEIGHT_KEY = 'histogramHeight'; +export const HISTOGRAM_BREAKDOWN_FIELD_KEY = 'histogramBreakdownField'; + +const getLocalStorageKey = (prefix: string, key: string) => `${prefix}:${key}`; + +/** + * Get the chart hidden state from local storage + */ +export const getChartHidden = ( + storage: Storage, + localStorageKeyPrefix: string +): boolean | undefined => storage.get(getLocalStorageKey(localStorageKeyPrefix, CHART_HIDDEN_KEY)); + +/** + * Get the top panel height from local storage + */ +export const getTopPanelHeight = ( + storage: Storage, + localStorageKeyPrefix: string +): number | undefined => + storage.get(getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_HEIGHT_KEY)) ?? undefined; + +/** + * Get the breakdown field from local storage + */ +export const getBreakdownField = ( + storage: Storage, + localStorageKeyPrefix: string +): string | undefined => + storage.get(getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_BREAKDOWN_FIELD_KEY)) ?? + undefined; + +/** + * Set the chart hidden state in local storage + */ +export const setChartHidden = ( + storage: Storage, + localStorageKeyPrefix: string, + chartHidden: boolean | undefined +) => storage.set(getLocalStorageKey(localStorageKeyPrefix, CHART_HIDDEN_KEY), chartHidden); + +/** + * Set the top panel height in local storage + */ +export const setTopPanelHeight = ( + storage: Storage, + localStorageKeyPrefix: string, + topPanelHeight: number | undefined +) => storage.set(getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_HEIGHT_KEY), topPanelHeight); + +/** + * Set the breakdown field in local storage + */ +export const setBreakdownField = ( + storage: Storage, + localStorageKeyPrefix: string, + breakdownField: string | undefined +) => + storage.set( + getLocalStorageKey(localStorageKeyPrefix, HISTOGRAM_BREAKDOWN_FIELD_KEY), + breakdownField + ); diff --git a/src/plugins/unified_histogram/public/container/utils/state_selectors.ts b/src/plugins/unified_histogram/public/container/utils/state_selectors.ts new file mode 100644 index 0000000000000..87f425ae64b45 --- /dev/null +++ b/src/plugins/unified_histogram/public/container/utils/state_selectors.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UnifiedHistogramState } from '../services/state_service'; + +export const breakdownFieldSelector = (state: UnifiedHistogramState) => state.breakdownField; +export const chartHiddenSelector = (state: UnifiedHistogramState) => state.chartHidden; +export const dataViewSelector = (state: UnifiedHistogramState) => state.dataView; +export const filtersSelector = (state: UnifiedHistogramState) => state.filters; +export const querySelector = (state: UnifiedHistogramState) => state.query; +export const requestAdapterSelector = (state: UnifiedHistogramState) => state.requestAdapter; +export const searchSessionIdSelector = (state: UnifiedHistogramState) => state.searchSessionId; +export const timeIntervalSelector = (state: UnifiedHistogramState) => state.timeInterval; +export const timeRangeSelector = (state: UnifiedHistogramState) => state.timeRange; +export const topPanelHeightSelector = (state: UnifiedHistogramState) => state.topPanelHeight; +export const totalHitsResultSelector = (state: UnifiedHistogramState) => state.totalHitsResult; +export const totalHitsStatusSelector = (state: UnifiedHistogramState) => state.totalHitsStatus; diff --git a/src/plugins/unified_histogram/public/container/utils/use_state_selector.ts b/src/plugins/unified_histogram/public/container/utils/use_state_selector.ts new file mode 100644 index 0000000000000..5920a34f90acb --- /dev/null +++ b/src/plugins/unified_histogram/public/container/utils/use_state_selector.ts @@ -0,0 +1,31 @@ +/* + * 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 { Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { useEffect, useState } from 'react'; + +export const useStateSelector = ( + state$: Observable | undefined, + selector: (state: S) => R, + equalityFn?: (arg0: R, arg1: R) => boolean +) => { + const [state, setState] = useState(); + + useEffect(() => { + const subscription = state$ + ?.pipe(map(selector), distinctUntilChanged(equalityFn)) + .subscribe(setState); + + return () => { + subscription?.unsubscribe(); + }; + }, [equalityFn, selector, state$]); + + return state; +}; diff --git a/src/plugins/unified_histogram/public/index.ts b/src/plugins/unified_histogram/public/index.ts index 97cebb51fdc2f..b183a5a1f8180 100644 --- a/src/plugins/unified_histogram/public/index.ts +++ b/src/plugins/unified_histogram/public/index.ts @@ -8,19 +8,28 @@ import { UnifiedHistogramPublicPlugin } from './plugin'; -export type { UnifiedHistogramLayoutProps } from './layout'; -export { UnifiedHistogramLayout } from './layout'; +export type { + UnifiedHistogramUninitializedApi, + UnifiedHistogramInitializedApi, + UnifiedHistogramApi, + UnifiedHistogramContainerProps, + UnifiedHistogramInitializeOptions, + UnifiedHistogramState, + UnifiedHistogramStateOptions, +} from './container'; +export { + UnifiedHistogramContainer, + getChartHidden, + getTopPanelHeight, + getBreakdownField, + setChartHidden, + setTopPanelHeight, + setBreakdownField, +} from './container'; export type { UnifiedHistogramServices, - UnifiedHistogramRequestContext, - UnifiedHistogramHitsContext, - UnifiedHistogramChartContext, - UnifiedHistogramBreakdownContext, UnifiedHistogramChartLoadEvent, UnifiedHistogramAdapters, - UnifiedHistogramRefetchMessage, - UnifiedHistogramInputMessage, - UnifiedHistogramInput$, } from './types'; export { UnifiedHistogramFetchStatus } from './types'; diff --git a/src/plugins/unified_histogram/public/layout/index.ts b/src/plugins/unified_histogram/public/layout/index.ts new file mode 100644 index 0000000000000..560ea44483c20 --- /dev/null +++ b/src/plugins/unified_histogram/public/layout/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { UnifiedHistogramLayoutProps } from './layout'; +export { UnifiedHistogramLayout } from './layout'; diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index db6a161a81b7e..5167bbf63f830 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -12,7 +12,7 @@ import React, { useMemo } from 'react'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { css } from '@emotion/css'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import type { LensEmbeddableInput, TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type { LensEmbeddableInput } from '@kbn/lens-plugin/public'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { Chart } from '../chart'; import { Panels, PANELS_MODE } from '../panels'; @@ -53,7 +53,7 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren */ timeRange?: TimeRange; /** - * Context object for requests made by unified histogram components -- optional + * Context object for requests made by Unified Histogram components -- optional */ request?: UnifiedHistogramRequestContext; /** @@ -97,13 +97,13 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren */ input$?: UnifiedHistogramInput$; /** - * Callback to update the topPanelHeight prop when a resize is triggered + * Callback to get the relative time range, useful when passing an absolute time range (e.g. for edit visualization button) */ - onTopPanelHeightChange?: (topPanelHeight: number | undefined) => void; + getRelativeTimeRange?: () => TimeRange; /** - * Callback to invoke when the user clicks the edit visualization button -- leave undefined to hide the button + * Callback to update the topPanelHeight prop when a resize is triggered */ - onEditVisualization?: (lensAttributes: TypedLensByValueInput['attributes']) => void; + onTopPanelHeightChange?: (topPanelHeight: number | undefined) => void; /** * Callback to hide or show the chart -- should set {@link UnifiedHistogramChartContext.hidden} to chartHidden */ @@ -153,8 +153,8 @@ export const UnifiedHistogramLayout = ({ disableTriggers, disabledActions, input$, + getRelativeTimeRange, onTopPanelHeightChange, - onEditVisualization, onChartHiddenChange, onTimeIntervalChange, onBreakdownFieldChange, @@ -222,7 +222,7 @@ export const UnifiedHistogramLayout = ({ disableTriggers={disableTriggers} disabledActions={disabledActions} input$={input$} - onEditVisualization={onEditVisualization} + getRelativeTimeRange={getRelativeTimeRange} onResetChartHeight={onResetChartHeight} onChartHiddenChange={onChartHiddenChange} onTimeIntervalChange={onTimeIntervalChange} @@ -248,6 +248,3 @@ export const UnifiedHistogramLayout = ({ ); }; - -// eslint-disable-next-line import/no-default-export -export default UnifiedHistogramLayout; diff --git a/src/plugins/unified_histogram/public/mocks.ts b/src/plugins/unified_histogram/public/mocks.ts new file mode 100644 index 0000000000000..0258b9900bf97 --- /dev/null +++ b/src/plugins/unified_histogram/public/mocks.ts @@ -0,0 +1,33 @@ +/* + * 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 { Observable } from 'rxjs'; +import type { UnifiedHistogramInitializedApi, UnifiedHistogramUninitializedApi } from './container'; + +export type MockUnifiedHistogramApi = Omit & + Omit & { initialized: boolean }; + +export const createMockUnifiedHistogramApi = ( + { initialized }: { initialized: boolean } = { initialized: false } +) => { + const api: MockUnifiedHistogramApi = { + initialized, + initialize: jest.fn(() => { + api.initialized = true; + }), + state$: new Observable(), + setChartHidden: jest.fn(), + setTopPanelHeight: jest.fn(), + setBreakdownField: jest.fn(), + setTimeInterval: jest.fn(), + setRequestParams: jest.fn(), + setTotalHits: jest.fn(), + refetch: jest.fn(), + }; + return api; +}; diff --git a/src/plugins/unified_histogram/public/types.ts b/src/plugins/unified_histogram/public/types.ts index f77bfa1bbdbee..d7eacdf9d5c44 100644 --- a/src/plugins/unified_histogram/public/types.ts +++ b/src/plugins/unified_histogram/public/types.ts @@ -15,9 +15,11 @@ import type { DataViewField } from '@kbn/data-views-plugin/public'; import type { RequestAdapter } from '@kbn/inspector-plugin/public'; import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common'; import type { Subject } from 'rxjs'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; /** - * The fetch status of a unified histogram request + * The fetch status of a Unified Histogram request */ export enum UnifiedHistogramFetchStatus { uninitialized = 'uninitialized', @@ -28,14 +30,16 @@ export enum UnifiedHistogramFetchStatus { } /** - * The services required by the unified histogram components + * The services required by the Unified Histogram components */ export interface UnifiedHistogramServices { data: DataPublicPluginStart; theme: Theme; + uiActions: UiActionsStart; uiSettings: IUiSettingsClient; fieldFormats: FieldFormatsStart; lens: LensPublicStart; + storage: Storage; } /** @@ -47,6 +51,9 @@ export interface UnifiedHistogramBucketInterval { scale?: number; } +/** + * The adapters passed up from Lens + */ export type UnifiedHistogramAdapters = Partial; /** @@ -60,7 +67,7 @@ export interface UnifiedHistogramChartLoadEvent { } /** - * Context object for requests made by unified histogram components + * Context object for requests made by Unified Histogram components */ export interface UnifiedHistogramRequestContext { /** diff --git a/src/plugins/unified_histogram/tsconfig.json b/src/plugins/unified_histogram/tsconfig.json index eb7da12b70b29..0e0f18ff9dd56 100644 --- a/src/plugins/unified_histogram/tsconfig.json +++ b/src/plugins/unified_histogram/tsconfig.json @@ -22,6 +22,8 @@ "@kbn/datemath", "@kbn/core-ui-settings-browser-mocks", "@kbn/shared-ux-utility", + "@kbn/ui-actions-plugin", + "@kbn/kibana-utils-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/packages/ml/date_picker/index.ts b/x-pack/packages/ml/date_picker/index.ts index c3047c4957d5d..f795d6a4d1f06 100644 --- a/x-pack/packages/ml/date_picker/index.ts +++ b/x-pack/packages/ml/date_picker/index.ts @@ -13,6 +13,7 @@ export { useRefreshIntervalUpdates, useTimefilter, useTimeRangeUpdates, + useRefresh, } from './src/hooks/use_timefilter'; export { DatePickerWrapper } from './src/components/date_picker_wrapper'; export { diff --git a/x-pack/packages/ml/date_picker/src/hooks/use_timefilter.ts b/x-pack/packages/ml/date_picker/src/hooks/use_timefilter.ts index 9b610908d5dff..ac82a29118192 100644 --- a/x-pack/packages/ml/date_picker/src/hooks/use_timefilter.ts +++ b/x-pack/packages/ml/date_picker/src/hooks/use_timefilter.ts @@ -7,6 +7,7 @@ import { useEffect, useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; +import { merge, type Observable } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import { isEqual } from 'lodash'; @@ -14,6 +15,7 @@ import type { TimeRange } from '@kbn/es-query'; import type { TimefilterContract } from '@kbn/data-plugin/public'; import { useDatePickerContext } from './use_date_picker_context'; +import { mlTimefilterRefresh$, Refresh } from '../services/timefilter_refresh_service'; /** * Options interface for the `useTimefilter` custom hook. @@ -100,3 +102,29 @@ export const useTimeRangeUpdates = (absolute = false): TimeRange => { return useObservable(timeChangeObservable$, getTimeCallback()); }; + +/** + * Provides the latest refresh, both manual or auto. + */ +export const useRefresh = () => { + const timefilter = useTimefilter(); + + const getTimeRange = () => { + const { from, to } = timefilter.getTime(); + return { start: from, end: to }; + }; + + const refresh$ = useMemo(() => { + return merge( + mlTimefilterRefresh$, + timefilter.getTimeUpdate$().pipe( + map(() => { + return { lastRefresh: Date.now(), timeRange: getTimeRange() }; + }) + ) + ) as Observable; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return useObservable(refresh$); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx index 7aaf4dbc6dc53..9b9e414a71e6f 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx @@ -19,6 +19,8 @@ import { startWith } from 'rxjs'; import type { Query, Filter } from '@kbn/es-query'; import { usePageUrlState } from '@kbn/ml-url-state'; import { useTimefilter, useTimeRangeUpdates } from '@kbn/ml-date-picker'; +import { DEFAULT_AGG_FUNCTION } from './constants'; +import { useSplitFieldCardinality } from './use_split_field_cardinality'; import { createMergedEsQuery, getEsQueryFromSavedSearch, @@ -36,11 +38,12 @@ export interface ChangePointDetectionPageUrlState { export interface ChangePointDetectionRequestParams { fn: string; - splitField: string; + splitField?: string; metricField: string; interval: string; query: Query; filters: Filter[]; + changePointType?: ChangePointType[]; } export const ChangePointDetectionContext = createContext<{ @@ -61,6 +64,7 @@ export const ChangePointDetectionContext = createContext<{ pageCount: number; updatePagination: (newPage: number) => void; }; + splitFieldCardinality: number | null; }>({ isLoading: false, splitFieldsOptions: [], @@ -79,6 +83,7 @@ export const ChangePointDetectionContext = createContext<{ pageCount: 1, updatePagination: () => {}, }, + splitFieldCardinality: null, }); export type ChangePointType = @@ -95,13 +100,14 @@ export interface ChangePointAnnotation { label: string; reason: string; timestamp: string; - group_field: string; + group?: { + name: string; + value: string; + }; type: ChangePointType; p_value: number; } -const DEFAULT_AGG_FUNCTION = 'min'; - export const ChangePointDetectionContextProvider: FC = ({ children }) => { const { dataView, savedSearch } = useDataSource(); const { @@ -181,12 +187,9 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { if (!params.metricField && metricFieldOptions.length > 0) { params.metricField = metricFieldOptions[0].name; } - if (!params.splitField && splitFieldsOptions.length > 0) { - params.splitField = splitFieldsOptions[0].name; - } params.interval = bucketInterval?.expression!; return params; - }, [requestParamsFromUrl, metricFieldOptions, splitFieldsOptions, bucketInterval]); + }, [requestParamsFromUrl, metricFieldOptions, bucketInterval]); const updateFilters = useCallback( (update: Filter[]) => { @@ -240,12 +243,14 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { return mergedQuery; }, [resultFilters, resultQuery, uiSettings, dataView, timeRange]); + const splitFieldCardinality = useSplitFieldCardinality(requestParams.splitField, combinedQuery); + const { results: annotations, isLoading: annotationsLoading, progress, pagination, - } = useChangePointResults(requestParams, combinedQuery); + } = useChangePointResults(requestParams, combinedQuery, splitFieldCardinality); if (!bucketInterval) return null; @@ -263,6 +268,7 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { updateFilters, resultQuery, pagination, + splitFieldCardinality, }; return ( diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx index 8e5c06b38f85c..081961c8b889d 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx @@ -7,24 +7,29 @@ import React, { FC, useCallback } from 'react'; import { EuiBadge, + EuiCallOut, EuiDescriptionList, EuiEmptyPrompt, EuiFlexGrid, EuiFlexGroup, EuiFlexItem, + EuiHorizontalRule, EuiIcon, EuiPagination, EuiPanel, EuiProgress, EuiSpacer, - EuiTitle, + EuiText, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Query } from '@kbn/es-query'; +import { useDataSource } from '../../hooks/use_data_source'; +import { SPLIT_FIELD_CARDINALITY_LIMIT } from './constants'; +import { ChangePointTypeFilter } from './change_point_type_filter'; import { SearchBarWrapper } from './search_bar'; -import { useChangePointDetectionContext } from './change_point_detection_context'; +import { ChangePointType, useChangePointDetectionContext } from './change_point_detection_context'; import { MetricFieldSelector } from './metric_field_selector'; import { SplitFieldSelector } from './split_field_selector'; import { FunctionPicker } from './function_picker'; @@ -40,8 +45,13 @@ export const ChangePointDetectionPage: FC = () => { resultQuery, progress, pagination, + splitFieldCardinality, + splitFieldsOptions, + metricFieldOptions, } = useChangePointDetectionContext(); + const { dataView } = useDataSource(); + const setFn = useCallback( (fn: string) => { updateRequestParams({ fn }); @@ -50,7 +60,7 @@ export const ChangePointDetectionPage: FC = () => { ); const setSplitField = useCallback( - (splitField: string) => { + (splitField: string | undefined) => { updateRequestParams({ splitField }); }, [updateRequestParams] @@ -70,7 +80,37 @@ export const ChangePointDetectionPage: FC = () => { [updateRequestParams] ); - const selectControlCss = { width: '200px' }; + const setChangePointType = useCallback( + (changePointType: ChangePointType[] | undefined) => { + updateRequestParams({ changePointType }); + }, + [updateRequestParams] + ); + + const selectControlCss = { width: '300px' }; + + const cardinalityExceeded = + splitFieldCardinality && splitFieldCardinality > SPLIT_FIELD_CARDINALITY_LIMIT; + + if (metricFieldOptions.length === 0) { + return ( + +

+ {i18n.translate('xpack.aiops.index.dataViewWithoutMetricNotificationDescription', { + defaultMessage: + 'Change point detection can only be run on data views with a metric field.', + })} +

+
+ ); + } return (
@@ -90,9 +130,11 @@ export const ChangePointDetectionPage: FC = () => { - - - + {splitFieldsOptions.length > 0 ? ( + + + + ) : null} { + {cardinalityExceeded ? ( + <> + +

+ {i18n.translate('xpack.aiops.changePointDetection.cardinalityWarningMessage', { + defaultMessage: + 'The "{splitField}" field cardinality is {cardinality} which exceeds the limit of {cardinalityLimit}. Only the first {cardinalityLimit} partitions, sorted by document count, are analyzed.', + values: { + cardinality: splitFieldCardinality, + cardinalityLimit: SPLIT_FIELD_CARDINALITY_LIMIT, + splitField: requestParams.splitField, + }, + })} +

+
+ + + ) : null} + + + + + + {requestParams.interval} + + + + + + + + + {annotations.length === 0 && progress === 100 ? ( <> {

} @@ -140,46 +227,70 @@ export const ChangePointDetectionPage: FC = () => { = 2 ? 2 : 1} responsive gutterSize={'m'}> {annotations.map((v) => { return ( - + - + - - - -

{v.group_field}

-
-
- {v.reason ? ( - - - - - - ) : null} -
+ {v.group ? ( + + ) : null} + + {v.reason ? ( + + + + ) : null} +
+ + + {requestParams.fn}({requestParams.metricField}) + +
+ + + + + {v.p_value !== undefined ? ( + + + ), + description: v.p_value.toPrecision(3), + }, + ]} + /> + + ) : null} {v.type} - {v.p_value !== undefined ? ( - - ) : null}
diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx index d2d275f0b2251..1662dfc09a1bf 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import { pick } from 'lodash'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { DataView } from '@kbn/data-views-plugin/common'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; @@ -19,6 +19,7 @@ import { DatePickerContextProvider } from '@kbn/ml-date-picker'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; +import { i18n } from '@kbn/i18n'; import { DataSourceContext } from '../../hooks/use_data_source'; import { AiopsAppContext, AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; import { AIOPS_STORAGE_KEYS } from '../../types/storage'; @@ -48,6 +49,25 @@ export const ChangePointDetectionAppState: FC uiSettingsKeys: UI_SETTINGS, }; + if (!dataView.isTimeBased()) { + return ( + +

+ {i18n.translate('xpack.aiops.index.changePointTimeSeriesNotificationDescription', { + defaultMessage: 'Change point detection only runs over time-based indices.', + })} +

+
+ ); + } + return ( diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_type_filter.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_type_filter.tsx new file mode 100644 index 0000000000000..50441710c33ef --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_type_filter.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { type FC, useCallback, useMemo } from 'react'; +import { + EuiComboBox, + type EuiComboBoxOptionOption, + type EuiComboBoxOptionsListProps, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isDefined } from '@kbn/ml-is-defined'; +import { type ChangePointType } from './change_point_detection_context'; + +export type ChangePointUIValue = ChangePointType | undefined; + +interface ChangePointTypeFilterProps { + value: ChangePointType[] | undefined; + onChange: (changePointType: ChangePointType[] | undefined) => void; +} + +const changePointTypes: Array<{ value: ChangePointType; description: string }> = [ + { + value: 'dip', + description: i18n.translate('xpack.aiops.changePointDetection.dipDescription', { + defaultMessage: 'A significant dip occurs at this point.', + }), + }, + { + value: 'spike', + description: i18n.translate('xpack.aiops.changePointDetection.spikeDescription', { + defaultMessage: 'A significant spike occurs at this point.', + }), + }, + { + value: 'distribution_change', + description: i18n.translate('xpack.aiops.changePointDetection.distributionChangeDescription', { + defaultMessage: 'The overall distribution of the values has changed significantly.', + }), + }, + { + value: 'step_change', + description: i18n.translate('xpack.aiops.changePointDetection.stepChangeDescription', { + defaultMessage: + 'The change indicates a statistically significant step up or down in value distribution.', + }), + }, + { + value: 'trend_change', + description: i18n.translate('xpack.aiops.changePointDetection.trendChangeDescription', { + defaultMessage: 'An overall trend change occurs at this point.', + }), + }, +]; + +interface FilterOption { + value: ChangePointUIValue; + label: string; + description: string; +} + +type ChangePointTypeFilterOptions = Array>; + +export const ChangePointTypeFilter: FC = ({ value, onChange }) => { + const options = useMemo(() => { + return [{ value: undefined, description: '' }, ...changePointTypes].map((v) => ({ + value: v.value, + label: + v.value ?? + i18n.translate('xpack.aiops.changePointDetection.selectAllChangePoints', { + defaultMessage: 'Select all', + }), + description: v.description, + })); + }, []); + + const selection: ChangePointTypeFilterOptions = !value + ? [options[0]] + : options.filter((v) => value.includes(v.value!)); + + const onChangeCallback = useCallback( + (selectedOptions: ChangePointTypeFilterOptions) => { + if ( + selectedOptions.length === 0 || + selectedOptions[selectedOptions.length - 1].value === undefined + ) { + onChange(undefined); + return; + } + + onChange(selectedOptions.map((v) => v.value as ChangePointType).filter(isDefined)); + }, + [onChange] + ); + + const renderOption = useCallback((option: FilterOption) => { + const { label, description } = option; + + if (!description) { + return <>{label}; + } + return ( + + + + + + {label} + + + ); + }, []) as unknown as EuiComboBoxOptionsListProps['renderOption']; + + return ( + + + options={options} + selectedOptions={selection} + onChange={onChangeCallback} + isClearable + data-test-subj="aiopsChangePointTypeFilter" + renderOption={renderOption} + /> + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx index e1402496800ca..6cc620a804691 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx @@ -14,16 +14,14 @@ import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; import { useDataSource } from '../../hooks/use_data_source'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; -import { useChangePointDetectionContext } from './change_point_detection_context'; +import { + type ChangePointAnnotation, + useChangePointDetectionContext, +} from './change_point_detection_context'; import { fnOperationTypeMapping } from './constants'; export interface ChartComponentProps { - annotation: { - group_field: string; - label: string; - timestamp: string; - reason: string; - }; + annotation: ChangePointAnnotation; } export const ChartComponent: FC = React.memo(({ annotation }) => { @@ -35,37 +33,38 @@ export const ChartComponent: FC = React.memo(({ annotation const { dataView } = useDataSource(); const { requestParams, bucketInterval } = useChangePointDetectionContext(); - const filters = useMemo( - () => [ - { - meta: { - index: dataView.id!, - alias: null, - negate: false, - disabled: false, - type: 'phrase', - key: requestParams.splitField, - params: { - query: annotation.group_field, - }, - }, - query: { - match_phrase: { - [requestParams.splitField]: annotation.group_field, + const filters = useMemo(() => { + return annotation.group + ? [ + { + meta: { + index: dataView.id!, + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: annotation.group.name, + params: { + query: annotation.group.value, + }, + }, + query: { + match_phrase: { + [annotation.group.name]: annotation.group.value, + }, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, }, - }, - $state: { - store: FilterStateStore.APP_STATE, - }, - }, - ], - [dataView.id, requestParams.splitField, annotation.group_field] - ); + ] + : []; + }, [dataView.id, annotation.group]); // @ts-ignore incorrect types for attributes const attributes = useMemo(() => { return { - title: annotation.group_field, + title: annotation.group?.value ?? '', description: '', visualizationType: 'lnsXY', type: 'lens', @@ -83,6 +82,9 @@ export const ChartComponent: FC = React.memo(({ annotation ], state: { visualization: { + yLeftExtent: { + mode: 'dataBounds', + }, legend: { isVisible: false, position: 'right', @@ -204,7 +206,7 @@ export const ChartComponent: FC = React.memo(({ annotation return ( = { sum: 'sum', avg: 'average', } as const; + +export const DEFAULT_AGG_FUNCTION = 'avg'; + +export const SPLIT_FIELD_CARDINALITY_LIMIT = 10000; + +export const COMPOSITE_AGG_SIZE = 500; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx index bbdc3e5742b08..539df3a13608d 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { FC, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; +import { EuiComboBox, type EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { useChangePointDetectionContext } from './change_point_detection_context'; interface MetricFieldSelectorProps { @@ -19,19 +19,34 @@ export const MetricFieldSelector: FC = React.memo( ({ value, onChange }) => { const { metricFieldOptions } = useChangePointDetectionContext(); - const options = useMemo(() => { - return metricFieldOptions.map((v) => ({ value: v.name, text: v.displayName })); + const options = useMemo(() => { + return metricFieldOptions.map((v) => ({ value: v.name, label: v.displayName })); }, [metricFieldOptions]); + const selection = options.filter((v) => v.value === value); + + const onChangeCallback = useCallback( + (selectedOptions: EuiComboBoxOptionOption[]) => { + const option = selectedOptions[0]; + if (typeof option !== 'undefined') { + onChange(option.label); + } + }, + [onChange] + ); + return ( - onChange(e.target.value)} + selectedOptions={selection} + onChange={onChangeCallback} + isClearable={false} + data-test-subj="aiopsChangePointMetricField" /> ); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx index 1a91e69af65ba..fbe5478054882 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx @@ -5,32 +5,57 @@ * 2.0. */ -import React, { FC, useMemo } from 'react'; +import React, { FC, useMemo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiSelect, type EuiSelectOption } from '@elastic/eui'; +import { EuiComboBox, type EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { useChangePointDetectionContext } from './change_point_detection_context'; interface SplitFieldSelectorProps { - value: string; - onChange: (value: string) => void; + value: string | undefined; + onChange: (value: string | undefined) => void; } export const SplitFieldSelector: FC = React.memo(({ value, onChange }) => { const { splitFieldsOptions } = useChangePointDetectionContext(); - const options = useMemo(() => { - return splitFieldsOptions.map((v) => ({ value: v.name, text: v.displayName })); + const options = useMemo>>(() => { + return [ + { + name: undefined, + displayName: i18n.translate('xpack.aiops.changePointDetection.notSelectedSplitFieldLabel', { + defaultMessage: '--- Not selected ---', + }), + }, + ...splitFieldsOptions, + ].map((v) => ({ + value: v.name, + label: v.displayName, + })); }, [splitFieldsOptions]); + const selection = options.filter((v) => v.value === value); + + const onChangeCallback = useCallback( + (selectedOptions: Array>) => { + const option = selectedOptions[0]; + const newValue = option?.value; + onChange(newValue); + }, + [onChange] + ); + return ( - onChange(e.target.value)} + selectedOptions={selection} + onChange={onChangeCallback} + isClearable + data-test-subj="aiopsChangePointSplitField" /> ); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts index b00c5dab790b7..35d2f768fc45b 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts @@ -8,6 +8,8 @@ import { useEffect, useCallback, useState, useMemo } from 'react'; import { type QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { i18n } from '@kbn/i18n'; +import { useRefresh } from '@kbn/ml-date-picker'; +import { isDefined } from '@kbn/ml-is-defined'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { ChangePointAnnotation, @@ -16,78 +18,87 @@ import { } from './change_point_detection_context'; import { useDataSource } from '../../hooks/use_data_source'; import { useCancellableSearch } from '../../hooks/use_cancellable_search'; -import { useSplitFieldCardinality } from './use_split_field_cardinality'; +import { SPLIT_FIELD_CARDINALITY_LIMIT, COMPOSITE_AGG_SIZE } from './constants'; interface RequestOptions { index: string; fn: string; metricField: string; - splitField: string; + splitField?: string; timeField: string; timeInterval: string; afterKey?: string; } -export const COMPOSITE_AGG_SIZE = 500; - function getChangePointDetectionRequestBody( { index, fn, metricField, splitField, timeInterval, timeField, afterKey }: RequestOptions, query: QueryDslQueryContainer ) { - return { - params: { - index, - size: 0, - body: { - query, - aggregations: { - groupings: { - composite: { - size: COMPOSITE_AGG_SIZE, - ...(afterKey !== undefined ? { after: { splitFieldTerm: afterKey } } : {}), - sources: [ - { - splitFieldTerm: { - terms: { - field: splitField, - }, - }, - }, - ], + const timeSeriesAgg = { + over_time: { + date_histogram: { + field: timeField, + fixed_interval: timeInterval, + }, + aggs: { + function_value: { + [fn]: { + field: metricField, + }, + }, + }, + }, + change_point_request: { + change_point: { + buckets_path: 'over_time>function_value', + }, + }, + // Bucket selecting and sorting are only applicable for partitions + ...(isDefined(splitField) + ? { + select: { + bucket_selector: { + buckets_path: { p_value: 'change_point_request.p_value' }, + script: 'params.p_value < 1', }, - aggregations: { - over_time: { - date_histogram: { - field: timeField, - fixed_interval: timeInterval, - }, - aggs: { - function_value: { - [fn]: { - field: metricField, - }, + }, + sort: { + bucket_sort: { + sort: [{ 'change_point_request.p_value': { order: 'asc' } }], + }, + }, + } + : {}), + }; + + const aggregations = splitField + ? { + groupings: { + composite: { + size: COMPOSITE_AGG_SIZE, + ...(afterKey !== undefined ? { after: { splitFieldTerm: afterKey } } : {}), + sources: [ + { + splitFieldTerm: { + terms: { + field: splitField, }, }, }, - change_point_request: { - change_point: { - buckets_path: 'over_time>function_value', - }, - }, - select: { - bucket_selector: { - buckets_path: { p_value: 'change_point_request.p_value' }, - script: 'params.p_value < 1', - }, - }, - sort: { - bucket_sort: { - sort: [{ 'change_point_request.p_value': { order: 'asc' } }], - }, - }, - }, + ], }, + aggregations: timeSeriesAgg, }, + } + : timeSeriesAgg; + + return { + params: { + index, + size: 0, + body: { + query, + aggregations, }, }, }; @@ -97,7 +108,8 @@ const CHARTS_PER_PAGE = 6; export function useChangePointResults( requestParams: ChangePointDetectionRequestParams, - query: QueryDslQueryContainer + query: QueryDslQueryContainer, + splitFieldCardinality: number | null ) { const { notifications: { toasts }, @@ -105,11 +117,19 @@ export function useChangePointResults( const { dataView } = useDataSource(); + const refresh = useRefresh(); + const [results, setResults] = useState([]); const [activePage, setActivePage] = useState(0); const [progress, setProgress] = useState(0); - const splitFieldCardinality = useSplitFieldCardinality(requestParams.splitField, query); + const isSingleMetric = !isDefined(requestParams.splitField); + + const totalAggPages = useMemo(() => { + return Math.ceil( + Math.min(splitFieldCardinality ?? 0, SPLIT_FIELD_CARDINALITY_LIMIT) / COMPOSITE_AGG_SIZE + ); + }, [splitFieldCardinality]); const { runRequest, cancelRequest, isLoading } = useCancellableSearch(); @@ -121,9 +141,9 @@ export function useChangePointResults( }, [cancelRequest]); const fetchResults = useCallback( - async (afterKey?: string, prevBucketsCount?: number) => { + async (pageNumber: number = 1, afterKey?: string) => { try { - if (!splitFieldCardinality) { + if (!isSingleMetric && !totalAggPages) { setProgress(100); return; } @@ -150,22 +170,28 @@ export function useChangePointResults( return; } - const buckets = result.rawResponse.aggregations.groupings.buckets; + const buckets = ( + isSingleMetric + ? [result.rawResponse.aggregations] + : result.rawResponse.aggregations.groupings.buckets + ) as ChangePointAggResponse['aggregations']['groupings']['buckets']; - setProgress( - Math.min( - Math.round(((buckets.length + (prevBucketsCount ?? 0)) / splitFieldCardinality) * 100), - 100 - ) - ); + setProgress(Math.min(Math.round((pageNumber / totalAggPages) * 100), 100)); - const groups = buckets.map((v) => { + let groups = buckets.map((v) => { const changePointType = Object.keys(v.change_point_request.type)[0] as ChangePointType; const timeAsString = v.change_point_request.bucket?.key; const rawPValue = v.change_point_request.type[changePointType].p_value; return { - group_field: v.key.splitFieldTerm, + ...(isSingleMetric + ? {} + : { + group: { + name: requestParams.splitField, + value: v.key.splitFieldTerm, + }, + }), type: changePointType, p_value: rawPValue, timestamp: timeAsString, @@ -174,6 +200,10 @@ export function useChangePointResults( } as ChangePointAnnotation; }); + if (Array.isArray(requestParams.changePointType)) { + groups = groups.filter((v) => requestParams.changePointType!.includes(v.type)); + } + setResults((prev) => { return ( (prev ?? []) @@ -183,10 +213,13 @@ export function useChangePointResults( ); }); - if (result.rawResponse.aggregations.groupings.after_key?.splitFieldTerm) { + if ( + result.rawResponse.aggregations?.groupings?.after_key?.splitFieldTerm && + pageNumber < totalAggPages + ) { await fetchResults( - result.rawResponse.aggregations.groupings.after_key.splitFieldTerm, - buckets.length + (prevBucketsCount ?? 0) + pageNumber + 1, + result.rawResponse.aggregations.groupings.after_key.splitFieldTerm ); } else { setProgress(100); @@ -199,7 +232,7 @@ export function useChangePointResults( }); } }, - [runRequest, requestParams, query, dataView, splitFieldCardinality, toasts] + [runRequest, requestParams, query, dataView, totalAggPages, toasts, isSingleMetric] ); useEffect( @@ -211,7 +244,7 @@ export function useChangePointResults( cancelRequest(); }; }, - [requestParams, query, splitFieldCardinality, fetchResults, reset, cancelRequest] + [requestParams, query, splitFieldCardinality, fetchResults, reset, cancelRequest, refresh] ); const pagination = useMemo(() => { @@ -230,11 +263,15 @@ export function useChangePointResults( return { results: resultPerPage, isLoading, reset, progress, pagination }; } +/** + * Response type for aggregation with composite agg pagination. + * TODO: update type for the single metric + */ interface ChangePointAggResponse { took: number; timed_out: boolean; _shards: { total: number; failed: number; successful: number; skipped: number }; - hits: { hits: any[]; total: number; max_score: null }; + hits: { hits: unknown[]; total: number; max_score: null }; aggregations: { groupings: { after_key?: { diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts index 19e8212f1b8a8..5bfaf09693184 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts @@ -19,8 +19,11 @@ import { useDataSource } from '../../hooks/use_data_source'; * @param splitField * @param query */ -export function useSplitFieldCardinality(splitField: string, query: QueryDslQueryContainer) { - const [cardinality, setCardinality] = useState(); +export function useSplitFieldCardinality( + splitField: string | undefined, + query: QueryDslQueryContainer +) { + const [cardinality, setCardinality] = useState(null); const { dataView } = useDataSource(); const requestPayload = useMemo(() => { @@ -46,6 +49,10 @@ export function useSplitFieldCardinality(splitField: string, query: QueryDslQuer useEffect( function performCardinalityCheck() { + if (splitField === undefined) { + return; + } + cancelRequest(); getSplitFieldCardinality< @@ -62,7 +69,7 @@ export function useSplitFieldCardinality(splitField: string, query: QueryDslQuer } }); }, - [getSplitFieldCardinality, requestPayload, cancelRequest] + [getSplitFieldCardinality, requestPayload, cancelRequest, splitField] ); return cardinality; diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json index a5f260e695cbb..1f465d61f80ab 100644 --- a/x-pack/plugins/aiops/tsconfig.json +++ b/x-pack/plugins/aiops/tsconfig.json @@ -47,6 +47,7 @@ "@kbn/ml-date-picker", "@kbn/ml-local-storage", "@kbn/ml-query-utils", + "@kbn/ml-is-defined", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 162778c8f5826..49c274f140ea9 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -35,6 +35,12 @@ export type RuleExecutionStatuses = typeof RuleExecutionStatusValues[number]; export const RuleLastRunOutcomeValues = ['succeeded', 'warning', 'failed'] as const; export type RuleLastRunOutcomes = typeof RuleLastRunOutcomeValues[number]; +export const RuleLastRunOutcomeOrderMap: Record = { + succeeded: 0, + warning: 10, + failed: 20, +}; + export enum RuleExecutionStatusErrorReasons { Read = 'read', Decrypt = 'decrypt', @@ -93,6 +99,7 @@ export interface RuleAggregations { export interface RuleLastRun { outcome: RuleLastRunOutcomes; + outcomeOrder?: number; warning?: RuleExecutionStatusErrorReasons | RuleExecutionStatusWarningReasons | null; outcomeMsg?: string[] | null; alertsCount: { 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 4747ad98412c9..cef3cf8804f40 100644 --- a/x-pack/plugins/alerting/public/lib/common_transformations.test.ts +++ b/x-pack/plugins/alerting/public/lib/common_transformations.test.ts @@ -81,6 +81,7 @@ describe('common_transformations', () => { }, last_run: { outcome: RuleLastRunOutcomeValues[2], + outcome_order: 20, outcome_msg: ['this is just a test'], warning: RuleExecutionStatusErrorReasons.Unknown, alerts_count: { @@ -137,6 +138,7 @@ describe('common_transformations', () => { "outcomeMsg": Array [ "this is just a test", ], + "outcomeOrder": 20, "warning": "unknown", }, "monitoring": Object { @@ -255,6 +257,7 @@ describe('common_transformations', () => { }, last_run: { outcome: 'failed', + outcome_order: 20, outcome_msg: ['this is just a test'], warning: RuleExecutionStatusErrorReasons.Unknown, alerts_count: { @@ -300,6 +303,7 @@ describe('common_transformations', () => { "outcomeMsg": Array [ "this is just a test", ], + "outcomeOrder": 20, "warning": "unknown", }, "monitoring": Object { diff --git a/x-pack/plugins/alerting/public/lib/common_transformations.ts b/x-pack/plugins/alerting/public/lib/common_transformations.ts index 6e3888db451b8..6a89b1ce9958b 100644 --- a/x-pack/plugins/alerting/public/lib/common_transformations.ts +++ b/x-pack/plugins/alerting/public/lib/common_transformations.ts @@ -63,10 +63,16 @@ function transformMonitoring(input: RuleMonitoring): RuleMonitoring { } function transformLastRun(input: AsApiContract): RuleLastRun { - const { outcome_msg: outcomeMsg, alerts_count: alertsCount, ...rest } = input; + const { + outcome_msg: outcomeMsg, + alerts_count: alertsCount, + outcome_order: outcomeOrder, + ...rest + } = input; return { outcomeMsg, alertsCount, + outcomeOrder, ...rest, }; } diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.ts b/x-pack/plugins/alerting/server/lib/last_run_status.ts index d2bc22cadb285..a007d8637eac0 100644 --- a/x-pack/plugins/alerting/server/lib/last_run_status.ts +++ b/x-pack/plugins/alerting/server/lib/last_run_status.ts @@ -8,7 +8,7 @@ import { RuleTaskStateAndMetrics } from '../task_runner/types'; import { getReasonFromError } from './error_with_reason'; import { getEsErrorMessage } from './errors'; -import { ActionsCompletion, RuleLastRunOutcomes } from '../../common'; +import { ActionsCompletion, RuleLastRunOutcomeOrderMap, RuleLastRunOutcomes } from '../../common'; import { RuleLastRunOutcomeValues, RuleExecutionStatusWarningReasons, @@ -65,6 +65,7 @@ export const lastRunFromState = ( return { lastRun: { outcome, + outcomeOrder: RuleLastRunOutcomeOrderMap[outcome], outcomeMsg: outcomeMsg.length > 0 ? outcomeMsg : null, warning: warning || null, alertsCount: { @@ -80,9 +81,11 @@ export const lastRunFromState = ( export const lastRunFromError = (error: Error): ILastRun => { const esErrorMessage = getEsErrorMessage(error); + const outcome = RuleLastRunOutcomeValues[2]; return { lastRun: { - outcome: RuleLastRunOutcomeValues[2], + outcome, + outcomeOrder: RuleLastRunOutcomeOrderMap[outcome], warning: getReasonFromError(error), outcomeMsg: esErrorMessage ? [esErrorMessage] : null, alertsCount: {}, @@ -104,5 +107,6 @@ export const lastRunToRaw = (lastRun: ILastRun['lastRun']): RawRuleLastRun => { }, warning: warning ?? null, outcomeMsg: outcomeMsg && !Array.isArray(outcomeMsg) ? [outcomeMsg] : outcomeMsg, + outcomeOrder: RuleLastRunOutcomeOrderMap[lastRun.outcome], }; }; 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 0cc910a4af2f0..bbcb489a9597e 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -9,10 +9,11 @@ import { omit } from 'lodash'; import { RuleTypeParams, SanitizedRule, RuleLastRun } from '../../types'; export const rewriteRuleLastRun = (lastRun: RuleLastRun) => { - const { outcomeMsg, alertsCount, ...rest } = lastRun; + const { outcomeMsg, outcomeOrder, alertsCount, ...rest } = lastRun; return { alerts_count: alertsCount, outcome_msg: outcomeMsg, + outcome_order: outcomeOrder, ...rest, }; }; diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/8.7/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/8.7/index.ts index b2bd0b01f7f61..1b49f606cb6b5 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/8.7/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/8.7/index.ts @@ -14,7 +14,7 @@ import { isLogThresholdRuleType, pipeMigrations, } from '../utils'; -import { RawRule } from '../../../types'; +import { RawRule, RuleLastRunOutcomeOrderMap } from '../../../types'; function addGroupByToEsQueryRule( doc: SavedObjectUnsanitizedDoc @@ -70,9 +70,29 @@ function addLogViewRefToLogThresholdRule( return doc; } +function addOutcomeOrder( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + if (!doc.attributes.lastRun) { + return doc; + } + + const outcome = doc.attributes.lastRun.outcome; + return { + ...doc, + attributes: { + ...doc.attributes, + lastRun: { + ...doc.attributes.lastRun, + outcomeOrder: RuleLastRunOutcomeOrderMap[outcome], + }, + }, + }; +} + export const getMigrations870 = (encryptedSavedObjects: EncryptedSavedObjectsPluginSetup) => createEsoMigration( encryptedSavedObjects, (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(addGroupByToEsQueryRule, addLogViewRefToLogThresholdRule) + pipeMigrations(addGroupByToEsQueryRule, addLogViewRefToLogThresholdRule, addOutcomeOrder) ); 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 5043cad69b696..7ecf7596259fa 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 @@ -2606,6 +2606,28 @@ describe('successful migrations', () => { expect(migratedAlert870.references).toEqual([]); }); }); + + test('migrates last run outcome order', () => { + const migration870 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.7.0']; + + // Failed rule + const failedRule = getMockData({ lastRun: { outcome: 'failed' } }); + const failedRule870 = migration870(failedRule, migrationContext); + expect(failedRule870.attributes.lastRun).toEqual({ outcome: 'failed', outcomeOrder: 20 }); + + // Rule with warnings + const warningRule = getMockData({ lastRun: { outcome: 'warning' } }); + const warningRule870 = migration870(warningRule, migrationContext); + expect(warningRule870.attributes.lastRun).toEqual({ outcome: 'warning', outcomeOrder: 10 }); + + // Succeeded rule + const succeededRule = getMockData({ lastRun: { outcome: 'succeeded' } }); + const succeededRule870 = migration870(succeededRule, migrationContext); + expect(succeededRule870.attributes.lastRun).toEqual({ + outcome: 'succeeded', + outcomeOrder: 0, + }); + }); }); describe('Metrics Inventory Threshold rule', () => { diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 7784247ca75f3..7acb36c34bd9c 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -6,7 +6,14 @@ */ import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { Rule, RuleTypeParams, RecoveredActionGroup, RuleMonitoring } from '../../common'; +import { + Rule, + RuleTypeParams, + RecoveredActionGroup, + RuleMonitoring, + RuleLastRunOutcomeOrderMap, + RuleLastRunOutcomes, +} from '../../common'; import { getDefaultMonitoring } from '../lib/monitoring'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -74,7 +81,7 @@ export const generateSavedObjectParams = ({ error?: null | { reason: string; message: string }; warning?: null | { reason: string; message: string }; status?: string; - outcome?: string; + outcome?: RuleLastRunOutcomes; nextRun?: string | null; successRatio?: number; history?: RuleMonitoring['run']['history']; @@ -110,6 +117,7 @@ export const generateSavedObjectParams = ({ }, lastRun: { outcome, + outcomeOrder: RuleLastRunOutcomeOrderMap[outcome], outcomeMsg: (error?.message && [error?.message]) || (warning?.message && [warning?.message]) || null, warning: error?.reason || warning?.reason || null, 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 d840a9397e248..8b7284bb8ffd4 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 @@ -279,7 +279,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 3, - 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":0,"new":0,"recovered":0,"ignored":0}}' + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":0,"new":0,"recovered":0,"ignored":0}}' ); expect(logger.debug).nthCalledWith( 4, @@ -360,7 +360,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":1,"recovered":0,"ignored":0}}' + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":1,"recovered":0,"ignored":0}}' ); expect(logger.debug).nthCalledWith( 5, @@ -447,7 +447,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":1,"recovered":0,"ignored":0}}' + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":1,"recovered":0,"ignored":0}}' ); expect(logger.debug).nthCalledWith( 6, @@ -627,7 +627,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":2,"new":2,"recovered":0,"ignored":0}}' + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":2,"new":2,"recovered":0,"ignored":0}}' ); expect(logger.debug).nthCalledWith( 6, @@ -1066,7 +1066,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":0,"recovered":1,"ignored":0}}' + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":0,"recovered":1,"ignored":0}}' ); expect(logger.debug).nthCalledWith( 6, @@ -1192,7 +1192,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":0,"recovered":1,"ignored":0}}' + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":1,"new":0,"recovered":1,"ignored":0}}' ); expect(logger.debug).nthCalledWith( 6, @@ -2330,7 +2330,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 3, - 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeMsg":null,"warning":null,"alertsCount":{"active":0,"new":0,"recovered":0,"ignored":0}}' + 'ruleRunStatus for test:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":0,"new":0,"recovered":0,"ignored":0}}' ); expect(logger.debug).nthCalledWith( 4, 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 489b633569333..f0727024d277f 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -45,6 +45,7 @@ import { RuleTypeState, parseDuration, RawAlertInstance, + RuleLastRunOutcomeOrderMap, } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; @@ -825,10 +826,12 @@ export class TaskRunner< this.logger.debug( `Updating rule task for ${this.ruleType.id} rule with id ${ruleId} - execution error due to timeout` ); + const outcome = 'failed'; await this.updateRuleSavedObjectPostRun(ruleId, namespace, { executionStatus: ruleExecutionStatusToRaw(executionStatus), lastRun: { - outcome: 'failed', + outcome, + outcomeOrder: RuleLastRunOutcomeOrderMap[outcome], warning: RuleExecutionStatusErrorReasons.Timeout, outcomeMsg, alertsCount: {}, 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 6c094d330bf16..84ee0f101d8b8 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 @@ -235,6 +235,7 @@ describe('Task Runner Cancel', () => { outcomeMsg: [ 'test:1: execution cancelled due to timeout - exceeded rule type timeout of 5m', ], + outcomeOrder: 20, warning: 'timeout', }, monitoring: { diff --git a/x-pack/plugins/apm/public/components/app/alerts_overview/index.tsx b/x-pack/plugins/apm/public/components/app/alerts_overview/index.tsx index 898762c8350fa..541a4619300f3 100644 --- a/x-pack/plugins/apm/public/components/app/alerts_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/alerts_overview/index.tsx @@ -103,6 +103,7 @@ export function AlertsOverview() { configurationId={AlertConsumers.OBSERVABILITY} featureIds={[AlertConsumers.APM]} query={esQuery} + showAlertStatusWithFlapping showExpandToDetails={false} /> )} diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index f5b8c48d97a67..dd62cafd3b958 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -309,7 +309,8 @@ describe('', () => { }); }); - describe('detail panel', () => { + // FLAKY: https://github.com/elastic/kibana/issues/100951 + describe.skip('detail panel', () => { test('should open a detail panel when clicking on a follower index', async () => { expect(exists('followerIndexDetail')).toBe(false); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx index da664b3d97490..745a6ed5e131e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react'; import { useActions, useValues } from 'kea'; import { + EuiTableActionsColumnType, EuiBasicTableColumn, EuiButton, EuiConfirmModal, @@ -52,6 +53,33 @@ export const EngineIndices: React.FC = () => { if (!engineData) return null; const { indices } = engineData; + const removeIndexAction: EuiTableActionsColumnType['actions'][0] = { + color: 'danger', + 'data-test-subj': 'engine-remove-index-btn', + description: i18n.translate( + 'xpack.enterpriseSearch.content.engine.indices.actions.removeIndex.title', + { + defaultMessage: 'Remove this index from engine', + } + ), + icon: 'minusInCircle', + isPrimary: false, + name: (index: EnterpriseSearchEngineIndex) => + i18n.translate('xpack.enterpriseSearch.content.engine.indices.actions.removeIndex.caption', { + defaultMessage: 'Remove index {indexName}', + values: { + indexName: index.name, + }, + }), + onClick: (index: EnterpriseSearchEngineIndex) => { + setConfirmRemoveIndex(index.name); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-indices-removeIndex', + }); + }, + type: 'icon', + }; const columns: Array> = [ { field: 'name', @@ -138,36 +166,7 @@ export const EngineIndices: React.FC = () => { ), type: 'icon', }, - { - color: 'danger', - 'data-test-subj': 'engine-remove-index-btn', - description: i18n.translate( - 'xpack.enterpriseSearch.content.engine.indices.actions.removeIndex.title', - { - defaultMessage: 'Remove this index from engine', - } - ), - icon: 'minusInCircle', - isPrimary: false, - name: (index) => - i18n.translate( - 'xpack.enterpriseSearch.content.engine.indices.actions.removeIndex.caption', - { - defaultMessage: 'Remove index {indexName}', - values: { - indexName: index.name, - }, - } - ), - onClick: (index) => { - setConfirmRemoveIndex(index.name); - sendEnterpriseSearchTelemetry({ - action: 'clicked', - metric: 'entSearchContent-engines-indices-removeIndex', - }); - }, - type: 'icon', - }, + ...(indices.length > 1 ? [removeIndexAction] : []), ], name: i18n.translate('xpack.enterpriseSearch.content.engine.indices.actions.columnTitle', { defaultMessage: 'Actions', diff --git a/x-pack/plugins/lists/common/schemas/request/import_exceptions_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/import_exceptions_schema.mock.ts index a9440520ec27b..9baf9f77c785b 100644 --- a/x-pack/plugins/lists/common/schemas/request/import_exceptions_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/import_exceptions_schema.mock.ts @@ -53,6 +53,7 @@ export const getImportExceptionsListItemSchemaDecodedMock = ( ): ImportExceptionListItemSchemaDecoded => ({ ...getImportExceptionsListItemSchemaMock(itemId, listId), comments: [], + expire_time: undefined, meta: undefined, namespace_type: 'single', os_types: [], diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts index b49ad7150bdbc..4ebfcd0fec910 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts @@ -33,6 +33,7 @@ export const getExceptionListItemSchemaMock = ( created_by: USER, description: DESCRIPTION, entries: ENTRIES, + expire_time: undefined, id: '1', item_id: 'endpoint_list_item', list_id: 'endpoint_list_id', diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index c6f423184f926..95675fe96aee8 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -698,6 +698,7 @@ describe('Exceptions Lists API', () => { await exportExceptionList({ http: httpMock, id: 'some-id', + includeExpiredExceptions: true, listId: 'list-id', namespaceType: 'single', signal: abortCtrl.signal, @@ -707,6 +708,7 @@ describe('Exceptions Lists API', () => { method: 'POST', query: { id: 'some-id', + include_expired_exceptions: true, list_id: 'list-id', namespace_type: 'single', }, @@ -718,6 +720,7 @@ describe('Exceptions Lists API', () => { const exceptionResponse = await exportExceptionList({ http: httpMock, id: 'some-id', + includeExpiredExceptions: true, listId: 'list-id', namespaceType: 'single', signal: abortCtrl.signal, diff --git a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts index d6272626618ec..f031b9d1e2882 100644 --- a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts @@ -43,6 +43,7 @@ export const createEndpointListItemRoute = (router: ListsPluginRouter): void => comments, description, entries, + expire_time: expireTime, item_id: itemId, os_types: osTypes, type, @@ -62,6 +63,7 @@ export const createEndpointListItemRoute = (router: ListsPluginRouter): void => comments, description, entries, + expireTime, itemId, meta, name, diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts index 1659fc2645f16..7754bc9cc31b5 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts @@ -50,6 +50,7 @@ export const createExceptionListItemRoute = (router: ListsPluginRouter): void => list_id: listId, os_types: osTypes, type, + expire_time: expireTime, } = request.body; const exceptionLists = await getExceptionListClient(context); const exceptionList = await exceptionLists.getExceptionList({ @@ -92,6 +93,7 @@ export const createExceptionListItemRoute = (router: ListsPluginRouter): void => comments, description, entries, + expireTime, itemId, listId, meta, diff --git a/x-pack/plugins/lists/server/routes/delete_list_route.ts b/x-pack/plugins/lists/server/routes/delete_list_route.ts index 57923699c25eb..e9f0e1245f1d7 100644 --- a/x-pack/plugins/lists/server/routes/delete_list_route.ts +++ b/x-pack/plugins/lists/server/routes/delete_list_route.ts @@ -217,6 +217,7 @@ const updateExceptionListItems = async ( comments: listItem.comments, description: listItem.description, entries: remainingEntries, + expireTime: listItem.expire_time, id: listItem.id, itemId: listItem.item_id, meta: listItem.meta, diff --git a/x-pack/plugins/lists/server/routes/export_exception_list_route.ts b/x-pack/plugins/lists/server/routes/export_exception_list_route.ts index 03c7b416ab54f..71f67ead4a8a1 100644 --- a/x-pack/plugins/lists/server/routes/export_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/export_exception_list_route.ts @@ -28,11 +28,22 @@ export const exportExceptionsRoute = (router: ListsPluginRouter): void => { const siemResponse = buildSiemResponse(response); try { - const { id, list_id: listId, namespace_type: namespaceType } = request.query; + const { + id, + list_id: listId, + namespace_type: namespaceType, + include_expired_exceptions: includeExpiredExceptionsString, + } = request.query; const exceptionListsClient = await getExceptionListClient(context); + // Defaults to including expired exceptions if query param is not present + const includeExpiredExceptions = + includeExpiredExceptionsString !== undefined + ? includeExpiredExceptionsString === 'true' + : true; const exportContent = await exceptionListsClient.exportExceptionListAndItems({ id, + includeExpiredExceptions, listId, namespaceType, }); diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts index f77a3a7327d69..c337ac818cbfc 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts @@ -52,6 +52,11 @@ export const findExceptionListItemRoute = (router: ListsPluginRouter): void => { body: `list_id and namespace_id need to have the same comma separated number of values. Expected list_id length: ${listId.length} to equal namespace_type length: ${namespaceType.length}`, statusCode: 400, }); + } else if (listId.length !== filter.length && filter.length !== 0) { + return siemResponse.error({ + body: `list_id and filter need to have the same comma separated number of values. Expected list_id length: ${listId.length} to equal filter length: ${filter.length}`, + statusCode: 400, + }); } else { const exceptionListItems = await exceptionLists.findExceptionListsItem({ filter, diff --git a/x-pack/plugins/lists/server/routes/get_exception_filter_route.ts b/x-pack/plugins/lists/server/routes/get_exception_filter_route.ts index 0746421e1ee49..b06076e0fbbef 100644 --- a/x-pack/plugins/lists/server/routes/get_exception_filter_route.ts +++ b/x-pack/plugins/lists/server/routes/get_exception_filter_route.ts @@ -82,6 +82,7 @@ export const getExceptionFilterRoute = (router: ListsPluginRouter): void => { excludeExceptions, listClient, lists: exceptionItems, + startedAt: new Date(), }); return response.ok({ body: { filter } ?? {} }); diff --git a/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts index e1e117f6c5604..a0a2c8f43f83f 100644 --- a/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts @@ -49,6 +49,7 @@ export const updateEndpointListItemRoute = (router: ListsPluginRouter): void => entries, item_id: itemId, tags, + expire_time: expireTime, } = request.body; const exceptionLists = await getExceptionListClient(context); const exceptionListItem = await exceptionLists.updateEndpointListItem({ @@ -56,6 +57,7 @@ export const updateEndpointListItemRoute = (router: ListsPluginRouter): void => comments, description, entries, + expireTime, id, itemId, meta, diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts index a997ac132e7cf..e4751662a1949 100644 --- a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts @@ -56,6 +56,7 @@ export const updateExceptionListItemRoute = (router: ListsPluginRouter): void => namespace_type: namespaceType, os_types: osTypes, tags, + expire_time: expireTime, } = request.body; if (id == null && itemId == null) { return siemResponse.error({ @@ -69,6 +70,7 @@ export const updateExceptionListItemRoute = (router: ListsPluginRouter): void => comments, description, entries, + expireTime, id, itemId, meta, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index be0a9982e2913..4d401da8d74d2 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -159,6 +159,9 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = { }, }, }, + expire_time: { + type: 'date', + }, item_id: { type: 'keyword', }, diff --git a/x-pack/plugins/lists/server/saved_objects/migrations.test.ts b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts index f4b594e63fe1d..85167aa0aadb7 100644 --- a/x-pack/plugins/lists/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts @@ -22,6 +22,7 @@ const DEFAULT_EXCEPTION_LIST_SO: ExceptionListSoSchema = { created_by: 'user', description: 'description', entries: undefined, + expire_time: undefined, immutable: false, item_id: undefined, list_id: 'some_list', diff --git a/x-pack/plugins/lists/server/schemas/saved_objects/exceptions_list_so_schema.ts b/x-pack/plugins/lists/server/schemas/saved_objects/exceptions_list_so_schema.ts index b5583e5bc9820..4c06f7276661c 100644 --- a/x-pack/plugins/lists/server/schemas/saved_objects/exceptions_list_so_schema.ts +++ b/x-pack/plugins/lists/server/schemas/saved_objects/exceptions_list_so_schema.ts @@ -14,6 +14,7 @@ import { entriesArrayOrUndefined, exceptionListItemType, exceptionListType, + expireTimeOrUndefined, immutableOrUndefined, itemIdOrUndefined, list_id, @@ -37,6 +38,7 @@ export const exceptionListSoSchema = t.exact( created_by, description, entries: entriesArrayOrUndefined, + expire_time: expireTimeOrUndefined, immutable: immutableOrUndefined, item_id: itemIdOrUndefined, list_id, diff --git a/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.test.ts b/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.test.ts index 17f15a39bc21f..2c1f61fabd54f 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.test.ts @@ -11,6 +11,7 @@ import type { ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; +import { ENTRIES } from '../../../common/constants.mock'; import { getEntryMatchAnyExcludeMock, getEntryMatchAnyMock, @@ -51,6 +52,7 @@ import { buildNestedClause, createOrClauses, filterOutUnprocessableValueLists, + removeExpiredExceptions, } from './build_exception_filter'; const modifiedGetEntryMatchAnyMock = (): EntryMatchAny => ({ @@ -70,6 +72,7 @@ describe('build_exceptions_filter', () => { excludeExceptions: false, listClient, lists: [], + startedAt: new Date(), }); expect(filter).toBeUndefined(); }); @@ -81,6 +84,7 @@ describe('build_exceptions_filter', () => { excludeExceptions: false, listClient, lists: [getExceptionListItemSchemaMock()], + startedAt: new Date(), }); expect(filter).toMatchInlineSnapshot(` @@ -154,6 +158,7 @@ describe('build_exceptions_filter', () => { excludeExceptions: true, listClient, lists: [exceptionItem1, exceptionItem2], + startedAt: new Date(), }); expect(filter).toMatchInlineSnapshot(` Object { @@ -236,6 +241,7 @@ describe('build_exceptions_filter', () => { excludeExceptions: true, listClient, lists: [exceptionItem1, exceptionItem2, exceptionItem3], + startedAt: new Date(), }); expect(filter).toMatchInlineSnapshot(` @@ -325,6 +331,7 @@ describe('build_exceptions_filter', () => { excludeExceptions: true, listClient, lists: exceptions, + startedAt: new Date(), }); expect(filter).toMatchInlineSnapshot(` @@ -479,6 +486,95 @@ describe('build_exceptions_filter', () => { } `); }); + + test('it should remove all exception items that are expired', async () => { + const futureDate = new Date(Date.now() + 1000000).toISOString(); + const expiredDate = new Date(Date.now() - 1000000).toISOString(); + const exceptions = [ + { ...getExceptionListItemSchemaMock(), entries: [ENTRIES[0]], expire_time: futureDate }, + { ...getExceptionListItemSchemaMock(), entries: [ENTRIES[1]], expire_time: expiredDate }, + getExceptionListItemSchemaMock(), + ]; + + const { filter } = await buildExceptionFilter({ + alias: null, + chunkSize: 1, + excludeExceptions: true, + listClient, + lists: exceptions, + startedAt: new Date(), + }); + + expect(filter).toMatchInlineSnapshot(` + Object { + "meta": Object { + "alias": null, + "disabled": false, + "negate": true, + }, + "query": Object { + "bool": Object { + "should": Array [ + Object { + "nested": Object { + "path": "some.parentField", + "query": Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "some.parentField.nested.field": "some value", + }, + }, + ], + }, + }, + "score_mode": "none", + }, + }, + Object { + "bool": Object { + "filter": Array [ + Object { + "nested": Object { + "path": "some.parentField", + "query": Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "some.parentField.nested.field": "some value", + }, + }, + ], + }, + }, + "score_mode": "none", + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "some.not.nested.field": "some value", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + } + `); + }); }); describe('createOrClauses', () => { @@ -1297,4 +1393,22 @@ describe('build_exceptions_filter', () => { expect(unprocessableValueListExceptions).toEqual([listExceptionItem]); }); }); + + describe('removeExpiredExceptions', () => { + test('it should filter out expired exceptions', () => { + const futureDate = new Date(Date.now() + 1000000).toISOString(); + const expiredDate = new Date(Date.now() - 1000000).toISOString(); + const exceptions = [ + { ...getExceptionListItemSchemaMock(), expire_time: futureDate }, + { ...getExceptionListItemSchemaMock(), expire_time: expiredDate }, + getExceptionListItemSchemaMock(), + ]; + const filteredExceptions = removeExpiredExceptions(exceptions, new Date()); + + expect(filteredExceptions).toEqual([ + { ...getExceptionListItemSchemaMock(), expire_time: futureDate }, + getExceptionListItemSchemaMock(), + ]); + }); + }); }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.ts b/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.ts index 1eb17fe40ab26..8e453a30fc766 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/build_exception_filter.ts @@ -273,6 +273,19 @@ export const filterOutUnprocessableValueLists = async < return { filteredExceptions, unprocessableValueListExceptions }; }; +export const removeExpiredExceptions = < + T extends ExceptionListItemSchema | CreateExceptionListItemSchema +>( + lists: T[], + startedAt: Date +): T[] => + lists.filter((listItem) => { + if (listItem.expire_time && new Date(listItem.expire_time) < startedAt) { + return false; + } + return true; + }); + export const buildExceptionFilter = async < T extends ExceptionListItemSchema | CreateExceptionListItemSchema >({ @@ -281,17 +294,21 @@ export const buildExceptionFilter = async < chunkSize, alias = null, listClient, + startedAt, }: { lists: T[]; excludeExceptions: boolean; chunkSize: number; alias: string | null; listClient: ListClient; + startedAt: Date; }): Promise<{ filter: Filter | undefined; unprocessedExceptions: T[] }> => { + const filteredLists = removeExpiredExceptions(lists, startedAt); + // Remove exception items with large value lists. These are evaluated // elsewhere for the moment being. const [exceptionsWithoutValueLists, valueListExceptions] = partition( - lists, + filteredLists, (item): item is T => !hasLargeValueList(item.entries) ); diff --git a/x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts index 0d770fa699ad4..5043c16ca6f75 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts @@ -41,6 +41,7 @@ export const bulkCreateExceptionListItems = async ({ created_by: user, description: item.description, entries: item.entries, + expire_time: item.expire_time, immutable: false, item_id: item.item_id, list_id: item.list_id, diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts index deed1b41bd8ed..d939d1f7baf1b 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts @@ -44,6 +44,7 @@ export const createEndpointList = async ({ created_by: user, description: ENDPOINT_LIST_DESCRIPTION, entries: undefined, + expire_time: undefined, immutable: false, item_id: undefined, list_id: ENDPOINT_LIST_ID, diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts index bf6a0bd00ff4c..ea08f03aac6f8 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts @@ -52,6 +52,7 @@ export const createEndpointTrustedAppsList = async ({ created_by: user, description: ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION, entries: undefined, + expire_time: undefined, immutable: false, item_id: undefined, list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts index 466c877a1abf4..7fcb831ed6e91 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts @@ -62,6 +62,7 @@ export const createExceptionList = async ({ created_by: user, description, entries: undefined, + expire_time: undefined, immutable, item_id: undefined, list_id: listId, diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index 44fac7b07f8d2..eb6e2ca85546d 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -13,6 +13,7 @@ import type { EntriesArray, ExceptionListItemSchema, ExceptionListItemType, + ExpireTimeOrUndefined, ItemId, ListId, MetaOrUndefined, @@ -45,11 +46,13 @@ interface CreateExceptionListItemOptions { tieBreaker?: string; type: ExceptionListItemType; osTypes: OsTypeArray; + expireTime: ExpireTimeOrUndefined; } export const createExceptionListItem = async ({ comments, entries, + expireTime, itemId, listId, savedObjectsClient, @@ -75,6 +78,7 @@ export const createExceptionListItem = async ({ created_by: user, description, entries, + expire_time: expireTime, immutable: undefined, item_id: itemId, list_id: listId, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts index 218ccf585dec4..303a0751e4d2a 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts @@ -88,6 +88,7 @@ export const getCreateExceptionListItemOptionsMock = (): CreateExceptionListItem comments, description, entries, + expire_time: expireTime, item_id: itemId, list_id: listId, meta, @@ -102,6 +103,7 @@ export const getCreateExceptionListItemOptionsMock = (): CreateExceptionListItem comments, description, entries, + expireTime, itemId, listId, meta, @@ -114,14 +116,26 @@ export const getCreateExceptionListItemOptionsMock = (): CreateExceptionListItem }; export const getUpdateExceptionListItemOptionsMock = (): UpdateExceptionListItemOptions => { - const { comments, entries, itemId, namespaceType, name, osTypes, description, meta, tags, type } = - getCreateExceptionListItemOptionsMock(); + const { + comments, + entries, + expireTime, + itemId, + namespaceType, + name, + osTypes, + description, + meta, + tags, + type, + } = getCreateExceptionListItemOptionsMock(); return { _version: undefined, comments, description, entries, + expireTime, id: ID, itemId, meta, @@ -161,6 +175,7 @@ export const getExceptionListSoSchemaMock = ( created_by, description, entries, + expire_time: undefined, immutable: undefined, item_id, list_id, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts index 8ad92f5d14bff..95064ddaa3f97 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts @@ -247,6 +247,7 @@ describe('exception_list_client', () => { (): ReturnType => { return exceptionListClient.exportExceptionListAndItems({ id: '1', + includeExpiredExceptions: true, listId: '1', namespaceType: 'agnostic', }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 562ebffc9f00c..b8fbb35f29354 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -287,6 +287,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, itemId, meta, name, @@ -300,6 +301,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, itemId, listId: ENDPOINT_LIST_ID, meta, @@ -356,6 +358,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, id, itemId, meta, @@ -371,6 +374,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, id, itemId, meta, @@ -526,6 +530,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, itemId, listId, meta, @@ -540,6 +545,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, itemId, listId, meta, @@ -593,6 +599,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, id, itemId, meta, @@ -608,6 +615,7 @@ export class ExceptionListClient { comments, description, entries, + expireTime, id, itemId, meta, @@ -970,6 +978,7 @@ export class ExceptionListClient { listId, id, namespaceType, + includeExpiredExceptions, }: ExportExceptionListAndItemsOptions): Promise => { const { savedObjectsClient } = this; @@ -978,6 +987,7 @@ export class ExceptionListClient { 'exceptionsListPreExport', { id, + includeExpiredExceptions, listId, namespaceType, }, @@ -987,6 +997,7 @@ export class ExceptionListClient { return exportExceptionListAndItems({ id, + includeExpiredExceptions, listId, namespaceType, savedObjectsClient, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 6b87945710a37..b994919398a1c 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -21,6 +21,7 @@ import type { ExceptionListItemTypeOrUndefined, ExceptionListType, ExceptionListTypeOrUndefined, + ExpireTimeOrUndefined, ExportExceptionDetails, FilterOrUndefined, FoundExceptionListItemSchema, @@ -242,6 +243,8 @@ export interface CreateExceptionListItemOptions { comments: CreateCommentsArray; /** an array with the exception list item entries */ entries: EntriesArray; + /** an optional datetime string with an expiration time */ + expireTime: ExpireTimeOrUndefined; /** the "item_id" of the exception list item */ itemId: ItemId; /** the "list_id" of the parent exception list */ @@ -271,6 +274,8 @@ export interface CreateEndpointListItemOptions { comments: CreateCommentsArray; /** The entries of the endpoint list item */ entries: EntriesArray; + /** an optional datetime string with an expiration time */ + expireTime: ExpireTimeOrUndefined; /** The item id of the list item */ itemId: ItemId; /** The name of the list item */ @@ -309,6 +314,8 @@ export interface UpdateExceptionListItemOptions { comments: UpdateCommentsArray; /** item exception entries logic */ entries: EntriesArray; + /** an optional datetime string with an expiration time */ + expireTime: ExpireTimeOrUndefined; /** the "id" of the exception list item */ id: IdOrUndefined; /** the "item_id" of the exception list item */ @@ -340,6 +347,8 @@ export interface UpdateEndpointListItemOptions { comments: UpdateCommentsArray; /** The entries of the endpoint list item */ entries: EntriesArray; + /** an optional datetime string with an expiration time */ + expireTime: ExpireTimeOrUndefined; /** The id of the list item (Either this or itemId has to be defined) */ id: IdOrUndefined; /** The item id of the list item (Either this or id has to be defined) */ @@ -490,6 +499,8 @@ export interface ExportExceptionListAndItemsOptions { id: IdOrUndefined; /** saved object namespace (single | agnostic) */ namespaceType: NamespaceType; + /** whether or not to include expired exceptions */ + includeExpiredExceptions: boolean; } /** diff --git a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts index 06e764bd1658b..acad2dd0f45ee 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts @@ -29,6 +29,7 @@ describe('export_exception_list_and_items', () => { const result = await exportExceptionListAndItems({ id: '123', + includeExpiredExceptions: true, listId: 'non-existent', namespaceType: 'single', savedObjectsClient: savedObjectsClientMock.create(), @@ -45,6 +46,7 @@ describe('export_exception_list_and_items', () => { ); const result = await exportExceptionListAndItems({ id: '123', + includeExpiredExceptions: true, listId: 'non-existent', namespaceType: 'single', savedObjectsClient: savedObjectsClientMock.create(), diff --git a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts index fecbb91837b6f..006c7773e4e89 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts @@ -15,6 +15,7 @@ import type { } from '@kbn/securitysolution-io-ts-list-types'; import { transformDataToNdjson } from '@kbn/securitysolution-utils'; import type { SavedObjectsClientContract } from '@kbn/core/server'; +import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; import { getExceptionList } from './get_exception_list'; @@ -24,6 +25,7 @@ interface ExportExceptionListAndItemsOptions { listId: ListIdOrUndefined; savedObjectsClient: SavedObjectsClientContract; namespaceType: NamespaceType; + includeExpiredExceptions: boolean; } export interface ExportExceptionListAndItemsReturn { @@ -35,6 +37,7 @@ export const exportExceptionListAndItems = async ({ id, listId, namespaceType, + includeExpiredExceptions, savedObjectsClient, }: ExportExceptionListAndItemsOptions): Promise => { const exceptionList = await getExceptionList({ @@ -52,10 +55,14 @@ export const exportExceptionListAndItems = async ({ const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { exceptionItems = [...exceptionItems, ...response.data]; }; + const savedObjectPrefix = getSavedObjectType({ namespaceType }); + const filter = includeExpiredExceptions + ? undefined + : `(${savedObjectPrefix}.attributes.expire_time > "${new Date().toISOString()}" OR NOT ${savedObjectPrefix}.attributes.expire_time: *)`; await findExceptionListItemPointInTimeFinder({ executeFunctionOnStream, - filter: undefined, + filter, listId: exceptionList.list_id, maxSize: undefined, // NOTE: This is unbounded when it is "undefined" namespaceType: exceptionList.namespace_type, diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts index 81e58709528c7..e3fcb9d2461e4 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts @@ -11,6 +11,7 @@ import type { EntriesArray, ExceptionListItemSchema, ExceptionListItemTypeOrUndefined, + ExpireTimeOrUndefined, IdOrUndefined, ItemIdOrUndefined, MetaOrUndefined, @@ -38,6 +39,7 @@ interface UpdateExceptionListItemOptions { name: NameOrUndefined; description: DescriptionOrUndefined; entries: EntriesArray; + expireTime: ExpireTimeOrUndefined; savedObjectsClient: SavedObjectsClientContract; namespaceType: NamespaceType; osTypes: OsTypeArray; @@ -53,6 +55,7 @@ export const updateExceptionListItem = async ({ _version, comments, entries, + expireTime, id, savedObjectsClient, namespaceType, @@ -87,6 +90,7 @@ export const updateExceptionListItem = async ({ comments: transformedComments, description, entries, + expire_time: expireTime, meta, name, os_types: osTypes, diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts index 8e8b3499338dc..33ef8f1936aa5 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.test.ts @@ -39,7 +39,7 @@ describe('getExceptionListsItemFilter', () => { savedObjectType: ['exception-list'], }); expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "some-list-id") AND exception-list.attributes.name: "Sample Endpoint Exception List")' + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "some-list-id") AND (exception-list.attributes.name: "Sample Endpoint Exception List"))' ); }); @@ -61,7 +61,7 @@ describe('getExceptionListsItemFilter', () => { savedObjectType: ['exception-list', 'exception-list-agnostic'], }); expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2")' + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND (exception-list.attributes.name: "Sample Endpoint Exception List")) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2")' ); }); @@ -83,7 +83,7 @@ describe('getExceptionListsItemFilter', () => { savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], }); expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3")' + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND (exception-list.attributes.name: "Sample Endpoint Exception List")) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3")' ); }); @@ -98,7 +98,7 @@ describe('getExceptionListsItemFilter', () => { savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], }); expect(filter).toEqual( - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND exception-list.attributes.name: "Sample Endpoint Exception List 1") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") AND exception-list.attributes.name: "Sample Endpoint Exception List 2") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3") AND exception-list.attributes.name: "Sample Endpoint Exception List 3")' + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "list-1") AND (exception-list.attributes.name: "Sample Endpoint Exception List 1")) OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-2") AND (exception-list.attributes.name: "Sample Endpoint Exception List 2")) OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "list-3") AND (exception-list.attributes.name: "Sample Endpoint Exception List 3"))' ); }); }); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts index 935ae8839a71d..fdb2ca954f070 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/get_exception_lists_item_filter.ts @@ -26,7 +26,7 @@ export const getExceptionListsItemFilter = ({ const escapedListId = escapeQuotes(singleListId); const listItemAppend = `(${savedObjectType[index]}.attributes.list_type: item AND ${savedObjectType[index]}.attributes.list_id: "${escapedListId}")`; const listItemAppendWithFilter = - filter[index] != null ? `(${listItemAppend} AND ${filter[index]})` : listItemAppend; + filter[index] != null ? `(${listItemAppend} AND (${filter[index]}))` : listItemAppend; if (accum === '') { return listItemAppendWithFilter; } else { diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.test.ts index a3dbc4cdfa9eb..91fa46fe953f5 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_items.test.ts @@ -22,6 +22,7 @@ describe('bulkCreateImportedItems', () => { created_by: 'elastic', description: 'description here', entries: ENTRIES, + expire_time: undefined, immutable: undefined, item_id: 'item-id', list_id: 'list-id', diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.test.ts index 9001f225e7364..dfac36c2bf874 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/bulk_create_imported_lists.test.ts @@ -21,6 +21,7 @@ describe('bulkCreateImportedLists', () => { created_by: 'elastic', description: 'some description', entries: undefined, + expire_time: undefined, immutable: false, item_id: undefined, list_id: 'list-id', diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.test.ts index 60d63c00b0114..7320d7dafe397 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/find_all_exception_list_item_types.test.ts @@ -65,7 +65,7 @@ describe('find_all_exception_list_item_types', () => { expect(savedObjectsClient.find).toHaveBeenCalledWith({ filter: - '((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "detection_list_id") AND exception-list-agnostic.attributes.item_id:(1))', + '((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "detection_list_id") AND (exception-list-agnostic.attributes.item_id:(1)))', page: undefined, perPage: 100, sortField: undefined, @@ -83,7 +83,7 @@ describe('find_all_exception_list_item_types', () => { expect(savedObjectsClient.find).toHaveBeenCalledWith({ filter: - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "detection_list_id") AND exception-list.attributes.item_id:(1))', + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "detection_list_id") AND (exception-list.attributes.item_id:(1)))', page: undefined, perPage: 100, sortField: undefined, @@ -101,7 +101,7 @@ describe('find_all_exception_list_item_types', () => { expect(savedObjectsClient.find).toHaveBeenCalledWith({ filter: - '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "detection_list_id") AND exception-list.attributes.item_id:(2)) OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "detection_list_id") AND exception-list-agnostic.attributes.item_id:(1))', + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: "detection_list_id") AND (exception-list.attributes.item_id:(2))) OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: "detection_list_id") AND (exception-list-agnostic.attributes.item_id:(1)))', page: undefined, perPage: 100, sortField: undefined, diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_update.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_update.ts index 2c93b0d33e35c..0eca82c3ac325 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_update.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_items_to_create_update.ts @@ -50,6 +50,7 @@ export const sortExceptionItemsToUpdateOrCreate = ({ comments, description, entries, + expire_time: expireTime, item_id: itemId, meta, list_id: listId, @@ -89,6 +90,7 @@ export const sortExceptionItemsToUpdateOrCreate = ({ created_by: user, description, entries, + expire_time: expireTime, immutable: undefined, item_id: itemId, list_id: listId, diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_update.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_update.ts index 6dbfea6e724db..cb255eab3c66e 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_update.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/import/sort_exception_lists_to_create_update.ts @@ -71,6 +71,7 @@ export const sortExceptionListsToUpdateOrCreate = ({ created_by: user, description, entries: undefined, + expire_time: undefined, immutable: false, item_id: undefined, list_id: listId, @@ -112,6 +113,7 @@ export const sortExceptionListsToUpdateOrCreate = ({ created_by: user, description, entries: undefined, + expire_time: undefined, immutable: false, item_id: undefined, list_type: 'list', diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts b/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts index 1cfce1208ece0..8af2bfcfa7175 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils/index.ts @@ -150,6 +150,7 @@ export const transformSavedObjectToExceptionListItem = ({ created_by, description, entries, + expire_time, item_id: itemId, list_id, meta, @@ -174,6 +175,7 @@ export const transformSavedObjectToExceptionListItem = ({ created_by, description, entries: entries ?? [], + expire_time, id, item_id: itemId ?? '(unknown)', list_id, @@ -203,6 +205,7 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ comments, description, entries, + expire_time: expireTime, meta, name, os_types: osTypes, @@ -225,6 +228,7 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ created_by: exceptionListItem.created_by, description: description ?? exceptionListItem.description, entries: entries ?? exceptionListItem.entries, + expire_time: expireTime ?? exceptionListItem.expire_time, id, item_id: exceptionListItem.item_id, list_id: exceptionListItem.list_id, @@ -308,6 +312,7 @@ export const transformCreateCommentsToComments = ({ }; export const transformCreateExceptionListItemOptionsToCreateExceptionListItemSchema = ({ + expireTime, listId, itemId, namespaceType, @@ -316,6 +321,7 @@ export const transformCreateExceptionListItemOptionsToCreateExceptionListItemSch }: CreateExceptionListItemOptions): CreateExceptionListItemSchema => { return { ...rest, + expire_time: expireTime, item_id: itemId, list_id: listId, namespace_type: namespaceType, @@ -327,6 +333,7 @@ export const transformUpdateExceptionListItemOptionsToUpdateExceptionListItemSch itemId, namespaceType, osTypes, + expireTime, // The `UpdateExceptionListItemOptions` type differs from the schema in that some properties are // marked as having `undefined` as a valid value, where the schema, however, requires it. // So we assign defaults here @@ -338,6 +345,7 @@ export const transformUpdateExceptionListItemOptionsToUpdateExceptionListItemSch return { ...rest, description, + expire_time: expireTime, item_id: itemId, name, namespace_type: namespaceType, diff --git a/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx b/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx index b3d3a537b9be1..77642001757ab 100644 --- a/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx +++ b/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { pick } from 'lodash'; - +import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -62,7 +62,12 @@ export const ChangePointDetectionPage: FC = () => { ])} /> ) : null} - + ); }; diff --git a/x-pack/plugins/ml/public/application/components/help_menu/help_menu.tsx b/x-pack/plugins/ml/public/application/components/help_menu/help_menu.tsx index 4d0c88036dad3..ab92a134e421e 100644 --- a/x-pack/plugins/ml/public/application/components/help_menu/help_menu.tsx +++ b/x-pack/plugins/ml/public/application/components/help_menu/help_menu.tsx @@ -11,17 +11,20 @@ import { useMlKibana } from '../../contexts/kibana'; interface HelpMenuProps { docLink: string; + appName?: string; } // Component for adding a documentation link to the help menu -export const HelpMenu: FC = React.memo(({ docLink }) => { +export const HelpMenu: FC = React.memo(({ docLink, appName }) => { const { chrome } = useMlKibana().services; useEffect(() => { chrome.setHelpExtension({ - appName: i18n.translate('xpack.ml.chrome.help.appName', { - defaultMessage: 'Machine Learning', - }), + appName: + appName ?? + i18n.translate('xpack.ml.chrome.help.appName', { + defaultMessage: 'Machine Learning', + }), links: [ { href: docLink, diff --git a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts index 6109eabb87e96..9f10d7e537a9f 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts @@ -329,14 +329,17 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper createdAlerts: augmentedAlerts .map((alert, idx) => { const responseItem = - bulkResponse.body.items[idx + duplicateAlertUpdates.length].create; + bulkResponse.body.items[idx + duplicateAlerts.length].create; return { _id: responseItem?._id ?? '', _index: responseItem?._index ?? '', ...alert._source, }; }) - .filter((_, idx) => bulkResponse.body.items[idx].create?.status === 201), + .filter( + (_, idx) => + bulkResponse.body.items[idx + duplicateAlerts.length].create?.status === 201 + ), errors: errorAggregator(bulkResponse.body, [409]), }; } else { diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.test.ts index 67a3d045d746d..d0fc36831ad23 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.test.ts @@ -29,7 +29,7 @@ describe('Find rules request schema', () => { const payload: FindRulesRequestQuery = { per_page: 5, page: 1, - sort_field: 'some field', + sort_field: 'name', fields: ['field 1', 'field 2'], filter: 'some filter', sort_order: 'asc', @@ -80,14 +80,14 @@ describe('Find rules request schema', () => { test('sort_field validates', () => { const payload: FindRulesRequestQuery = { - sort_field: 'value', + sort_field: 'name', }; const decoded = FindRulesRequestQuery.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); - expect((message.schema as FindRulesRequestQuery).sort_field).toEqual('value'); + expect((message.schema as FindRulesRequestQuery).sort_field).toEqual('name'); }); test('fields validates with a string', () => { @@ -173,7 +173,7 @@ describe('Find rules request schema', () => { test('sort_order validates with desc and sort_field', () => { const payload: FindRulesRequestQuery = { sort_order: 'desc', - sort_field: 'some field', + sort_field: 'name', }; const decoded = FindRulesRequestQuery.decode(payload); @@ -187,7 +187,7 @@ describe('Find rules request schema', () => { test('sort_order does not validate with a string other than asc and desc', () => { const payload: Omit & { sort_order: string } = { sort_order: 'some other string', - sort_field: 'some field', + sort_field: 'name', }; const decoded = FindRulesRequestQuery.decode(payload); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.ts index 9b321d443b2de..08d05a54a5753 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema.ts @@ -8,7 +8,28 @@ import * as t from 'io-ts'; import { DefaultPerPage, DefaultPage } from '@kbn/securitysolution-io-ts-alerting-types'; import type { PerPage, Page } from '../../../../schemas/common'; -import { queryFilter, fields, SortField, SortOrder } from '../../../../schemas/common'; +import { queryFilter, fields, SortOrder } from '../../../../schemas/common'; + +export type FindRulesSortField = t.TypeOf; +export const FindRulesSortField = t.union([ + t.literal('created_at'), + t.literal('createdAt'), // Legacy notation, keeping for backwards compatibility + t.literal('enabled'), + t.literal('execution_summary.last_execution.date'), + t.literal('execution_summary.last_execution.metrics.execution_gap_duration_s'), + t.literal('execution_summary.last_execution.metrics.total_indexing_duration_ms'), + t.literal('execution_summary.last_execution.metrics.total_search_duration_ms'), + t.literal('execution_summary.last_execution.status'), + t.literal('name'), + t.literal('risk_score'), + t.literal('riskScore'), // Legacy notation, keeping for backwards compatibility + t.literal('severity'), + t.literal('updated_at'), + t.literal('updatedAt'), // Legacy notation, keeping for backwards compatibility +]); + +export type FindRulesSortFieldOrUndefined = t.TypeOf; +export const FindRulesSortFieldOrUndefined = t.union([FindRulesSortField, t.undefined]); /** * Query string parameters of the API route. @@ -18,7 +39,7 @@ export const FindRulesRequestQuery = t.exact( t.partial({ fields, filter: queryFilter, - sort_field: SortField, + sort_field: FindRulesSortField, sort_order: SortOrder, page: DefaultPage, // defaults to 1 per_page: DefaultPerPage, // defaults to 20 diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema_validation.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema_validation.test.ts index 862bf7cc1a350..6d0cd10b21700 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema_validation.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/find_rules/request_schema_validation.test.ts @@ -18,7 +18,7 @@ describe('Find rules request schema, additional validation', () => { test('You can have both a sort_field and and a sort_order', () => { const schema: FindRulesRequestQuery = { - sort_field: 'some field', + sort_field: 'name', sort_order: 'asc', }; const errors = validateFindRulesRequestQuery(schema); @@ -27,7 +27,7 @@ describe('Find rules request schema, additional validation', () => { test('You cannot have sort_field without sort_order', () => { const schema: FindRulesRequestQuery = { - sort_field: 'some field', + sort_field: 'name', }; const errors = validateFindRulesRequestQuery(schema); expect(errors).toEqual([ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/sorting.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/sorting.ts index 2cf1712e5ffbc..8aa8cf2831ea1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/sorting.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/sorting.ts @@ -9,12 +9,6 @@ import * as t from 'io-ts'; import type { Either } from 'fp-ts/lib/Either'; import { capitalize } from 'lodash'; -export type SortField = t.TypeOf; -export const SortField = t.string; - -export type SortFieldOrUndefined = t.TypeOf; -export const SortFieldOrUndefined = t.union([SortField, t.undefined]); - export type SortOrder = t.TypeOf; export const SortOrder = t.keyof({ asc: null, desc: null }); diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index 737d81cc9d1ed..a9b2cbd697f4a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -30,14 +30,14 @@ type NonNullableTypeProperties = { * create a value for (almost) all properties */ type CreateExceptionListItemSchemaWithNonNullProps = NonNullableTypeProperties< - Omit + Omit > & - Pick; + Pick; type UpdateExceptionListItemSchemaWithNonNullProps = NonNullableTypeProperties< - Omit + Omit > & - Pick; + Pick; const exceptionItemToCreateExceptionItem = ( exceptionItem: ExceptionListItemSchema @@ -46,6 +46,7 @@ const exceptionItemToCreateExceptionItem = ( /* eslint-disable @typescript-eslint/naming-convention */ description, entries, + expire_time, list_id, name, type, @@ -61,6 +62,7 @@ const exceptionItemToCreateExceptionItem = ( return { description, entries, + expire_time, list_id, name, type, @@ -109,6 +111,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator { @@ -26,7 +27,8 @@ export const expandExceptionActions = () => { export const exportExceptionList = () => { cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().click(); - cy.get(EXCEPTIONS_TABLE_EXPORT_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_EXPORT_MODAL_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_EXPORT_CONFIRM_BTN).first().click(); }; export const deleteExceptionListWithoutRuleReference = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx index 82200b4f7478c..8e1836d733a88 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx @@ -112,9 +112,9 @@ export const BarGroup = styled.div.attrs({ `; BarGroup.displayName = 'BarGroup'; -export const BarText = styled.p.attrs({ - className: 'siemUtilityBar__text', -})<{ shouldWrap: boolean }>` +export const BarText = styled.p.attrs(({ className }) => ({ + className: className || 'siemUtilityBar__text', +}))<{ shouldWrap: boolean }>` ${({ shouldWrap, theme }) => css` color: ${theme.eui.euiTextSubduedColor}; font-size: ${theme.eui.euiFontSizeXS}; diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_text.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_text.tsx index c9e9d0a0b8769..4dd2de98695d9 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_text.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_text.tsx @@ -13,11 +13,12 @@ export interface UtilityBarTextProps { children: string | JSX.Element; dataTestSubj?: string; shouldWrap?: boolean; + className?: string; } export const UtilityBarText = React.memo( - ({ children, dataTestSubj, shouldWrap = false }) => ( - + ({ children, dataTestSubj, shouldWrap = false, className }) => ( + {children} ) diff --git a/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts b/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts index 44703b4f5707c..64c11e06ae8d1 100644 --- a/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts +++ b/x-pack/plugins/security_solution/public/common/lib/apm/user_actions.ts @@ -30,8 +30,6 @@ export const RULES_TABLE_ACTIONS = { REFRESH: `${APP_UI_ID} rulesTable refresh`, FILTER: `${APP_UI_ID} rulesTable filter`, LOAD_PREBUILT: `${APP_UI_ID} rulesTable loadPrebuilt`, - PREVIEW_ON: `${APP_UI_ID} rulesTable technicalPreview on`, - PREVIEW_OFF: `${APP_UI_ID} rulesTable technicalPreview off`, }; export const TIMELINE_ACTIONS = { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx index f031344631faa..b91367a019244 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx @@ -33,6 +33,7 @@ import type { ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; +import type { Moment } from 'moment'; import type { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import * as i18n from './translations'; import { ExceptionItemComments } from '../item_comments'; @@ -54,6 +55,7 @@ import { enrichNewExceptionItems } from '../flyout_components/utils'; import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; import { useInvalidateFetchRuleByIdQuery } from '../../../rule_management/api/hooks/use_fetch_rule_by_id_query'; +import { ExceptionsExpireTime } from '../flyout_components/expire_time'; const SectionHeader = styled(EuiTitle)` ${() => css` @@ -153,6 +155,8 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ newComment, itemConditionValidationErrorExists, errorSubmitting, + expireTime, + expireErrorExists, }, dispatch, ] = useReducer(createExceptionItemsReducer(), { @@ -312,6 +316,26 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ [dispatch] ); + const setExpireTime = useCallback( + (exceptionExpireTime: Moment | undefined): void => { + dispatch({ + type: 'setExpireTime', + expireTime: exceptionExpireTime, + }); + }, + [dispatch] + ); + + const setExpireError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setExpireError', + errorExists, + }); + }, + [dispatch] + ); + useEffect((): void => { if (listType === ExceptionListTypeEnum.ENDPOINT && alertData != null) { setInitialExceptionItems( @@ -343,6 +367,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ sharedLists, listType, selectedOs: osTypesSelection, + expireTime, items: exceptionItems, }); @@ -391,6 +416,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ bulkCloseIndex, setErrorSubmitting, invalidateFetchRuleByIdQuery, + expireTime, ]); const isSubmitButtonDisabled = useMemo( @@ -401,6 +427,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ exceptionItemName.trim() === '' || exceptionItems.every((item) => item.entries.length === 0) || itemConditionValidationErrorExists || + expireErrorExists || (addExceptionToRadioSelection === 'add_to_lists' && isEmpty(exceptionListsToAddTo)), [ isSubmitting, @@ -411,6 +438,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ itemConditionValidationErrorExists, addExceptionToRadioSelection, exceptionListsToAddTo, + expireErrorExists, ] ); @@ -502,6 +530,12 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ newCommentValue={newComment} newCommentOnChange={setComment} /> + + {showAlertCloseOptions && ( <> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts index c36c842930b0f..04d13c3a1b4e9 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts @@ -11,6 +11,7 @@ import type { ExceptionsBuilderExceptionItem, ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; +import type { Moment } from 'moment'; import type { Rule } from '../../../rule_management/logic/types'; @@ -30,6 +31,8 @@ export interface State { exceptionListsToAddTo: ExceptionListSchema[]; selectedRulesToAddTo: Rule[]; errorSubmitting: Error | null; + expireTime: Moment | undefined; + expireErrorExists: boolean; } export const initialState: State = { @@ -48,6 +51,8 @@ export const initialState: State = { selectedRulesToAddTo: [], listType: ExceptionListTypeEnum.RULE_DEFAULT, errorSubmitting: null, + expireTime: undefined, + expireErrorExists: false, }; export type Action = @@ -110,6 +115,14 @@ export type Action = | { type: 'setErrorSubmitting'; err: Error | null; + } + | { + type: 'setExpireTime'; + expireTime: Moment | undefined; + } + | { + type: 'setExpireError'; + errorExists: boolean; }; export const createExceptionItemsReducer = @@ -244,6 +257,22 @@ export const createExceptionItemsReducer = errorSubmitting: err, }; } + case 'setExpireTime': { + const { expireTime } = action; + + return { + ...state, + expireTime, + }; + } + case 'setExpireError': { + const { errorExists } = action; + + return { + ...state, + expireErrorExists: errorExists, + }; + } default: return state; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx index dc6a0ea238310..1d1dd112da377 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx @@ -10,6 +10,8 @@ import { mount, shallow } from 'enzyme'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { fetchExceptionListsItemsByListIds } from '@kbn/securitysolution-list-api'; + import { ExceptionsViewer } from '.'; import { useKibana } from '../../../../common/lib/kibana'; import { TestProviders } from '../../../../common/mock'; @@ -20,6 +22,7 @@ import * as i18n from './translations'; jest.mock('../../../../common/lib/kibana'); jest.mock('@kbn/securitysolution-list-hooks'); +jest.mock('@kbn/securitysolution-list-api'); jest.mock('../../logic/use_find_references'); jest.mock('react', () => { const r = jest.requireActual('react'); @@ -78,6 +81,8 @@ describe('ExceptionsViewer', () => { }, }); + (fetchExceptionListsItemsByListIds as jest.Mock).mockReturnValue({ total: 0 }); + (useFindExceptionListReferences as jest.Mock).mockReturnValue([ false, false, @@ -130,6 +135,7 @@ describe('ExceptionsViewer', () => { exceptionToEdit: null, viewerState: 'loading', exceptionLists: [], + exceptionsToShow: { active: true }, }, jest.fn(), ]); @@ -168,6 +174,7 @@ describe('ExceptionsViewer', () => { exceptionToEdit: null, viewerState: 'empty_search', exceptionLists: [], + exceptionsToShow: { active: true }, }, jest.fn(), ]); @@ -206,6 +213,7 @@ describe('ExceptionsViewer', () => { exceptionToEdit: null, viewerState: 'empty', exceptionLists: [], + exceptionsToShow: { active: true }, }, jest.fn(), ]); @@ -250,6 +258,7 @@ describe('ExceptionsViewer', () => { exceptionToEdit: null, viewerState: 'empty', exceptionLists: [], + exceptionsToShow: { active: true }, }, jest.fn(), ]); @@ -294,6 +303,7 @@ describe('ExceptionsViewer', () => { exceptionToEdit: null, viewerState: null, exceptionLists: [], + exceptionsToShow: { active: true }, }, jest.fn(), ]); @@ -328,6 +338,7 @@ describe('ExceptionsViewer', () => { exceptionToEdit: sampleExceptionItem, viewerState: null, exceptionLists: [], + exceptionsToShow: { active: true }, }, jest.fn(), ]); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx index 41482bccaf3b7..adcbd7ac36b5f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx @@ -24,6 +24,11 @@ import { fetchExceptionListsItemsByListIds, } from '@kbn/securitysolution-list-api'; +import { + buildShowActiveExceptionsFilter, + buildShowExpiredExceptionsFilter, + getSavedObjectTypes, +} from '@kbn/securitysolution-list-utils'; import { useUserData } from '../../../../detections/components/user_info'; import { useKibana, useToasts } from '../../../../common/lib/kibana'; import { ExceptionsViewerSearchBar } from './search_bar'; @@ -44,7 +49,7 @@ const StyledText = styled(EuiText)` font-style: italic; `; -const STATES_SEARCH_HIDDEN: ViewerState[] = ['error', 'empty']; +const STATES_FILTERS_HIDDEN: ViewerState[] = ['error']; const STATES_PAGINATION_UTILITY_HIDDEN: ViewerState[] = [ 'loading', 'empty_search', @@ -66,6 +71,7 @@ const initialState: State = { viewerState: 'loading', isReadOnly: true, lastUpdated: Date.now(), + exceptionsToShow: { active: true }, }; export interface GetExceptionItemProps { @@ -116,7 +122,16 @@ const ExceptionsViewerComponent = ({ // Reducer state const [ - { exceptions, pagination, currenFlyout, exceptionToEdit, viewerState, isReadOnly, lastUpdated }, + { + exceptions, + pagination, + currenFlyout, + exceptionToEdit, + viewerState, + isReadOnly, + lastUpdated, + exceptionsToShow, + }, dispatch, ] = useReducer(allExceptionItemsReducer(), { ...initialState, @@ -179,6 +194,16 @@ const ExceptionsViewerComponent = ({ [dispatch] ); + const setExceptionsToShow = useCallback( + (optionId: string): void => { + dispatch({ + type: 'setExceptionsToShow', + optionId, + }); + }, + [dispatch] + ); + const [isLoadingReferences, isFetchReferencesError, allReferences, fetchReferences] = useFindExceptionListReferences(); @@ -198,6 +223,26 @@ const ExceptionsViewerComponent = ({ } }, [isLoadingReferences, isFetchReferencesError, setViewerState, viewerState]); + const namespaceTypes = useMemo( + () => exceptionListsToQuery.map((list) => list.namespace_type), + [exceptionListsToQuery] + ); + + const exceptionListFilter = useMemo(() => { + if (exceptionsToShow.active && exceptionsToShow.expired) { + return undefined; + } + const savedObjectPrefixes = getSavedObjectTypes({ + namespaceType: namespaceTypes, + }); + if (exceptionsToShow.active) { + return buildShowActiveExceptionsFilter(savedObjectPrefixes); + } + if (exceptionsToShow.expired) { + return buildShowExpiredExceptionsFilter(savedObjectPrefixes); + } + }, [exceptionsToShow, namespaceTypes]); + const handleFetchItems = useCallback( async (options?: GetExceptionItemProps) => { const abortCtrl = new AbortController(); @@ -228,10 +273,10 @@ const ExceptionsViewerComponent = ({ total, data, } = await fetchExceptionListsItemsByListIds({ - filter: undefined, + filter: exceptionListFilter, http: services.http, listIds: exceptionListsToQuery.map((list) => list.list_id), - namespaceTypes: exceptionListsToQuery.map((list) => list.namespace_type), + namespaceTypes, search: options?.search, pagination: newPagination, signal: abortCtrl.signal, @@ -248,9 +293,34 @@ const ExceptionsViewerComponent = ({ total, }; }, - [pagination.pageIndex, pagination.pageSize, exceptionListsToQuery, services.http] + [ + pagination.pageIndex, + pagination.pageSize, + exceptionListsToQuery, + services.http, + exceptionListFilter, + namespaceTypes, + ] ); + const getTotalExceptionCount = useCallback(async () => { + const abortCtrl = new AbortController(); + + if (exceptionListsToQuery.length === 0) { + return 0; + } + + const { total } = await fetchExceptionListsItemsByListIds({ + filter: undefined, + http: services.http, + listIds: exceptionListsToQuery.map((list) => list.list_id), + namespaceTypes, + pagination: {}, + signal: abortCtrl.signal, + }); + return total; + }, [exceptionListsToQuery, namespaceTypes, services.http]); + const handleGetExceptionListItems = useCallback( async (options?: GetExceptionItemProps) => { try { @@ -266,7 +336,9 @@ const ExceptionsViewerComponent = ({ }, }); - setViewerState(total > 0 ? null : 'empty'); + setViewerState( + total > 0 ? null : (await getTotalExceptionCount()) > 0 ? 'empty_search' : 'empty' + ); } catch (e) { setViewerState('error'); @@ -276,7 +348,7 @@ const ExceptionsViewerComponent = ({ }); } }, - [handleFetchItems, setExceptions, setViewerState, toasts] + [handleFetchItems, setExceptions, setViewerState, toasts, getTotalExceptionCount] ); const handleSearch = useCallback( @@ -306,6 +378,13 @@ const ExceptionsViewerComponent = ({ [handleFetchItems, setExceptions, setViewerState, toasts] ); + const handleExceptionsToShow = useCallback( + (optionId: string): void => { + setExceptionsToShow(optionId); + }, + [setExceptionsToShow] + ); + const handleAddException = useCallback((): void => { setFlyoutType('addException'); }, [setFlyoutType]); @@ -430,22 +509,25 @@ const ExceptionsViewerComponent = ({ {isEndpointSpecified ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT : i18n.EXCEPTIONS_TAB_ABOUT} - {!STATES_SEARCH_HIDDEN.includes(viewerState) && ( - - )} - {!STATES_PAGINATION_UTILITY_HIDDEN.includes(viewerState) && ( + {!STATES_FILTERS_HIDDEN.includes(viewerState) && ( <> - - - + + + )} + { totalItemCount: 105, pageSizeOptions: [5, 10, 20, 50, 100], }} + exceptionsToShow={{ active: true }} + onChangeExceptionsToShow={(optionId: string) => {}} lastUpdated={1660534202} /> @@ -42,6 +44,8 @@ describe('ExceptionsViewerUtility', () => { totalItemCount: 1, pageSizeOptions: [5, 10, 20, 50, 100], }} + exceptionsToShow={{ active: true }} + onChangeExceptionsToShow={(optionId: string) => {}} lastUpdated={Date.now()} /> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.tsx index 0f1a24df3b3a5..d7ea7acfd5f31 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiText, EuiButtonGroup, EuiFlexGroup } from '@elastic/eui'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -18,24 +18,30 @@ import { UtilityBarText, } from '../../../../common/components/utility_bar'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; +import * as i18n from './translations'; const StyledText = styled.span` font-weight: bold; `; -const MyUtilities = styled(EuiFlexGroup)` +const MyUtilities = styled.div` height: 50px; `; -const StyledCondition = styled.span` - display: inline-block !important; - vertical-align: middle !important; +const StyledBarGroup = styled(EuiFlexGroup)` + align-items: center; +`; + +const PaginationUtilityBarText = styled(UtilityBarText)` + align-self: center; `; interface ExceptionsViewerUtilityProps { pagination: ExceptionsPagination; // Corresponds to last time exception items were fetched lastUpdated: string | number; + exceptionsToShow: { [id: string]: boolean }; + onChangeExceptionsToShow: (optionId: string) => void; } /** @@ -44,19 +50,21 @@ interface ExceptionsViewerUtilityProps { const ExceptionsViewerUtilityComponent: React.FC = ({ pagination, lastUpdated, -}): JSX.Element => ( - - + exceptionsToShow, + onChangeExceptionsToShow, +}): JSX.Element => { + return ( + - + {`1-${Math.min( + {`${pagination.totalItemCount === 0 ? '0' : '1'}-${Math.min( pagination.pageSize, pagination.totalItemCount )}`} @@ -64,31 +72,44 @@ const ExceptionsViewerUtilityComponent: React.FC = partTwo: {`${pagination.totalItemCount}`}, }} /> - + - - - - - - + + + + , + }} /> - - ), - }} - /> - - - -); + + + + + + + + ); +}; ExceptionsViewerUtilityComponent.displayName = 'ExceptionsViewerUtilityComponent'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx index 6bf0b87652e3e..04a350e328a26 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx @@ -33,6 +33,8 @@ import { import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; +import type { Moment } from 'moment'; +import moment from 'moment'; import { isEqlRule, isNewTermsRule, @@ -56,6 +58,7 @@ import { createExceptionItemsReducer } from './reducer'; import { useEditExceptionItems } from './use_edit_exception'; import * as i18n from './translations'; +import { ExceptionsExpireTime } from '../flyout_components/expire_time'; interface EditExceptionFlyoutProps { list: ExceptionListSchema; @@ -119,6 +122,8 @@ const EditExceptionFlyoutComponent: React.FC = ({ disableBulkClose, bulkCloseIndex, entryErrorExists, + expireTime, + expireErrorExists, }, dispatch, ] = useReducer(createExceptionItemsReducer(), { @@ -129,6 +134,8 @@ const EditExceptionFlyoutComponent: React.FC = ({ disableBulkClose: true, bulkCloseIndex: undefined, entryErrorExists: false, + expireTime: itemToEdit.expire_time !== undefined ? moment(itemToEdit.expire_time) : undefined, + expireErrorExists: false, }); const allowLargeValueLists = useMemo((): boolean => { @@ -231,6 +238,26 @@ const EditExceptionFlyoutComponent: React.FC = ({ [dispatch] ); + const setExpireTime = useCallback( + (exceptionExpireTime: Moment | undefined): void => { + dispatch({ + type: 'setExpireTime', + expireTime: exceptionExpireTime, + }); + }, + [dispatch] + ); + + const setExpireError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setExpireError', + errorExists, + }); + }, + [dispatch] + ); + const handleCloseFlyout = useCallback((): void => { onCancel(false); }, [onCancel]); @@ -251,6 +278,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ commentToAdd: newComment, listType, selectedOs: itemToEdit.os_types, + expireTime, items: exceptionItems, }); @@ -292,6 +320,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ onConfirm, bulkCloseIndex, onCancel, + expireTime, ]); const editExceptionMessage = useMemo( @@ -308,8 +337,9 @@ const EditExceptionFlyoutComponent: React.FC = ({ isClosingAlerts || exceptionItems.every((item) => item.entries.length === 0) || isLoading || - entryErrorExists, - [isLoading, entryErrorExists, exceptionItems, isSubmitting, isClosingAlerts] + entryErrorExists || + expireErrorExists, + [isLoading, entryErrorExists, exceptionItems, isSubmitting, isClosingAlerts, expireErrorExists] ); return ( @@ -370,6 +400,12 @@ const EditExceptionFlyoutComponent: React.FC = ({ newCommentValue={newComment} newCommentOnChange={setComment} /> + + {showAlertCloseOptions && ( <> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts index 22fefe760e4aa..e08b3c8d135c0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts @@ -6,6 +6,7 @@ */ import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; +import type { Moment } from 'moment'; export interface State { exceptionItems: ExceptionsBuilderReturnExceptionItem[]; @@ -15,6 +16,8 @@ export interface State { disableBulkClose: boolean; bulkCloseIndex: string[] | undefined; entryErrorExists: boolean; + expireTime: Moment | undefined; + expireErrorExists: boolean; } export type Action = @@ -45,6 +48,14 @@ export type Action = | { type: 'setConditionValidationErrorExists'; errorExists: boolean; + } + | { + type: 'setExpireTime'; + expireTime: Moment | undefined; + } + | { + type: 'setExpireError'; + errorExists: boolean; }; export const createExceptionItemsReducer = @@ -110,6 +121,22 @@ export const createExceptionItemsReducer = entryErrorExists: errorExists, }; } + case 'setExpireTime': { + const { expireTime } = action; + + return { + ...state, + expireTime, + }; + } + case 'setExpireError': { + const { errorExists } = action; + + return { + ...state, + expireErrorExists: errorExists, + }; + } default: return state; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx index 667883d666bdc..d53ef1c54d5b4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx @@ -51,6 +51,11 @@ export const ExceptionItemCardMetaInfo = memo( const onCloseRulesPopover = () => setIsRulesPopoverOpen(false); const onClosListsPopover = () => setIsListsPopoverOpen(false); + const isExpired = useMemo( + () => (item.expire_time ? new Date(item.expire_time) <= new Date() : false), + [item] + ); + const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { if (listAndReferences == null) { return []; @@ -177,6 +182,20 @@ export const ExceptionItemCardMetaInfo = memo( dataTestSubj={`${dataTestSubj}-updatedBy`} /> + {item.expire_time != null && ( + <> + + } + dataTestSubj={`${dataTestSubj}-expireTime`} + /> + + + )} {listAndReferences != null && ( <> {rulesAffected} @@ -193,7 +212,7 @@ interface MetaInfoDetailsProps { fieldName: string; label: string; value1: JSX.Element | string; - value2: string; + value2?: string; dataTestSubj: string; } @@ -210,20 +229,24 @@ const MetaInfoDetails = memo(({ label, value1, value2, dat {value1}
- - - {i18n.EXCEPTION_ITEM_META_BY} - - - - + {value2 != null && ( + <> - - {value2} - + + {i18n.EXCEPTION_ITEM_META_BY} + - - + + + + + {value2} + + + + + + )} ); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts index 75d0b098c297e..4e1525adf2fba 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts @@ -49,6 +49,20 @@ export const EXCEPTION_ITEM_UPDATED_LABEL = i18n.translate( } ); +export const EXCEPTION_ITEM_EXPIRES_LABEL = i18n.translate( + 'xpack.securitySolution.ruleExceptions.exceptionItem.expiresLabel', + { + defaultMessage: 'Expires at', + } +); + +export const EXCEPTION_ITEM_EXPIRED_LABEL = i18n.translate( + 'xpack.securitySolution.ruleExceptions.exceptionItem.expiredLabel', + { + defaultMessage: 'Expired at', + } +); + export const EXCEPTION_ITEM_META_BY = i18n.translate( 'xpack.securitySolution.ruleExceptions.exceptionItem.metaDetailsBy', { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_options/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_options/index.test.tsx index fe65b3ce0384e..6515d8f5243e3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_options/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_options/index.test.tsx @@ -10,17 +10,15 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ExceptionsAddToRulesOptions } from '.'; import { TestProviders } from '../../../../../common/mock'; -import { useFindRulesInMemory } from '../../../../rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory'; +import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; import { getRulesSchemaMock } from '../../../../../../common/detection_engine/rule_schema/mocks'; import type { Rule } from '../../../../rule_management/logic/types'; -jest.mock( - '../../../../rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory' -); +jest.mock('../../../../rule_management/logic/use_find_rules'); describe('ExceptionsAddToRulesOptions', () => { beforeEach(() => { - (useFindRulesInMemory as jest.Mock).mockReturnValue({ + (useFindRules as jest.Mock).mockReturnValue({ data: { rules: [getRulesSchemaMock(), { ...getRulesSchemaMock(), id: '345', name: 'My rule' }], total: 0, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/index.test.tsx index 7d56fcc298f90..4be59efe2e927 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/index.test.tsx @@ -10,18 +10,15 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ExceptionsAddToRulesTable } from '.'; import { TestProviders } from '../../../../../common/mock'; -import { useFindRulesInMemory } from '../../../../rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory'; +import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; import { getRulesSchemaMock } from '../../../../../../common/detection_engine/rule_schema/mocks'; import type { Rule } from '../../../../rule_management/logic/types'; -jest.mock( - '../../../../rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory' -); -// TODO convert this test to RTL +jest.mock('../../../../rule_management/logic/use_find_rules'); describe('ExceptionsAddToRulesTable', () => { it('it displays loading state while fetching rules', () => { - (useFindRulesInMemory as jest.Mock).mockReturnValue({ + (useFindRules as jest.Mock).mockReturnValue({ data: { rules: [], total: 0 }, isFetched: false, }); @@ -37,7 +34,7 @@ describe('ExceptionsAddToRulesTable', () => { }); it.skip('it displays fetched rules', () => { - (useFindRulesInMemory as jest.Mock).mockReturnValue({ + (useFindRules as jest.Mock).mockReturnValue({ data: { rules: [getRulesSchemaMock(), { ...getRulesSchemaMock(), id: '345', name: 'My rule' }], total: 0, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/use_add_to_rules_table.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/use_add_to_rules_table.tsx index dc37a480afcd4..e18cc71802c31 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/use_add_to_rules_table.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/add_to_rules_table/use_add_to_rules_table.tsx @@ -15,10 +15,10 @@ import { i18n } from '@kbn/i18n'; import * as myI18n from './translations'; import * as commonI18n from '../translations'; -import { useFindRulesInMemory } from '../../../../rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory'; import type { Rule } from '../../../../rule_management/logic/types'; import { getRulesTableColumn } from '../utils'; import { LinkRuleSwitch } from './link_rule_switch'; +import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; export interface ExceptionsAddToRulesComponentProps { initiallySelectedRules?: Rule[]; @@ -28,8 +28,7 @@ export const useAddToRulesTable = ({ initiallySelectedRules, onRuleSelectionChange, }: ExceptionsAddToRulesComponentProps) => { - const { data: { rules } = { rules: [], total: 0 }, isFetched } = useFindRulesInMemory({ - isInMemorySorting: true, + const { data: { rules } = { rules: [], total: 0 }, isFetched } = useFindRules({ filterOptions: { filter: '', showCustomRules: false, @@ -38,7 +37,6 @@ export const useAddToRulesTable = ({ }, sortingOptions: undefined, pagination: undefined, - refetchInterval: false, }); const [pagination, setPagination] = useState({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/expire_time/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/expire_time/index.tsx new file mode 100644 index 0000000000000..1f71c7a2fe51c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/expire_time/index.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDatePicker, EuiFormRow, EuiSpacer, EuiTitle } from '@elastic/eui'; +import type { Moment } from 'moment'; +import moment from 'moment'; +import React, { useCallback, useState } from 'react'; +import styled, { css } from 'styled-components'; +import * as i18n from './translations'; + +interface ExceptionItmeExpireTimeProps { + expireTime: Moment | undefined; + setExpireTime: (date: Moment | undefined) => void; + setExpireError: (errorExists: boolean) => void; +} + +const SectionHeader = styled(EuiTitle)` + ${() => css` + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; + `} +`; + +const ExceptionItemExpireTime: React.FC = ({ + expireTime, + setExpireTime, + setExpireError, +}): JSX.Element => { + const [dateTime, setDateTime] = useState(expireTime); + const [isInvalid, setIsInvalid] = useState(false); + const [errors, setErrors] = useState([]); + + const handleChange = useCallback( + (date: Moment | null) => { + setDateTime(date ?? undefined); + setExpireTime(date ?? undefined); + if (date?.isBefore()) { + setIsInvalid(true); + setErrors([i18n.EXCEPTION_EXPIRE_TIME_ERROR]); + setExpireError(true); + } else { + setIsInvalid(false); + setErrors([]); + setExpireError(false); + } + }, + [setDateTime, setExpireTime, setExpireError] + ); + + return ( +
+ +

{i18n.EXCEPTION_EXPIRE_TIME_HEADER}

+
+ + + handleChange(null)} + minDate={moment()} + /> + +
+ ); +}; + +export const ExceptionsExpireTime = React.memo(ExceptionItemExpireTime); + +ExceptionsExpireTime.displayName = 'ExceptionsExpireTime'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/expire_time/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/expire_time/translations.ts new file mode 100644 index 0000000000000..6e02591bfe41b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/expire_time/translations.ts @@ -0,0 +1,29 @@ +/* + * 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'; + +export const EXPIRE_TIME_LABEL = i18n.translate( + 'xpack.securitySolution.rule_exceptions.flyoutComponents.expireTime.expireTimeLabel', + { + defaultMessage: 'Exception will expire at', + } +); + +export const EXCEPTION_EXPIRE_TIME_HEADER = i18n.translate( + 'xpack.securitySolution.rule_exceptions.flyoutComponents.expireTime.exceptionExpireTime', + { + defaultMessage: 'Exception Expiration', + } +); + +export const EXCEPTION_EXPIRE_TIME_ERROR = i18n.translate( + 'xpack.securitySolution.rule_exceptions.flyoutComponents.expireTime.exceptionExpireTimeError', + { + defaultMessage: 'Selected date and time must be in the future.', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.test.tsx index 4096cd03f2bc3..0c1e4a40d8373 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.test.tsx @@ -39,6 +39,7 @@ describe('add_exception_flyout#utils', () => { selectedOs: [], listType: ExceptionListTypeEnum.RULE_DEFAULT, items, + expireTime: undefined, }) ).toEqual([ { @@ -75,6 +76,7 @@ describe('add_exception_flyout#utils', () => { selectedOs: [], listType: ExceptionListTypeEnum.DETECTION, items, + expireTime: undefined, }) ).toEqual([ { @@ -114,6 +116,7 @@ describe('add_exception_flyout#utils', () => { selectedOs: ['windows'], listType: ExceptionListTypeEnum.ENDPOINT, items, + expireTime: undefined, }) ).toEqual([ { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx index f2b18680b3542..e70844348c385 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx @@ -13,6 +13,7 @@ import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; import type { HorizontalAlignment } from '@elastic/eui'; +import type { Moment } from 'moment'; import { HeaderMenu, generateLinkedRulesMenuItems, @@ -22,6 +23,7 @@ import { ListDetailsLinkAnchor } from '../../../../exceptions/components'; import { enrichExceptionItemsWithOS, enrichNewExceptionItemsWithComments, + enrichNewExceptionItemsWithExpireTime, enrichNewExceptionItemsWithName, enrichRuleExceptions, enrichSharedExceptions, @@ -58,6 +60,18 @@ export const enrichItemWithName = return itemName.trim() !== '' ? enrichNewExceptionItemsWithName(items, itemName) : items; }; +/** + * Adds expiration datetime to all new exceptionItems + * @param expireTimeToAdd new expireTime to add to item + */ +export const enrichItemWithExpireTime = + (expireTimeToAdd: Moment | undefined) => + (items: ExceptionsBuilderReturnExceptionItem[]): ExceptionsBuilderReturnExceptionItem[] => { + return expireTimeToAdd != null + ? enrichNewExceptionItemsWithExpireTime(items, expireTimeToAdd) + : items; + }; + /** * Modifies item entries to be in correct format and adds os selection to items * @param listType exception list type @@ -114,6 +128,7 @@ export const enrichItemsForSharedLists = * @param sharedLists shared exception lists that were selected to add items to * @param selectedOs os selection * @param listType exception list type + * @param expireTime exception item expire time * @param items exception items to be modified */ export const enrichNewExceptionItems = ({ @@ -124,6 +139,7 @@ export const enrichNewExceptionItems = ({ sharedLists, selectedOs, listType, + expireTime, items, }: { itemName: string; @@ -133,10 +149,12 @@ export const enrichNewExceptionItems = ({ addToSharedLists: boolean; sharedLists: ExceptionListSchema[]; listType: ExceptionListTypeEnum; + expireTime: Moment | undefined; items: ExceptionsBuilderReturnExceptionItem[]; }): ExceptionsBuilderReturnExceptionItem[] => { const enriched: ExceptionsBuilderReturnExceptionItem[] = pipe( enrichItemWithComment(commentToAdd), + enrichItemWithExpireTime(expireTime), enrichItemWithName(itemName), enrichEndpointItems(listType, selectedOs), enrichItemsForDefaultRuleList(listType, addToRules), @@ -155,6 +173,7 @@ export const enrichNewExceptionItems = ({ * @param sharedLists shared exception lists that were selected to add items to * @param selectedOs os selection * @param listType exception list type + * @param expireTime exception item expire time * @param items exception items to be modified */ export const enrichExceptionItemsForUpdate = ({ @@ -162,16 +181,19 @@ export const enrichExceptionItemsForUpdate = ({ commentToAdd, selectedOs, listType, + expireTime, items, }: { itemName: string; commentToAdd: string; selectedOs: OsType[]; listType: ExceptionListTypeEnum; + expireTime: Moment | undefined; items: ExceptionsBuilderReturnExceptionItem[]; }): ExceptionsBuilderReturnExceptionItem[] => { const enriched: ExceptionsBuilderReturnExceptionItem[] = pipe( enrichItemWithComment(commentToAdd), + enrichItemWithExpireTime(expireTime), enrichItemWithName(itemName), enrichEndpointItems(listType, selectedOs) )(items); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx index 6547785e29cc9..6a739fd34a05a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { EuiCommentProps } from '@elastic/eui'; import { EuiText, EuiAvatar } from '@elastic/eui'; import { capitalize, omit } from 'lodash'; +import type { Moment } from 'moment'; import moment from 'moment'; import type { @@ -177,6 +178,23 @@ export const enrichNewExceptionItemsWithComments = ( }); }; +/** + * Adds expireTime to all new exceptionItems if not present already + * @param exceptionItems new or existing ExceptionItem[] + * @param expireTime new expireTime + */ +export const enrichNewExceptionItemsWithExpireTime = ( + exceptionItems: ExceptionsBuilderReturnExceptionItem[], + expireTime: Moment +): ExceptionsBuilderReturnExceptionItem[] => { + return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => { + return { + ...item, + expire_time: expireTime.toISOString(), + }; + }); +}; + export const buildGetAlertByIdQuery = (id: string | undefined) => ({ query: { match: { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index a1d59efa4c30a..fe111c13debec 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -287,7 +287,7 @@ describe('Detections Rules API', () => { tags: ['hello', 'world'], }, sortingOptions: { - field: 'updated_at', + field: 'updatedAt', order: 'desc', }, signal: abortCtrl.signal, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index aec165167db59..87334a121c993 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { camelCase } from 'lodash'; import type { CreateRuleExceptionListItemSchema, ExceptionListItemSchema, @@ -152,15 +151,10 @@ export const fetchRules = async ({ }: FetchRulesProps): Promise => { const filterString = convertRulesFilterToKQL(filterOptions); - // Sort field is camel cased because we use that in our mapping, but display snake case on the front end - const getFieldNameForSortField = (field: string) => { - return field === 'name' ? `${field}.keyword` : camelCase(field); - }; - const query = { page: pagination.page, per_page: pagination.perPage, - sort_field: getFieldNameForSortField(sortingOptions.field), + sort_field: sortingOptions.field, sort_order: sortingOptions.order, ...(filterString !== '' ? { filter: filterString } : {}), }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index 246a000356761..1eadf14b02b8c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -73,6 +73,7 @@ import { } from '../../../../common/detection_engine/rule_schema'; import type { PatchRuleRequestBody } from '../../../../common/detection_engine/rule_management'; +import { FindRulesSortField } from '../../../../common/detection_engine/rule_management'; import type { RuleCreateProps, RuleUpdateProps, @@ -217,25 +218,9 @@ export interface FetchRulesProps { signal?: AbortSignal; } -export type RulesSortingFields = t.TypeOf; -export const RulesSortingFields = t.union([ - t.literal('created_at'), - t.literal('enabled'), - t.literal('execution_summary.last_execution.date'), - t.literal('execution_summary.last_execution.metrics.execution_gap_duration_s'), - t.literal('execution_summary.last_execution.metrics.total_indexing_duration_ms'), - t.literal('execution_summary.last_execution.metrics.total_search_duration_ms'), - t.literal('execution_summary.last_execution.status'), - t.literal('name'), - t.literal('risk_score'), - t.literal('severity'), - t.literal('updated_at'), - t.literal('version'), -]); - export type SortingOptions = t.TypeOf; export const SortingOptions = t.type({ - field: RulesSortingFields, + field: FindRulesSortField, order: SortOrder, }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_find_rules.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_find_rules.ts index 653f1de8e3b3f..df8a79aaafcbc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_find_rules.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/use_find_rules.ts @@ -27,7 +27,12 @@ export interface RulesQueryData { */ export const useFindRules = ( requestArgs: FindRulesQueryArgs, - options: UseQueryOptions + options?: UseQueryOptions< + RulesQueryData, + Error, + RulesQueryData, + [...string[], FindRulesQueryArgs] + > ) => { const { addError } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx index 1a03370ea5915..688b90c860ceb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx @@ -32,7 +32,6 @@ export const useRulesTableContextMock = { isAllSelected: false, isFetched: true, isFetching: false, - isInMemorySorting: false, isLoading: false, isRefetching: false, isRefreshOn: true, @@ -46,7 +45,6 @@ export const useRulesTableContextMock = { reFetchRules: jest.fn(), setFilterOptions: jest.fn(), setIsAllSelected: jest.fn(), - setIsInMemorySorting: jest.fn(), setIsRefreshOn: jest.fn(), setLoadingRules: jest.fn(), setPage: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx index 552ca7b0cbd88..cb2eb08c5381b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import type { PropsWithChildren } from 'react'; import React from 'react'; import { useUiSetting$ } from '../../../../../common/lib/kibana'; +import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; import type { RulesTableState } from './rules_table_context'; import { RulesTableContextProvider, useRulesTableContext } from './rules_table_context'; import { @@ -18,17 +19,16 @@ import { DEFAULT_SORTING_OPTIONS, } from './rules_table_defaults'; import { RuleSource } from './rules_table_saved_state'; -import { useFindRulesInMemory } from './use_find_rules_in_memory'; import { useRulesTableSavedState } from './use_rules_table_saved_state'; jest.mock('../../../../../common/lib/kibana'); -jest.mock('./use_find_rules_in_memory'); +jest.mock('../../../../rule_management/logic/use_find_rules'); jest.mock('./use_rules_table_saved_state'); function renderUseRulesTableContext( savedState: ReturnType ): RulesTableState { - (useFindRulesInMemory as jest.Mock).mockReturnValue({ + (useFindRules as jest.Mock).mockReturnValue({ data: { rules: [], total: 0 }, refetch: jest.fn(), dataUpdatedAt: 0, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx index 5667b544b7b72..f6ec9a3714f66 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx @@ -5,38 +5,37 @@ * 2.0. */ +import { isEqual } from 'lodash'; import React, { createContext, useCallback, useContext, useEffect, useMemo, - useState, useRef, + useState, } from 'react'; -import { isEqual } from 'lodash'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; import { invariant } from '../../../../../../common/utils/invariant'; -import { useReplaceUrlParams } from '../../../../../common/utils/global_query_string/helpers'; -import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state'; +import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; +import { useReplaceUrlParams } from '../../../../../common/utils/global_query_string/helpers'; import type { FilterOptions, PaginationOptions, Rule, SortingOptions, } from '../../../../rule_management/logic/types'; +import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; +import { RULES_TABLE_STATE_STORAGE_KEY } from '../constants'; import { + DEFAULT_FILTER_OPTIONS, DEFAULT_PAGE, DEFAULT_RULES_PER_PAGE, - DEFAULT_FILTER_OPTIONS, DEFAULT_SORTING_OPTIONS, } from './rules_table_defaults'; import { RuleSource } from './rules_table_saved_state'; -import { useFindRulesInMemory } from './use_find_rules_in_memory'; import { useRulesTableSavedState } from './use_rules_table_saved_state'; -import { getRulesComparator } from './utils'; -import { RULES_TABLE_STATE_STORAGE_KEY } from '../constants'; export interface RulesTableState { /** @@ -63,10 +62,6 @@ export interface RulesTableState { * Is true whenever a request is in-flight, which includes initial loading as well as background refetches. */ isFetching: boolean; - /** - * Is true when we store and sort all rules in-memory. - */ - isInMemorySorting: boolean; /** * Is true then there is no cached data and the query is currently fetching. */ @@ -129,10 +124,9 @@ export interface LoadingRules { } export interface RulesTableActions { - reFetchRules: ReturnType['refetch']; + reFetchRules: ReturnType['refetch']; setFilterOptions: (newFilter: Partial) => void; setIsAllSelected: React.Dispatch>; - setIsInMemorySorting: (value: boolean) => void; setIsPreflightInProgress: React.Dispatch>; /** * enable/disable rules table auto refresh @@ -169,24 +163,19 @@ interface RulesTableContextProviderProps { children: React.ReactNode; } -const IN_MEMORY_STORAGE_KEY = 'detection-rules-table-in-memory'; - export const RulesTableContextProvider = ({ children }: RulesTableContextProviderProps) => { const [autoRefreshSettings] = useUiSetting$<{ on: boolean; value: number; idleTimeout: number; }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); - const { storage, sessionStorage } = useKibana().services; + const { sessionStorage } = useKibana().services; const { filter: savedFilter, sorting: savedSorting, pagination: savedPagination, } = useRulesTableSavedState(); - const [isInMemorySorting, setIsInMemorySorting] = useState( - storage.get(IN_MEMORY_STORAGE_KEY) ?? false - ); const [filterOptions, setFilterOptions] = useState({ filter: savedFilter?.searchTerm ?? DEFAULT_FILTER_OPTIONS.filter, tags: savedFilter?.tags ?? DEFAULT_FILTER_OPTIONS.tags, @@ -200,6 +189,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide field: savedSorting?.field ?? DEFAULT_SORTING_OPTIONS.field, order: savedSorting?.order ?? DEFAULT_SORTING_OPTIONS.order, }); + const [isAllSelected, setIsAllSelected] = useState(false); const [isRefreshOn, setIsRefreshOn] = useState(autoRefreshSettings.on); const [loadingRules, setLoadingRules] = useState({ @@ -212,19 +202,6 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide const [selectedRuleIds, setSelectedRuleIds] = useState([]); const autoRefreshBeforePause = useRef(null); - const toggleInMemorySorting = useCallback( - (value: boolean) => { - setIsInMemorySorting(value); // Update state so the table gets re-rendered - storage.set(IN_MEMORY_STORAGE_KEY, value); // Persist new value in the local storage - - // Reset sorting options when switching to server-side implementation as currently selected sorting might not be supported - if (value === false) { - setSortingOptions(DEFAULT_SORTING_OPTIONS); - } - }, - [storage] - ); - const isActionInProgress = loadingRules.ids.length > 0; const pagination = useMemo(() => ({ page, perPage }), [page, perPage]); @@ -285,25 +262,23 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide isFetching, isLoading, isRefetching, - } = useFindRulesInMemory({ - isInMemorySorting, - filterOptions, - sortingOptions, - pagination, - refetchInterval: isRefreshOn && !isActionInProgress && autoRefreshSettings.value, - }); - - // Paginate and sort rules - const rulesToDisplay = isInMemorySorting - ? rules.sort(getRulesComparator(sortingOptions)).slice((page - 1) * perPage, page * perPage) - : rules; + } = useFindRules( + { + filterOptions, + sortingOptions, + pagination, + }, + { + refetchInterval: isRefreshOn && !isActionInProgress && autoRefreshSettings.value, + keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change + } + ); const actions = useMemo( () => ({ reFetchRules: refetch, setFilterOptions: handleFilterOptionsChange, setIsAllSelected, - setIsInMemorySorting: toggleInMemorySorting, setIsRefreshOn, setLoadingRules, setPage, @@ -318,7 +293,6 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide refetch, handleFilterOptionsChange, setIsAllSelected, - toggleInMemorySorting, setIsRefreshOn, setLoadingRules, setPage, @@ -334,7 +308,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide const providerValue = useMemo( () => ({ state: { - rules: rulesToDisplay, + rules, pagination: { page, perPage, @@ -346,7 +320,6 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide isAllSelected, isFetched, isFetching, - isInMemorySorting, isLoading, isRefetching, isRefreshOn, @@ -358,17 +331,15 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide isDefault: isDefaultState(filterOptions, sortingOptions, { page, perPage, - total: isInMemorySorting ? rules.length : total, + total, }), }, actions, }), [ - rulesToDisplay, + rules, page, perPage, - isInMemorySorting, - rules.length, total, filterOptions, isPreflightInProgress, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory.ts deleted file mode 100644 index c1429fddbd312..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_find_rules_in_memory.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FindRulesQueryArgs } from '../../../../rule_management/api/hooks/use_find_rules_query'; -import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; - -interface UseFindRulesArgs extends FindRulesQueryArgs { - isInMemorySorting: boolean; - refetchInterval: number | false; -} - -const MAX_RULES_PER_PAGE = 10000; - -/** - * This hook is used to fetch detection rules. Under the hood, it implements a - * "feature switch" that allows switching from an in-memory implementation to a - * backend-based implementation on the fly. - * - * @param args - find rules arguments - * @returns rules query result - */ -export const useFindRulesInMemory = (args: UseFindRulesArgs) => { - const { pagination, filterOptions, sortingOptions, isInMemorySorting, refetchInterval } = args; - - // Use this query result when isInMemorySorting = true - const allRules = useFindRules( - { pagination: { page: 1, perPage: MAX_RULES_PER_PAGE }, filterOptions }, - { - refetchInterval, - enabled: isInMemorySorting, - keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change - } - ); - - // Use this query result when isInMemorySorting = false - const pagedRules = useFindRules( - { pagination, filterOptions, sortingOptions }, - { - refetchInterval, - enabled: !isInMemorySorting, - keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change - } - ); - - return isInMemorySorting ? allRules : pagedRules; -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/utils.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/utils.ts deleted file mode 100644 index 5382a740f8213..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/utils.ts +++ /dev/null @@ -1,77 +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 { get } from 'lodash'; -import type { Rule, SortingOptions } from '../../../../rule_management/logic/types'; - -/** - * Returns a comparator function to be used with .sort() - * - * @param sortingOptions SortingOptions - */ -export function getRulesComparator(sortingOptions: SortingOptions) { - return (ruleA: Rule, ruleB: Rule): number => { - const { field, order } = sortingOptions; - const direction = order === 'asc' ? 1 : -1; - - switch (field) { - case 'enabled': { - const a = get(ruleA, field); - const b = get(ruleB, field); - - return compareNumbers(Number(a), Number(b), direction); - } - case 'version': - case 'risk_score': - case 'execution_summary.last_execution.metrics.execution_gap_duration_s': - case 'execution_summary.last_execution.metrics.total_indexing_duration_ms': - case 'execution_summary.last_execution.metrics.total_search_duration_ms': { - const a = get(ruleA, field) ?? -Infinity; - const b = get(ruleB, field) ?? -Infinity; - - return compareNumbers(a, b, direction); - } - case 'updated_at': - case 'created_at': - case 'execution_summary.last_execution.date': { - const a = get(ruleA, field); - const b = get(ruleB, field); - - return compareNumbers( - a == null ? 0 : new Date(a).getTime(), - b == null ? 0 : new Date(b).getTime(), - direction - ); - } - case 'execution_summary.last_execution.status': - case 'severity': - case 'name': { - const a = get(ruleA, field); - const b = get(ruleB, field); - return (a || '').localeCompare(b || '') * direction; - } - } - }; -} - -/** - * A helper to compare two numbers. - * - * @param a - first number - * @param b - second number - * @param direction - comparison direction +1 for asc or -1 for desc - * @returns comparison result - */ -const compareNumbers = (a: number, b: number, direction: number) => { - // We cannot use `return (a - b);` here as it might result in NaN if one of inputs is Infinity. - if (a > b) { - return direction; - } else if (a < b) { - return -direction; - } - return 0; -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_toolbar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_toolbar.tsx index 46ab8d50998b8..562177371de58 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_toolbar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_toolbar.tsx @@ -5,23 +5,9 @@ * 2.0. */ -import type { EuiSwitchEvent } from '@elastic/eui'; -import { EuiSwitch, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; +import React, { useMemo } from 'react'; import { TabNavigationWithBreadcrumbs } from '../../../../common/components/navigation/tab_navigation_with_breadcrumbs'; -import { useRulesTableContext } from './rules_table/rules_table_context'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; -import { RULES_TABLE_ACTIONS } from '../../../../common/lib/apm/user_actions'; -import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; - -const ToolbarLayout = styled.div` - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - grid-gap: 16px; - box-shadow: inset 0 -1px 0 ${({ theme }) => theme.eui.euiBorderColor}; -`; export enum AllRulesTabs { management = 'management', @@ -29,11 +15,6 @@ export enum AllRulesTabs { } export const RulesTableToolbar = React.memo(() => { - const { - state: { isInMemorySorting }, - actions: { setIsInMemorySorting }, - } = useRulesTableContext(); - const { startTransaction } = useStartTransaction(); const ruleTabs = useMemo( () => ({ [AllRulesTabs.management]: { @@ -52,33 +33,7 @@ export const RulesTableToolbar = React.memo(() => { [] ); - const handleInMemorySwitch = useCallback( - (e: EuiSwitchEvent) => { - startTransaction({ - name: isInMemorySorting ? RULES_TABLE_ACTIONS.PREVIEW_OFF : RULES_TABLE_ACTIONS.PREVIEW_ON, - }); - setIsInMemorySorting(e.target.checked); - }, - [isInMemorySorting, setIsInMemorySorting, startTransaction] - ); - - return ( - - - - - - - ); + return ; }); RulesTableToolbar.displayName = 'RulesTableToolbar'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx index 5d426bfc8ed7e..bbe5659e7481b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx @@ -17,7 +17,7 @@ import { Loader } from '../../../../common/components/loader'; import { useBoolState } from '../../../../common/hooks/use_bool_state'; import { useValueChanged } from '../../../../common/hooks/use_value_changed'; import { PrePackagedRulesPrompt } from '../../../../detections/components/rules/pre_packaged_rules/load_empty_prompt'; -import type { Rule, RulesSortingFields } from '../../../rule_management/logic'; +import type { Rule } from '../../../rule_management/logic'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; import type { EuiBasicTableOnChange } from '../../../../detections/pages/detection_engine/rules/types'; import { BulkActionDryRunConfirmation } from './bulk_actions/bulk_action_dry_run_confirmation'; @@ -39,6 +39,7 @@ import { BulkActionDuplicateExceptionsConfirmation } from './bulk_actions/bulk_d import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs'; import { RULES_TABLE_PAGE_SIZE_OPTIONS } from './constants'; import { useRuleManagementFilters } from '../../../rule_management/logic/use_rule_management_filters'; +import type { FindRulesSortField } from '../../../../../common/detection_engine/rule_management'; const INITIAL_SORT_FIELD = 'enabled'; @@ -141,7 +142,7 @@ export const RulesTables = React.memo(({ selectedTab }) => { const tableOnChangeCallback = useCallback( ({ page, sort }: EuiBasicTableOnChange) => { setSortingOptions({ - field: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types + field: (sort?.field as FindRulesSortField) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types order: sort?.direction ?? 'desc', }); setPage(page.index + 1); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx index 5f7bdb2a2745c..fdc9f5f3a5b43 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx @@ -235,7 +235,6 @@ export const useRulesColumns = ({ }: UseColumnsProps): TableColumn[] => { const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation }); const ruleNameColumn = useRuleNameColumn(); - const { isInMemorySorting } = useRulesTableContext().state; const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); const enabledColumn = useEnabledColumn({ hasCRUDPermissions, @@ -244,7 +243,7 @@ export const useRulesColumns = ({ startMlJobs, }); const executionStatusColumn = useRuleExecutionStatusColumn({ - sortable: !!isInMemorySorting, + sortable: true, width: '16%', isLoadingJobs, mlJobs, @@ -290,7 +289,7 @@ export const useRulesColumns = ({ /> ); }, - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '16%', }, @@ -314,22 +313,6 @@ export const useRulesColumns = ({ width: '18%', truncateText: true, }, - { - field: 'version', - name: i18n.COLUMN_VERSION, - render: (value: Rule['version']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - {value} - - ); - }, - sortable: !!isInMemorySorting, - truncateText: true, - width: '65px', - }, enabledColumn, ...(hasCRUDPermissions ? [actionsColumn] : []), ], @@ -338,7 +321,6 @@ export const useRulesColumns = ({ enabledColumn, executionStatusColumn, hasCRUDPermissions, - isInMemorySorting, ruleNameColumn, showRelatedIntegrations, ] @@ -355,7 +337,6 @@ export const useMonitoringColumns = ({ const docLinks = useKibana().services.docLinks; const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation }); const ruleNameColumn = useRuleNameColumn(); - const { isInMemorySorting } = useRulesTableContext().state; const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); const enabledColumn = useEnabledColumn({ hasCRUDPermissions, @@ -364,7 +345,7 @@ export const useMonitoringColumns = ({ startMlJobs, }); const executionStatusColumn = useRuleExecutionStatusColumn({ - sortable: !!isInMemorySorting, + sortable: true, width: '12%', isLoadingJobs, mlJobs, @@ -391,7 +372,7 @@ export const useMonitoringColumns = ({ {value != null ? value.toFixed() : getEmptyTagValue()} ), - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '16%', }, @@ -408,7 +389,7 @@ export const useMonitoringColumns = ({ {value != null ? value.toFixed() : getEmptyTagValue()} ), - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '14%', }, @@ -448,7 +429,7 @@ export const useMonitoringColumns = ({ {value != null ? moment.duration(value, 'seconds').humanize() : getEmptyTagValue()} ), - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '14%', }, @@ -468,7 +449,7 @@ export const useMonitoringColumns = ({ /> ); }, - sortable: !!isInMemorySorting, + sortable: true, truncateText: true, width: '16%', }, @@ -481,7 +462,6 @@ export const useMonitoringColumns = ({ enabledColumn, executionStatusColumn, hasCRUDPermissions, - isInMemorySorting, ruleNameColumn, showRelatedIntegrations, ] diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index bd58da0521c2c..dab1bf3b68285 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -78,6 +78,7 @@ export const getExceptionListItemSchemaMock = ( created_by: USER, description: DESCRIPTION, entries: ENTRIES, + expire_time: undefined, id: '1', item_id: 'endpoint_list_item', list_id: 'endpoint_list_id', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index c71cc4cf267a4..9511138f78f34 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -46,28 +46,6 @@ export const PAGE_TITLE = i18n.translate('xpack.securitySolution.detectionEngine defaultMessage: 'Rules', }); -export const EXPERIMENTAL_ON = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.experimentalOn', - { - defaultMessage: 'Advanced sorting', - } -); - -export const EXPERIMENTAL_OFF = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.experimentalOff', - { - defaultMessage: 'Advanced sorting', - } -); - -export const EXPERIMENTAL_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.experimentalDescription', - { - defaultMessage: - 'Turn this on to enable sorting for all table columns. You can turn it off if you encounter table performance issues.', - } -); - export const ADD_PAGE_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.addPageTitle', { @@ -561,13 +539,6 @@ export const COLUMN_LAST_RESPONSE = i18n.translate( } ); -export const COLUMN_VERSION = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.columns.versionTitle', - { - defaultMessage: 'Version', - } -); - export const COLUMN_TAGS = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.columns.tagsTitle', { diff --git a/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx index 478bdb9f2f5b5..54f2b0cf6c5d3 100644 --- a/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx @@ -33,6 +33,7 @@ import { ListExceptionItems } from '../list_exception_items'; import { useListDetailsView } from '../../hooks'; import { useExceptionsListCard } from '../../hooks/use_exceptions_list.card'; import { ManageRules } from '../manage_rules'; +import { ExportExceptionsListModal } from '../export_exceptions_list_modal'; interface ExceptionsListCardProps { exceptionsList: ExceptionListInfo; @@ -47,10 +48,12 @@ interface ExceptionsListCardProps { }) => () => Promise; handleExport: ({ id, + includeExpiredExceptions, listId, namespaceType, }: { id: string; + includeExpiredExceptions: boolean; listId: string; namespaceType: NamespaceType; }) => () => Promise; @@ -115,6 +118,9 @@ export const ExceptionsListCard = memo( emptyViewerTitle, emptyViewerBody, emptyViewerButtonText, + handleCancelExportModal, + handleConfirmExportModal, + showExportModal, } = useExceptionsListCard({ exceptionsList, handleExport, @@ -248,6 +254,12 @@ export const ExceptionsListCard = memo( onRuleSelectionChange={onRuleSelectionChange} /> ) : null} + {showExportModal ? ( + + ) : null} ); } diff --git a/x-pack/plugins/security_solution/public/exceptions/components/export_exceptions_list_modal/index.tsx b/x-pack/plugins/security_solution/public/exceptions/components/export_exceptions_list_modal/index.tsx new file mode 100644 index 0000000000000..7316925cc24cf --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/components/export_exceptions_list_modal/index.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useState } from 'react'; + +import { EuiConfirmModal, EuiSwitch } from '@elastic/eui'; +import * as i18n from '../../translations'; + +interface ExportExceptionsListModalProps { + handleCloseModal: () => void; + onModalConfirm: (includeExpired: boolean) => void; +} + +export const ExportExceptionsListModal = memo( + ({ handleCloseModal, onModalConfirm }) => { + const [exportExpired, setExportExpired] = useState(true); + + const handleSwitchChange = useCallback(() => { + setExportExpired(!exportExpired); + }, [setExportExpired, exportExpired]); + + const handleConfirm = useCallback(() => { + onModalConfirm(exportExpired); + handleCloseModal(); + }, [exportExpired, handleCloseModal, onModalConfirm]); + + return ( + + + + ); + } +); + +ExportExceptionsListModal.displayName = 'ExportExceptionsListModal'; diff --git a/x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx b/x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx index 3fb44d2d5b5c5..5a8912a422f5c 100644 --- a/x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx @@ -21,6 +21,12 @@ import { useListExceptionItems } from '../use_list_exception_items'; import * as i18n from '../../translations'; import { checkIfListCannotBeEdited } from '../../utils/list.utils'; +interface ExportListAction { + id: string; + listId: string; + namespaceType: NamespaceType; + includeExpiredExceptions: boolean; +} interface ListAction { id: string; listId: string; @@ -33,7 +39,12 @@ export const useExceptionsListCard = ({ handleManageRules, }: { exceptionsList: ExceptionListInfo; - handleExport: ({ id, listId, namespaceType }: ListAction) => () => Promise; + handleExport: ({ + id, + listId, + namespaceType, + includeExpiredExceptions, + }: ExportListAction) => () => Promise; handleDelete: ({ id, listId, namespaceType }: ListAction) => () => Promise; handleManageRules: () => void; }) => { @@ -41,6 +52,7 @@ export const useExceptionsListCard = ({ const [exceptionToEdit, setExceptionToEdit] = useState(); const [showAddExceptionFlyout, setShowAddExceptionFlyout] = useState(false); const [showEditExceptionFlyout, setShowEditExceptionFlyout] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); const { name: listName, @@ -111,13 +123,7 @@ export const useExceptionsListCard = ({ key: 'Export', icon: 'exportAction', label: i18n.EXPORT_EXCEPTION_LIST, - onClick: (e: React.MouseEvent) => { - handleExport({ - id: exceptionsList.id, - listId: exceptionsList.list_id, - namespaceType: exceptionsList.namespace_type, - })(); - }, + onClick: (e: React.MouseEvent) => setShowExportModal(true), }, { key: 'Delete', @@ -147,7 +153,7 @@ export const useExceptionsListCard = ({ exceptionsList.list_id, exceptionsList.namespace_type, handleDelete, - handleExport, + setShowExportModal, listCannotBeEdited, handleManageRules, ] @@ -173,6 +179,26 @@ export const useExceptionsListCard = ({ [fetchItems, setShowAddExceptionFlyout, setShowEditExceptionFlyout] ); + const onExportListClick = useCallback(() => { + setShowExportModal(true); + }, [setShowExportModal]); + + const handleCancelExportModal = () => { + setShowExportModal(false); + }; + + const handleConfirmExportModal = useCallback( + (includeExpiredExceptions: boolean): void => { + handleExport({ + id: exceptionsList.id, + listId: exceptionsList.list_id, + namespaceType: exceptionsList.namespace_type, + includeExpiredExceptions, + })(); + }, + [handleExport, exceptionsList] + ); + // routes to x-pack/plugins/security_solution/public/exceptions/routes.tsx // details component is here: x-pack/plugins/security_solution/public/exceptions/pages/list_detail_view/index.tsx const { onClick: goToExceptionDetail } = useGetSecuritySolutionLinkProps()({ @@ -211,5 +237,9 @@ export const useExceptionsListCard = ({ emptyViewerTitle, emptyViewerBody, emptyViewerButtonText, + showExportModal, + onExportListClick, + handleCancelExportModal, + handleConfirmExportModal, }; }; diff --git a/x-pack/plugins/security_solution/public/exceptions/hooks/use_list_detail_view/index.ts b/x-pack/plugins/security_solution/public/exceptions/hooks/use_list_detail_view/index.ts index 50db0ee551d72..949854d78ae65 100644 --- a/x-pack/plugins/security_solution/public/exceptions/hooks/use_list_detail_view/index.ts +++ b/x-pack/plugins/security_solution/public/exceptions/hooks/use_list_detail_view/index.ts @@ -163,28 +163,32 @@ export const useListDetailsView = (exceptionListId: string) => { }, [exceptionListId, handleErrorStatus, http, list] ); - const onExportList = useCallback(async () => { - try { - if (!list) return; - await exportExceptionList({ - id: list.id, - listId: list.list_id, - namespaceType: list.namespace_type, - onError: (error: Error) => handleErrorStatus(error), - onSuccess: (blob) => { - setExportedList(blob); - toasts?.addSuccess(i18n.EXCEPTION_LIST_EXPORTED_SUCCESSFULLY(list.list_id)); - }, - }); - } catch (error) { - handleErrorStatus( - error, - undefined, - i18n.EXCEPTION_EXPORT_ERROR, - i18n.EXCEPTION_EXPORT_ERROR_DESCRIPTION - ); - } - }, [list, exportExceptionList, handleErrorStatus, toasts]); + const onExportList = useCallback( + async (includeExpiredExceptions: boolean) => { + try { + if (!list) return; + await exportExceptionList({ + id: list.id, + listId: list.list_id, + includeExpiredExceptions, + namespaceType: list.namespace_type, + onError: (error: Error) => handleErrorStatus(error), + onSuccess: (blob) => { + setExportedList(blob); + toasts?.addSuccess(i18n.EXCEPTION_LIST_EXPORTED_SUCCESSFULLY(list.list_id)); + }, + }); + } catch (error) { + handleErrorStatus( + error, + undefined, + i18n.EXCEPTION_EXPORT_ERROR, + i18n.EXCEPTION_EXPORT_ERROR_DESCRIPTION + ); + } + }, + [list, exportExceptionList, handleErrorStatus, toasts] + ); // #region DeleteList diff --git a/x-pack/plugins/security_solution/public/exceptions/pages/list_detail_view/index.tsx b/x-pack/plugins/security_solution/public/exceptions/pages/list_detail_view/index.tsx index 4e7a85e616fbe..0be51bf851ba1 100644 --- a/x-pack/plugins/security_solution/public/exceptions/pages/list_detail_view/index.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/pages/list_detail_view/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import type { FC } from 'react'; import { @@ -24,6 +24,7 @@ import { AutoDownload } from '../../../common/components/auto_download/auto_down import { ListWithSearch, ManageRules, ListDetailsLinkAnchor } from '../../components'; import { useListDetailsView } from '../../hooks'; import * as i18n from '../../translations'; +import { ExportExceptionsListModal } from '../../components/export_exceptions_list_modal'; export const ListsDetailViewComponent: FC = () => { const { detailName: exceptionListId } = useParams<{ @@ -59,6 +60,12 @@ export const ListsDetailViewComponent: FC = () => { handleReferenceDelete, } = useListDetailsView(exceptionListId); + const [showExportModal, setShowExportModal] = useState(false); + + const onModalClose = useCallback(() => setShowExportModal(false), [setShowExportModal]); + + const onModalOpen = useCallback(() => setShowExportModal(true), [setShowExportModal]); + const detailsViewContent = useMemo(() => { if (viewerStatus === ViewerStatus.ERROR) return ; @@ -79,7 +86,7 @@ export const ListsDetailViewComponent: FC = () => { backOptions={headerBackOptions} securityLinkAnchorComponent={ListDetailsLinkAnchor} onEditListDetails={onEditListDetails} - onExportList={onExportList} + onExportList={onModalOpen} onDeleteList={handleDelete} onManageRules={onManageRules} /> @@ -107,6 +114,12 @@ export const ListsDetailViewComponent: FC = () => { onRuleSelectionChange={onRuleSelectionChange} /> ) : null} + {showExportModal && ( + + )} ); }, [ @@ -128,6 +141,7 @@ export const ListsDetailViewComponent: FC = () => { showManageButtonLoader, showManageRulesFlyout, showReferenceErrorModal, + showExportModal, viewerStatus, onCancelManageRules, onEditListDetails, @@ -138,6 +152,8 @@ export const ListsDetailViewComponent: FC = () => { handleCloseReferenceErrorModal, handleDelete, handleReferenceDelete, + onModalClose, + onModalOpen, ]); return ( <> diff --git a/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx index 8ca9f19f99485..57dc0249979d3 100644 --- a/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx @@ -192,10 +192,21 @@ export const SharedLists = React.memo(() => { ); const handleExport = useCallback( - ({ id, listId, namespaceType }: { id: string; listId: string; namespaceType: NamespaceType }) => + ({ + id, + listId, + namespaceType, + includeExpiredExceptions, + }: { + id: string; + listId: string; + namespaceType: NamespaceType; + includeExpiredExceptions: boolean; + }) => async () => { await exportExceptionList({ id, + includeExpiredExceptions, listId, namespaceType, onError: handleExportError, diff --git a/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts index 746b1df962c64..b024934fd0da2 100644 --- a/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts +++ b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts @@ -358,3 +358,31 @@ export const SORT_BY_CREATE_AT = i18n.translate( defaultMessage: 'Created At', } ); + +export const EXPORT_MODAL_CANCEL_BUTTON = i18n.translate( + 'xpack.securitySolution.exceptions.exportModalCancelButton', + { + defaultMessage: 'Cancel', + } +); + +export const EXPORT_MODAL_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.exportModalTitle', + { + defaultMessage: 'Export exception list', + } +); + +export const EXPORT_MODAL_INCLUDE_SWITCH_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.exportModalIncludeSwitchLabel', + { + defaultMessage: 'Include expired exceptions', + } +); + +export const EXPORT_MODAL_CONFIRM_BUTTON = i18n.translate( + 'xpack.securitySolution.exceptions.exportModalConfirmButton', + { + defaultMessage: 'Export', + } +); diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts index 70be4f5e19870..d9bab942de2ae 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/create_event_filters.ts @@ -97,6 +97,7 @@ export const createNonInteractiveSessionEventFilter = ( itemId: uuidv4(), meta: [], comments: [], + expireTime: undefined, }); } catch (err) { logger.error(`Error creating Event Filter: ${wrapErrorIfNeeded(err)}`); diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts index b423c47f6ca65..6d706c91ce121 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts @@ -57,6 +57,7 @@ export const removePolicyFromArtifacts = async ( namespaceType: artifact.namespace_type, osTypes: artifact.os_types, tags: artifact.tags.filter((currentPolicy) => currentPolicy !== `policy:${policy.id}`), + expireTime: artifact.expire_time, }), { /** Number of concurrent executions till the end of the artifacts array */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts index 6a658fe6e9dca..f2cf69d66fb5a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts @@ -190,6 +190,7 @@ export const createExceptionListItems = async ({ comments: item.comments, description: item.description, entries: item.entries, + expireTime: item.expire_time, itemId: item.item_id, listId: defaultList.list_id, meta: item.meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.test.ts index e34dff9733794..76d63ddcd54b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.test.ts @@ -67,7 +67,7 @@ describe('Find rules route', () => { query: { page: 2, per_page: 20, - sort_field: 'timestamp', + sort_field: 'name', fields: ['field1', 'field2'], }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_exceptions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_exceptions.ts index a1af3ee76e007..bf662d1f8abb9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_exceptions.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_exceptions.ts @@ -94,6 +94,7 @@ export const createPromises = ( id, listId, namespaceType, + includeExpiredExceptions: true, // TODO: pass this arg in via the rule export api }); } ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/find_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/find_rules.ts index bb46831417d71..d72e3e6900be3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/find_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/find_rules.ts @@ -6,24 +6,25 @@ */ import type { FindResult, RulesClient } from '@kbn/alerting-plugin/server'; +import type { FindRulesSortFieldOrUndefined } from '../../../../../../common/detection_engine/rule_management'; import type { FieldsOrUndefined, PageOrUndefined, PerPageOrUndefined, QueryFilterOrUndefined, - SortFieldOrUndefined, SortOrderOrUndefined, } from '../../../../../../common/detection_engine/schemas/common'; import type { RuleParams } from '../../../rule_schema'; import { enrichFilterWithRuleTypeMapping } from './enrich_filter_with_rule_type_mappings'; +import { transformSortField } from './transform_sort_field'; export interface FindRuleOptions { rulesClient: RulesClient; filter: QueryFilterOrUndefined; fields: FieldsOrUndefined; - sortField: SortFieldOrUndefined; + sortField: FindRulesSortFieldOrUndefined; sortOrder: SortOrderOrUndefined; page: PageOrUndefined; perPage: PerPageOrUndefined; @@ -45,7 +46,7 @@ export const findRules = ({ perPage, filter: enrichFilterWithRuleTypeMapping(filter), sortOrder, - sortField, + sortField: transformSortField(sortField), }, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/transform_sort_field.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/transform_sort_field.ts new file mode 100644 index 0000000000000..dc94afa07ddb0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/search/transform_sort_field.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FindRulesSortFieldOrUndefined } from '../../../../../../common/detection_engine/rule_management'; +import { assertUnreachable } from '../../../../../../common/utility_types'; + +/** + * Transform the sort field name from the request to the Alerting framework + * schema. + * + * It could be a bit confusing what field is used for sorting rules as it is + * hidden under several layers of abstraction. + * + * Sort field as defined in the UI rule schema -> API transformation (this + * function) -> Alerting framework transformation -> Saved Object Client + * transformation -> Elasticsearch field + * + * The alerting framework applies the following transformations to the sort + * field: + * - Some sort fields like `name` are converted to `name.keyword` by the + * Alerting framework automatically + * - Sort fields that have corresponding mapped params, like `riskScore`, will + * be prefixed with `mapped_params.` and converted to a snake case + * automatically. So `riskScore` will become `mapped_params.risk_score` + * - Other fields will be passed to the saved object client as is. + * + * Saved objects client also applies some transformations to the sort field: + * - First, it appends the saved object type to the sort field. So `name` will + * become `alert.name` + * - If the sort field doesn't exist in saved object mappings, then it tries the + * field without prefixes, e.g., just `name`. + * + * @param sortField Sort field parameter from the request + * @returns Sort field matching the Alerting framework schema + */ +export function transformSortField(sortField: FindRulesSortFieldOrUndefined): string | undefined { + if (!sortField) { + return undefined; + } + + switch (sortField) { + // Without this conversion, rules will be sorted by the top-level SO fields. + // That is seldom what is expected from sorting. After conversion, the + // fields will be treated as `alert.updatedAt` and `alert.createdAt` + case 'created_at': + return 'createdAt'; + case 'updated_at': + return 'updatedAt'; + + // Convert front-end representation to the field names that match the Alerting framework mappings + case 'execution_summary.last_execution.date': + return 'monitoring.run.last_run.timestamp'; + case 'execution_summary.last_execution.metrics.execution_gap_duration_s': + return 'monitoring.run.last_run.metrics.gap_duration_s'; + case 'execution_summary.last_execution.metrics.total_indexing_duration_ms': + return 'monitoring.run.last_run.metrics.total_indexing_duration_ms'; + case 'execution_summary.last_execution.metrics.total_search_duration_ms': + return 'monitoring.run.last_run.metrics.total_search_duration_ms'; + case 'execution_summary.last_execution.status': + return 'lastRun.outcomeOrder'; + + // Pass these fields as is. They will be converted to `alert.` by the saved object client + case 'createdAt': + case 'updatedAt': + case 'enabled': + case 'name': + return sortField; + + // Mapped fields will be converted to `mapped_params.risk_score` by the Alerting framework automatically + case 'riskScore': + case 'risk_score': + case 'severity': + return `params.${sortField}`; + + default: + assertUnreachable(sortField); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index bc738c4f397a0..5be1d2f23df2d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -313,6 +313,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = }); const { filter: exceptionFilter, unprocessedExceptions } = await buildExceptionFilter({ + startedAt, alias: null, excludeExceptions: true, chunkSize: 10, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts index 7c3ded7fdc921..8aa51719a69bb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts @@ -789,6 +789,7 @@ describe('create_signals', () => { alias: null, chunkSize: 1024, excludeExceptions: true, + startedAt: new Date(), }); const request = buildEqlSearchRequest({ query: 'process where true', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts index 3b2f2907cf2f0..4fb7bc2f14a87 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts @@ -233,6 +233,7 @@ describe('get_filter', () => { test('returns a query when given a list', async () => { const { filter } = await buildExceptionFilter({ + startedAt: new Date(), listClient: getListClientMock(), lists: [getExceptionListItemSchemaMock()], alias: null, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_query_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_query_filter.test.ts index c6acfc6d66477..7413541892610 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_query_filter.test.ts @@ -511,6 +511,7 @@ describe('get_filter', () => { test('it should work with a list', async () => { const { filter } = await buildExceptionFilter({ + startedAt: new Date(), listClient: getListClientMock(), lists: [getExceptionListItemSchemaMock()], alias: null, @@ -595,6 +596,7 @@ describe('get_filter', () => { test('it should work with a list with multiple items', async () => { const { filter } = await buildExceptionFilter({ + startedAt: new Date(), listClient: getListClientMock(), lists: [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()], alias: null, @@ -714,6 +716,7 @@ describe('get_filter', () => { test('it should work with an exception list that includes a nested type', async () => { const { filter } = await buildExceptionFilter({ + startedAt: new Date(), listClient: getListClientMock(), lists: [getExceptionListItemSchemaMock()], alias: null, @@ -819,6 +822,7 @@ describe('get_filter', () => { describe('when "excludeExceptions" is false', () => { test('it should work with a list', async () => { const { filter } = await buildExceptionFilter({ + startedAt: new Date(), listClient: getListClientMock(), lists: [getExceptionListItemSchemaMock()], alias: null, @@ -901,6 +905,7 @@ describe('get_filter', () => { test('it should work with a list with multiple items', async () => { const { filter } = await buildExceptionFilter({ + startedAt: new Date(), listClient: getListClientMock(), lists: [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()], alias: null, diff --git a/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts b/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts index a890207867b2a..8583dd951dc8b 100644 --- a/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts +++ b/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const combineFiltersAndUserSearch = (filters: string, search: string) => { +export const combineFiltersAndUserSearch = (filters: string, search?: string) => { if (!filters && !search) { return ''; } diff --git a/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts b/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts index ab613c9fcd370..ce6e0ec96313c 100644 --- a/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts +++ b/x-pack/plugins/synthetics/common/requests/get_certs_request_body.ts @@ -40,6 +40,7 @@ export const getCertsRequestBody = ({ from = DEFAULT_FROM, sortBy = DEFAULT_SORT, direction = DEFAULT_DIRECTION, + filters, }: GetCertsParams) => { const sort = SortFields[sortBy as keyof typeof SortFields]; @@ -77,6 +78,7 @@ export const getCertsRequestBody = ({ } : {}), filter: [ + ...(filters ? [filters] : []), { exists: { field: 'tls.server.hash.sha256', diff --git a/x-pack/plugins/synthetics/common/runtime_types/alerts/tls.ts b/x-pack/plugins/synthetics/common/runtime_types/alerts/tls.ts new file mode 100644 index 0000000000000..564485d44f239 --- /dev/null +++ b/x-pack/plugins/synthetics/common/runtime_types/alerts/tls.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +export const TLSParamsType = t.partial({ + search: t.string, + certAgeThreshold: t.number, + certExpirationThreshold: t.number, +}); + +export type TLSParams = t.TypeOf; diff --git a/x-pack/plugins/synthetics/common/runtime_types/certs.ts b/x-pack/plugins/synthetics/common/runtime_types/certs.ts index c1a411effb903..b7231da2bb1be 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/certs.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/certs.ts @@ -20,6 +20,7 @@ export const GetCertsParamsType = t.intersection([ sortBy: t.string, direction: t.string, size: t.number, + filters: t.unknown, }), ]); diff --git a/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/status_alert_flyouts_in_alerting_app.ts b/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/status_alert_flyouts_in_alerting_app.ts index 325633bba7413..6f058d33d7714 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/status_alert_flyouts_in_alerting_app.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/status_alert_flyouts_in_alerting_app.ts @@ -87,7 +87,11 @@ journey('StatusFlyoutInAlertingApp', async ({ page, params }) => { }); step('Tls alert flyout has setting values', async () => { - await assertText({ page, text: '30 days' }); - await assertText({ page, text: '730 days' }); + expect(await page.locator(byTestId('tlsExpirationThreshold')).textContent()).toBe( + 'has a certificate expiring within days: 30' + ); + expect(await page.locator(byTestId('tlsAgeExpirationThreshold')).textContent()).toBe( + 'or older than days: 730' + ); }); }); diff --git a/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/tls_alert_flyouts_in_alerting_app.ts b/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/tls_alert_flyouts_in_alerting_app.ts index 756bb05836035..8b36867fed7a5 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/tls_alert_flyouts_in_alerting_app.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/uptime/alerts/tls_alert_flyouts_in_alerting_app.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { journey, step, before } from '@elastic/synthetics'; +import { journey, step, before, expect } from '@elastic/synthetics'; import { assertText, byTestId, waitForLoadingToFinish } from '@kbn/observability-plugin/e2e/utils'; import { recordVideo } from '@kbn/observability-plugin/e2e/record_video'; import { loginPageProvider } from '../../../page_objects/login'; @@ -36,7 +36,11 @@ journey('TlsFlyoutInAlertingApp', async ({ page, params }) => { }); step('Tls alert flyout has setting values', async () => { - await assertText({ page, text: '30 days' }); - await assertText({ page, text: '730 days' }); + expect(await page.locator(byTestId('tlsExpirationThreshold')).textContent()).toBe( + 'has a certificate expiring within days: 30' + ); + expect(await page.locator(byTestId('tlsAgeExpirationThreshold')).textContent()).toBe( + 'or older than days: 730' + ); }); }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/monitor_errors.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/monitor_errors.tsx index 527033f16c7ce..ce367ef72ba3d 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/monitor_errors.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/monitor_errors.tsx @@ -80,7 +80,8 @@ const EmptyErrors = () => { }; const KEEP_CALM = i18n.translate('xpack.synthetics.errors.keepCalm', { - defaultMessage: 'Keep calm and carry on.', + defaultMessage: + 'This monitor ran successfully during the selected period. Increase the time range to check for older errors.', }); const NO_ERRORS_FOUND = i18n.translate('xpack.synthetics.errors.noErrorsFound', { diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alert_tls.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alert_tls.tsx index f7cd341e04dae..d3ba258e3ad54 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alert_tls.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alert_tls.tsx @@ -7,18 +7,24 @@ import { EuiExpression, EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import React from 'react'; +import { ValueExpression } from '@kbn/triggers-actions-ui-plugin/public'; import { TlsTranslations } from './translations'; -import { SettingsMessageExpressionPopover } from './settings_message_expression_popover'; interface Props { - ageThreshold?: number; - expirationThreshold?: number; - setAlertFlyoutVisible: (value: boolean) => void; + ageThreshold: number; + expirationThreshold: number; + setAgeThreshold: (value: number) => void; + setExpirationThreshold: (value: number) => void; } -export const AlertTlsComponent: React.FC = (props) => ( +export const AlertTlsComponent: React.FC = ({ + ageThreshold, + expirationThreshold, + setAgeThreshold, + setExpirationThreshold, +}) => ( <> - + = (props) => ( value={TlsTranslations.criteriaValue} /> - - + { + setExpirationThreshold(val); + }} description={TlsTranslations.expirationDescription} - value={TlsTranslations.expirationValue(props.expirationThreshold)} - {...props} + errors={[]} /> - - + { + setAgeThreshold(val); + }} description={TlsTranslations.ageDescription} - value={TlsTranslations.ageValue(props.ageThreshold)} - {...props} + errors={[]} /> diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/alert_tls.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/alert_tls.tsx index 9f3da1674ca09..6c7eb5008611b 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/alert_tls.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/alert_tls.tsx @@ -7,30 +7,64 @@ import { useDispatch, useSelector } from 'react-redux'; import React, { useCallback, useEffect } from 'react'; +import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiSpacer } from '@elastic/eui'; +import { useSnapShotCount } from './use_snap_shot'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../../common/constants'; +import { TLSParams } from '../../../../../../common/runtime_types/alerts/tls'; import { AlertTlsComponent } from '../alert_tls'; -import { setAlertFlyoutVisible } from '../../../../state/actions'; import { selectDynamicSettings } from '../../../../state/selectors'; import { getDynamicSettings } from '../../../../state/actions/dynamic_settings'; +import { AlertQueryBar } from '../alert_query_bar/query_bar'; +import { AlertMonitorCount } from '../monitor_status_alert/alert_monitor_status'; -export const AlertTls: React.FC<{}> = () => { +export const AlertTls: React.FC<{ + ruleParams: RuleTypeParamsExpressionProps['ruleParams']; + setRuleParams: RuleTypeParamsExpressionProps['setRuleParams']; +}> = ({ ruleParams, setRuleParams }) => { const dispatch = useDispatch(); - const setFlyoutVisible = useCallback( - (value: boolean) => dispatch(setAlertFlyoutVisible(value)), - [dispatch] - ); + const { settings } = useSelector(selectDynamicSettings); + const { count, loading } = useSnapShotCount({ + query: ruleParams.search ?? '', + }); + useEffect(() => { if (typeof settings === 'undefined') { dispatch(getDynamicSettings()); } }, [dispatch, settings]); + const onSearchChange = useCallback( + (value: string) => { + setRuleParams('search', value === '' ? undefined : value); + }, + [setRuleParams] + ); + return ( - + <> + + + + + + + setRuleParams('certAgeThreshold', Number(value))} + setExpirationThreshold={(value) => setRuleParams('certExpirationThreshold', Number(value))} + /> + ); }; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts index df402944cb462..ec0f03d87bbd0 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts @@ -9,7 +9,7 @@ import { useFetcher } from '@kbn/observability-plugin/public'; import { useGenerateUpdatedKueryString } from '../../../../hooks'; import { fetchSnapshotCount } from '../../../../state/api'; -export const useSnapShotCount = ({ query, filters }: { query: string; filters: [] | string }) => { +export const useSnapShotCount = ({ query, filters }: { query: string; filters?: [] | string }) => { const parsedFilters = filters === undefined || typeof filters === 'string' ? '' diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx index d2095e01c1a4f..0ff17f22d5c00 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx @@ -66,20 +66,7 @@ export const AlertMonitorStatusComponent: React.FC = (p <> - - {' '} - {snapshotLoading && } - - } - iconType="iInCircle" - /> + @@ -128,3 +115,22 @@ export const AlertMonitorStatusComponent: React.FC = (p ); }; + +export const AlertMonitorCount = ({ count, loading }: { count: number; loading?: boolean }) => { + return ( + + {' '} + {loading && } + + } + iconType="iInCircle" + /> + ); +}; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/settings_message_expression_popover.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/settings_message_expression_popover.tsx deleted file mode 100644 index cf4775033443a..0000000000000 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/settings_message_expression_popover.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiLink } from '@elastic/eui'; -import { EuiExpression, EuiPopover } from '@elastic/eui'; -import React, { useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - -interface SettingsMessageExpressionPopoverProps { - 'aria-label': string; - description: string; - id: string; - setAlertFlyoutVisible: (value: boolean) => void; - value: string; -} - -export const SettingsMessageExpressionPopover: React.FC = ({ - 'aria-label': ariaLabel, - description, - setAlertFlyoutVisible, - value, - id, -}) => { - const kibana = useKibana(); - const path = kibana.services?.application?.getUrlForApp('uptime', { path: 'settings' }); - const [isOpen, setIsOpen] = useState(false); - return ( - setIsOpen(!isOpen)} - value={value} - /> - } - isOpen={isOpen} - closePopover={() => setIsOpen(false)} - > - - { - setAlertFlyoutVisible(false); - }} - onKeyUp={(e) => { - if (e.key === 'Enter') { - setAlertFlyoutVisible(false); - } - }} - > - settings page - - - ), - }} - /> - - ); -}; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts index de9da94460338..bbe794c118fcc 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts @@ -270,39 +270,15 @@ export const TlsTranslations = { 'The context of this `when` is in the conditional sense, like "when there are three cookies, eat them all".', } ), - criteriaValue: i18n.translate('xpack.synthetics.alerts.tls.criteriaExpression.value', { - defaultMessage: 'any monitor', + criteriaValue: i18n.translate('xpack.synthetics.tls.criteriaExpression.value', { + defaultMessage: 'matching monitor', }), - expirationAriaLabel: i18n.translate( - 'xpack.synthetics.alerts.tls.expirationExpression.ariaLabel', - { - defaultMessage: - 'An expression displaying the threshold that will trigger the TLS alert for certificate expiration', - } - ), - expirationDescription: i18n.translate( - 'xpack.synthetics.alerts.tls.expirationExpression.description', - { - defaultMessage: 'has a certificate expiring within', - } - ), - expirationValue: (value?: number) => - i18n.translate('xpack.synthetics.alerts.tls.expirationExpression.value', { - defaultMessage: '{value} days', - values: { value }, - }), - ageAriaLabel: i18n.translate('xpack.synthetics.alerts.tls.ageExpression.ariaLabel', { - defaultMessage: - 'An expressing displaying the threshold that will trigger the TLS alert for old certificates', + expirationDescription: i18n.translate('xpack.synthetics.tls.expirationExpression.description', { + defaultMessage: 'has a certificate expiring within days: ', }), - ageDescription: i18n.translate('xpack.synthetics.alerts.tls.ageExpression.description', { - defaultMessage: 'or older than', + ageDescription: i18n.translate('xpack.synthetics.tls.ageExpression.description', { + defaultMessage: 'or older than days: ', }), - ageValue: (value?: number) => - i18n.translate('xpack.synthetics.alerts.tls.ageExpression.value', { - defaultMessage: '{value} days', - values: { value }, - }), }; export const ToggleFlyoutTranslations = { diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_update_kuery_string.ts b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_update_kuery_string.ts index 823c57edfd051..d486c58b0ff74 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_update_kuery_string.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_update_kuery_string.ts @@ -77,7 +77,7 @@ export const useGenerateUpdatedKueryString = ( // this try catch is necessary to evaluate user input in kuery bar, // this error will be actually shown in UI for user to see try { - if ((filterQueryString || urlFilters || excludedFilters) && dataView) { + if ((filterQueryString || urlFilters || excludedFilters) && dataView && combinedFilterString) { const ast = fromKueryExpression(combinedFilterString); const elasticsearchQuery = toElasticsearchQuery(ast, dataView); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/lazy_wrapper/tls_alert.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/lazy_wrapper/tls_alert.tsx index 2d1b8c9898475..bf1affd707859 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/lazy_wrapper/tls_alert.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/lazy_wrapper/tls_alert.tsx @@ -9,24 +9,30 @@ import React from 'react'; import { Provider as ReduxProvider } from 'react-redux'; import { CoreStart } from '@kbn/core/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import type { TLSParams } from '../../../../../common/runtime_types/alerts/tls'; import { store } from '../../../state'; import { ClientPluginsStart } from '../../../../plugin'; import { AlertTls } from '../../../components/overview/alerts/alerts_containers/alert_tls'; import { kibanaService } from '../../../state/kibana_service'; +import { UptimeDataViewContextProvider } from '../../../contexts/uptime_data_view_context'; interface Props { core: CoreStart; plugins: ClientPluginsStart; - params: any; + ruleParams: RuleTypeParamsExpressionProps['ruleParams']; + setRuleParams: RuleTypeParamsExpressionProps['setRuleParams']; } // eslint-disable-next-line import/no-default-export -export default function TLSAlert({ core, plugins, params: _params }: Props) { +export default function TLSAlert({ core, plugins, ruleParams, setRuleParams }: Props) { kibanaService.core = core; return ( - + + + ); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/lazy_wrapper/validate_tls_alert.ts b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/lazy_wrapper/validate_tls_alert.ts new file mode 100644 index 0000000000000..a30d8c9f0a8ce --- /dev/null +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/lazy_wrapper/validate_tls_alert.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { isRight } from 'fp-ts/lib/Either'; +import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public'; +import { TLSParamsType } from '../../../../../common/runtime_types/alerts/tls'; + +export function validateTLSAlertParams(ruleParams: any): ValidationResult { + const errors: Record = {}; + const decoded = TLSParamsType.decode(ruleParams); + + if (!isRight(decoded)) { + return { + errors: { + typeCheckFailure: 'Provided parameters do not conform to the expected type.', + typeCheckParsingMessage: PathReporter.report(decoded), + }, + }; + } + + return { errors }; +} diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx index 2032494da036e..f9d510243393b 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx @@ -8,12 +8,17 @@ import React from 'react'; import { ALERT_REASON } from '@kbn/rule-data-utils'; import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public'; +import type { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public'; +import { TLSParams } from '../../../../common/runtime_types/alerts/tls'; import { CLIENT_ALERT_TYPES } from '../../../../common/constants/uptime_alerts'; import { TlsTranslations } from '../../../../common/translations'; import { AlertTypeInitializer } from '.'; import { CERTIFICATES_ROUTE } from '../../../../common/constants/ui'; +let validateFunc: (ruleParams: any) => ValidationResult; + const { defaultActionMessage, defaultRecoveryMessage, description } = TlsTranslations; const TLSAlert = React.lazy(() => import('./lazy_wrapper/tls_alert')); export const initTlsAlertType: AlertTypeInitializer = ({ @@ -25,9 +30,24 @@ export const initTlsAlertType: AlertTypeInitializer = ({ documentationUrl(docLinks) { return `${docLinks.links.observability.tlsCertificate}`; }, - ruleParamsExpression: (params: any) => , + ruleParamsExpression: (params: RuleTypeParamsExpressionProps) => ( + + ), description, - validate: () => ({ errors: {} }), + validate: (ruleParams: any) => { + if (!validateFunc) { + (async function loadValidate() { + const { validateTLSAlertParams } = await import('./lazy_wrapper/validate_tls_alert'); + validateFunc = validateTLSAlertParams; + })(); + } + return validateFunc ? validateFunc(ruleParams) : ({} as ValidationResult); + }, defaultActionMessage, defaultRecoveryMessage, requiresAppContext: false, diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls_legacy.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls_legacy.tsx index c59bedcc70fae..ed67a50ee08b7 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls_legacy.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls_legacy.tsx @@ -22,9 +22,16 @@ export const initTlsLegacyAlertType: AlertTypeInitializer = ({ documentationUrl(docLinks) { return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/tls-certificate-alert.html`; }, - ruleParamsExpression: (params: any) => , + ruleParamsExpression: (params: any) => ( + + ), description, validate: () => ({ errors: {} }), defaultActionMessage, - requiresAppContext: false, + requiresAppContext: true, }); diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/status_check.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/status_check.ts index 66dd5afbbb9e7..6dd200b11fda2 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/status_check.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/status_check.ts @@ -104,8 +104,8 @@ export const hasFilters = (filters?: StatusCheckFilters) => { export const generateFilterDSL = async ( getIndexPattern: () => Promise, - filters: StatusCheckFilters, - search: string + filters?: StatusCheckFilters, + search?: string ) => { const filtersExist = hasFilters(filters); if (!filtersExist && !search) return undefined; @@ -122,8 +122,8 @@ export const generateFilterDSL = async ( export const formatFilterString = async ( uptimeEsClient: UptimeEsClient, - filters: StatusCheckFilters, - search: string, + filters?: StatusCheckFilters, + search?: string, libs?: UMServerLibs ) => await generateFilterDSL( diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls.ts index 85de29419b8e7..3d39d1d9329e6 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/tls.ts @@ -8,6 +8,8 @@ import moment from 'moment'; import { schema } from '@kbn/config-schema'; import { ALERT_REASON, ALERT_UUID } from '@kbn/rule-data-utils'; import { ActionGroupIdsOf } from '@kbn/alerting-plugin/common'; +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { formatFilterString } from './status_check'; import { UptimeAlertTypeFactory } from './types'; import { updateState, @@ -108,7 +110,11 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = ( producer: 'uptime', name: tlsTranslations.alertFactoryName, validate: { - params: schema.object({}), + params: schema.object({ + search: schema.maybe(schema.string()), + certExpirationThreshold: schema.maybe(schema.number()), + certAgeThreshold: schema.maybe(schema.number()), + }), }, defaultActionGroupId: TLS.id, actionGroups: [ @@ -131,6 +137,7 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = ( minimumLicenseRequired: 'basic', doesSetRecoveryContext: true, async executor({ + params, services: { alertFactory, alertWithLifecycle, @@ -149,38 +156,39 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = ( scopedClusterClient.asCurrentUser ); + const certExpirationThreshold = + params.certExpirationThreshold ?? + dynamicSettings?.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold; + + const certAgeThreshold = + params.certAgeThreshold ?? + dynamicSettings?.certAgeThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold; + + let filters: QueryDslQueryContainer | undefined; + + if (params.search) { + filters = await formatFilterString(uptimeEsClient, undefined, params.search, libs); + } + const { certs, total }: CertResult = await libs.requests.getCerts({ uptimeEsClient, pageIndex: 0, size: 1000, - notValidAfter: `now+${ - dynamicSettings?.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold - }d`, - notValidBefore: `now-${ - dynamicSettings?.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold - }d`, + notValidAfter: `now+${certExpirationThreshold}d`, + notValidBefore: `now-${certAgeThreshold}d`, sortBy: 'common_name', direction: 'desc', + filters, }); const foundCerts = total > 0; if (foundCerts) { certs.forEach((cert) => { - const absoluteExpirationThreshold = moment() - .add( - dynamicSettings.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, - 'd' - ) - .valueOf(); - const absoluteAgeThreshold = moment() - .subtract( - dynamicSettings.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, - 'd' - ) - .valueOf(); + const absoluteExpirationThreshold = moment().add(certExpirationThreshold, 'd').valueOf(); + const absoluteAgeThreshold = moment().subtract(certAgeThreshold, 'd').valueOf(); const summary = getCertSummary(cert, absoluteExpirationThreshold, absoluteAgeThreshold); if (!summary.summary || !summary.status) { diff --git a/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts b/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts index 764d6348a07a8..31134d6691f48 100644 --- a/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts +++ b/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts @@ -191,9 +191,9 @@ export const CASE_COMMENT_EXTERNAL_REFERENCE = `[data-test-subj="comment-externa export const CASE_ACTION_WRAPPER = `[data-test-subj="case-action-bar-wrapper"]`; -export const CASE_ELLIPSE_BUTTON = `[data-test-subj="property-actions-ellipses"]`; +export const CASE_ELLIPSE_BUTTON = `[data-test-subj="property-actions-case-ellipses"]`; -export const CASE_ELLIPSE_DELETE_CASE_OPTION = `[data-test-subj="property-actions-trash"]`; +export const CASE_ELLIPSE_DELETE_CASE_OPTION = `[data-test-subj="property-actions-case-trash"]`; export const CASE_ELLIPSE_DELETE_CASE_CONFIRMATION_BUTTON = `[data-test-subj="confirmModalConfirmButton"]`; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index b06153172685e..0e1aabfd937a5 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -28755,7 +28755,6 @@ "xpack.securitySolution.detectionEngine.rules.allRules.columns.ruleTitle": "Règle", "xpack.securitySolution.detectionEngine.rules.allRules.columns.severityTitle": "Sévérité", "xpack.securitySolution.detectionEngine.rules.allRules.columns.tagsTitle": "Balises", - "xpack.securitySolution.detectionEngine.rules.allRules.columns.versionTitle": "Version", "xpack.securitySolution.detectionEngine.rules.allRules.exportFilenameTitle": "rules_export", "xpack.securitySolution.detectionEngine.rules.allRules.featureTour.nextStepLabel": "Aller à l'étape suivante", "xpack.securitySolution.detectionEngine.rules.allRules.featureTour.previousStepLabel": "Revenir à l'étape précédente", @@ -28779,9 +28778,6 @@ "xpack.securitySolution.detectionEngine.rules.defineRuleTitle": "Définir la règle", "xpack.securitySolution.detectionEngine.rules.deleteDescription": "Supprimer", "xpack.securitySolution.detectionEngine.rules.editPageTitle": "Modifier", - "xpack.securitySolution.detectionEngine.rules.experimentalDescription": "Activez cette option pour autoriser le tri sur toutes les colonnes du tableau. Vous pouvez la désactiver si vous rencontrez des problèmes de performances dans votre tableau.", - "xpack.securitySolution.detectionEngine.rules.experimentalOff": "Tri avancé", - "xpack.securitySolution.detectionEngine.rules.experimentalOn": "Tri avancé", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content": "Pour commencer, vous devez charger les règles prédéfinies d'Elastic.", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title": "Charger les règles prédéfinies d'Elastic", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.nextButton": "Suivant", @@ -32739,12 +32735,9 @@ "xpack.synthetics.alerts.monitorStatus.defaultRecoveryMessage": "L'alerte pour le moniteur {monitorName} possédant l'URL {monitorUrl} depuis {observerLocation} a été résolue", "xpack.synthetics.alerts.monitorStatus.monitorCallOut.title": "Cette alerte s'appliquera à environ {snapshotCount} moniteurs.", "xpack.synthetics.alerts.monitorStatus.timerangeValueField.value": "dernier/dernière(s) {value}", - "xpack.synthetics.alerts.tls.ageExpression.value": "{value} jours", "xpack.synthetics.alerts.tls.defaultActionMessage": "Le certificat TLS {commonName} détecté de l'émetteur {issuer} est {status}. Certificat {summary}", "xpack.synthetics.alerts.tls.defaultRecoveryMessage": "L'alerte pour le certificat TLS {commonName} détecté de l'émetteur {issuer} a été résolue", - "xpack.synthetics.alerts.tls.expirationExpression.value": "{value} jours", "xpack.synthetics.alerts.tls.legacy.defaultActionMessage": "Détection de {count} certificats TLS sur le point d'expirer ou devenant trop anciens.\n{expiringConditionalOpen}\nNombre de certificats sur le point d'expirer : {expiringCount}\nCertificats sur le point d'expirer : {expiringCommonNameAndDate}\n{expiringConditionalClose}\n{agingConditionalOpen}\nNombre de certificats vieillissants : {agingCount}\nCertificats vieillissants : {agingCommonNameAndDate}\n{agingConditionalClose}\n", - "xpack.synthetics.alerts.tls.settingsPageNav.text": "Vous pouvez modifier ces seuils sur la {settingsPageLink}.", "xpack.synthetics.alerts.tls.validAfterExpiredString": "expiré le {date}, il y a {relativeDate} jours.", "xpack.synthetics.alerts.tls.validAfterExpiringString": "expire le {date}, dans {relativeDate} jours.", "xpack.synthetics.alerts.tls.validBeforeExpiredString": "valide depuis le {date}, il y a {relativeDate} jours.", @@ -33000,16 +32993,11 @@ "xpack.synthetics.alerts.tls.actionVariables.state.count": "Nombre de certificats détectés par l'exécuteur d'alertes", "xpack.synthetics.alerts.tls.actionVariables.state.expiringCommonNameAndDate": "Noms courants et date/heure d'expiration des certificats détectés", "xpack.synthetics.alerts.tls.actionVariables.state.expiringCount": "Nombre de certificats sur le point d'expirer détectés par l'alerte.", - "xpack.synthetics.alerts.tls.ageExpression.ariaLabel": "Expression affichant le seuil qui déclenchera l'alerte TLS pour les certificats anciens", - "xpack.synthetics.alerts.tls.ageExpression.description": "ou plus anciens que", "xpack.synthetics.alerts.tls.agingLabel": "devient trop ancien", "xpack.synthetics.alerts.tls.clientName": "Uptime TLS", "xpack.synthetics.alerts.tls.criteriaExpression.ariaLabel": "Expression affichant les critères des moniteurs surveillés par cette alerte", "xpack.synthetics.alerts.tls.criteriaExpression.description": "quand", - "xpack.synthetics.alerts.tls.criteriaExpression.value": "tout moniteur", "xpack.synthetics.alerts.tls.description": "Alerte lorsque le certificat TLS d'un moniteur Uptime est sur le point d'expirer.", - "xpack.synthetics.alerts.tls.expirationExpression.ariaLabel": "Expression affichant le seuil qui déclenchera l'alerte TLS pour l'expiration des certificats", - "xpack.synthetics.alerts.tls.expirationExpression.description": "possède un certificat expirant dans", "xpack.synthetics.alerts.tls.expiredLabel": "expiré", "xpack.synthetics.alerts.tls.expiringLabel": "sur le point d'expirer", "xpack.synthetics.alerts.tls.invalidLabel": "non valide", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 42ffa67a86fd6..92cab0ec41d51 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -28727,7 +28727,6 @@ "xpack.securitySolution.detectionEngine.rules.allRules.columns.ruleTitle": "ルール", "xpack.securitySolution.detectionEngine.rules.allRules.columns.severityTitle": "深刻度", "xpack.securitySolution.detectionEngine.rules.allRules.columns.tagsTitle": "タグ", - "xpack.securitySolution.detectionEngine.rules.allRules.columns.versionTitle": "バージョン", "xpack.securitySolution.detectionEngine.rules.allRules.exportFilenameTitle": "rules_export", "xpack.securitySolution.detectionEngine.rules.allRules.featureTour.nextStepLabel": "次のステップに進む", "xpack.securitySolution.detectionEngine.rules.allRules.featureTour.previousStepLabel": "前のステップに戻る", @@ -28751,9 +28750,6 @@ "xpack.securitySolution.detectionEngine.rules.defineRuleTitle": "ルールの定義", "xpack.securitySolution.detectionEngine.rules.deleteDescription": "削除", "xpack.securitySolution.detectionEngine.rules.editPageTitle": "編集", - "xpack.securitySolution.detectionEngine.rules.experimentalDescription": "オンにすると、すべてのテーブル列の並べ替えが有効になります。テーブルのパフォーマンスの問題が発生した場合は、オフにできます。", - "xpack.securitySolution.detectionEngine.rules.experimentalOff": "高度な並べ替え", - "xpack.securitySolution.detectionEngine.rules.experimentalOn": "高度な並べ替え", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content": "開始するには、Elasticの構築済みルールを読み込む必要があります。", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title": "Elastic事前構築済みルールを読み込む", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.nextButton": "次へ", @@ -32710,12 +32706,9 @@ "xpack.synthetics.alerts.monitorStatus.defaultRecoveryMessage": "{observerLocation}からのurl {monitorUrl}のモニター{monitorName}に関するアラートが回復しました", "xpack.synthetics.alerts.monitorStatus.monitorCallOut.title": "このアラートは約{snapshotCount}個のモニターに適用されます。", "xpack.synthetics.alerts.monitorStatus.timerangeValueField.value": "最終{value}", - "xpack.synthetics.alerts.tls.ageExpression.value": "{value}日以内に期限切れになる", "xpack.synthetics.alerts.tls.defaultActionMessage": "発行者{issuer}の検出されたTLS証明書{commonName}は{status}です。証明書{summary}", "xpack.synthetics.alerts.tls.defaultRecoveryMessage": "発行者{issuer}のTLS証明書{commonName}のアラートが回復しました", - "xpack.synthetics.alerts.tls.expirationExpression.value": "{value}日以内に期限切れになる", "xpack.synthetics.alerts.tls.legacy.defaultActionMessage": "期限切れになるか古くなりすぎた{count} TLS個のTLS証明書証明書を検知しました。\n{expiringConditionalOpen}\n期限切れになる証明書数:{expiringCount}\n期限切れになる証明書:{expiringCommonNameAndDate}\n{expiringConditionalClose}\n{agingConditionalOpen}\n古い証明書数:{agingCount}\n古い証明書:{agingCommonNameAndDate}\n{agingConditionalClose}\n", - "xpack.synthetics.alerts.tls.settingsPageNav.text": "これらのしきい値は{settingsPageLink}で編集できます。", "xpack.synthetics.alerts.tls.validAfterExpiredString": "{relativeDate}日前、{date}に期限切れになりました。", "xpack.synthetics.alerts.tls.validAfterExpiringString": "{relativeDate}日以内、{date}に期限切れになります。", "xpack.synthetics.alerts.tls.validBeforeExpiredString": "{relativeDate}日前、{date}以降有効です。", @@ -32971,16 +32964,11 @@ "xpack.synthetics.alerts.tls.actionVariables.state.count": "アラート実行によって検出された証明書数", "xpack.synthetics.alerts.tls.actionVariables.state.expiringCommonNameAndDate": "検出された証明書の共通名と有効期限日時", "xpack.synthetics.alerts.tls.actionVariables.state.expiringCount": "アラートによって検出された期限切れになる証明書数", - "xpack.synthetics.alerts.tls.ageExpression.ariaLabel": "古い証明書のTLSアラートをトリガーするしきい値を示す式", - "xpack.synthetics.alerts.tls.ageExpression.description": "または", "xpack.synthetics.alerts.tls.agingLabel": "古すぎます", "xpack.synthetics.alerts.tls.clientName": "アップタイムTLS", "xpack.synthetics.alerts.tls.criteriaExpression.ariaLabel": "このアラートで監視されるモニターの条件を示す式", "xpack.synthetics.alerts.tls.criteriaExpression.description": "タイミング", - "xpack.synthetics.alerts.tls.criteriaExpression.value": "任意のモニター", "xpack.synthetics.alerts.tls.description": "アップタイム監視の TLS 証明書の有効期限が近いときにアラートを発行します。", - "xpack.synthetics.alerts.tls.expirationExpression.ariaLabel": "証明書有効期限の TLS アラートをトリガーするしきい値を示す式", - "xpack.synthetics.alerts.tls.expirationExpression.description": "証明書が", "xpack.synthetics.alerts.tls.expiredLabel": "期限切れ", "xpack.synthetics.alerts.tls.expiringLabel": "まもなく期限切れ", "xpack.synthetics.alerts.tls.invalidLabel": "無効", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e41ae9b029458..67e7b522e1d1a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -28761,7 +28761,6 @@ "xpack.securitySolution.detectionEngine.rules.allRules.columns.ruleTitle": "规则", "xpack.securitySolution.detectionEngine.rules.allRules.columns.severityTitle": "严重性", "xpack.securitySolution.detectionEngine.rules.allRules.columns.tagsTitle": "标签", - "xpack.securitySolution.detectionEngine.rules.allRules.columns.versionTitle": "版本", "xpack.securitySolution.detectionEngine.rules.allRules.exportFilenameTitle": "rules_export", "xpack.securitySolution.detectionEngine.rules.allRules.featureTour.nextStepLabel": "前往下一步", "xpack.securitySolution.detectionEngine.rules.allRules.featureTour.previousStepLabel": "前往上一步", @@ -28785,9 +28784,6 @@ "xpack.securitySolution.detectionEngine.rules.defineRuleTitle": "定义规则", "xpack.securitySolution.detectionEngine.rules.deleteDescription": "删除", "xpack.securitySolution.detectionEngine.rules.editPageTitle": "编辑", - "xpack.securitySolution.detectionEngine.rules.experimentalDescription": "打开此项可为所有表列启用排序。如果遇到表性能问题,可以将其关闭。", - "xpack.securitySolution.detectionEngine.rules.experimentalOff": "高级排序", - "xpack.securitySolution.detectionEngine.rules.experimentalOn": "高级排序", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.content": "要开始使用,您需要加载 Elastic 预构建规则。", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.installPrebuiltRules.title": "加载 Elastic 预构建规则", "xpack.securitySolution.detectionEngine.rules.guidedOnboarding.nextButton": "下一步", @@ -32745,12 +32741,9 @@ "xpack.synthetics.alerts.monitorStatus.defaultRecoveryMessage": "来自 {observerLocation} 且 url 为 {monitorUrl} 的监测 {monitorName} 的告警已恢复", "xpack.synthetics.alerts.monitorStatus.monitorCallOut.title": "此告警将应用到大约 {snapshotCount} 个监测。", "xpack.synthetics.alerts.monitorStatus.timerangeValueField.value": "上一 {value}", - "xpack.synthetics.alerts.tls.ageExpression.value": "{value} 天内过期的证书", "xpack.synthetics.alerts.tls.defaultActionMessage": "检测到来自颁发者 {issuer} 的 TLS 证书 {commonName} 的状态为 {status}。证书 {summary}", "xpack.synthetics.alerts.tls.defaultRecoveryMessage": "来自颁发者 {issuer} 的 TLS 证书 {commonName} 的告警已恢复", - "xpack.synthetics.alerts.tls.expirationExpression.value": "{value} 天内过期的证书", "xpack.synthetics.alerts.tls.legacy.defaultActionMessage": "检测到 {count} 个 TLS 证书即将过期或即将过时。\n{expiringConditionalOpen}\n即将过期的证书计数:{expiringCount}\n即将过期的证书:{expiringCommonNameAndDate}\n{expiringConditionalClose}\n{agingConditionalOpen}\n过时的证书计数:{agingCount}\n过时的证书:{agingCommonNameAndDate}\n{agingConditionalClose}\n", - "xpack.synthetics.alerts.tls.settingsPageNav.text": "可以在 {settingsPageLink}上编辑这些阈值。", "xpack.synthetics.alerts.tls.validAfterExpiredString": "已于 {relativeDate} 天前,即 {date}到期。", "xpack.synthetics.alerts.tls.validAfterExpiringString": "将在{relativeDate} 天后,即 {date}到期。", "xpack.synthetics.alerts.tls.validBeforeExpiredString": "自 {relativeDate} 天前,即 {date}开始生效。", @@ -33006,16 +32999,11 @@ "xpack.synthetics.alerts.tls.actionVariables.state.count": "告警执行工具检测到的证书数目", "xpack.synthetics.alerts.tls.actionVariables.state.expiringCommonNameAndDate": "检测到的证书的常见名称和到期日期/时间", "xpack.synthetics.alerts.tls.actionVariables.state.expiringCount": "告警检测到的即将到期证书数目。", - "xpack.synthetics.alerts.tls.ageExpression.ariaLabel": "显示将触发旧证书 TLS 告警的阈值的表达式", - "xpack.synthetics.alerts.tls.ageExpression.description": "或超过", "xpack.synthetics.alerts.tls.agingLabel": "已过旧", "xpack.synthetics.alerts.tls.clientName": "Uptime TLS", "xpack.synthetics.alerts.tls.criteriaExpression.ariaLabel": "显示此告警监视的监测条件的表达式", "xpack.synthetics.alerts.tls.criteriaExpression.description": "当", - "xpack.synthetics.alerts.tls.criteriaExpression.value": "任意监测", "xpack.synthetics.alerts.tls.description": "运行时间监测的 TLS 证书即将过期时告警。", - "xpack.synthetics.alerts.tls.expirationExpression.ariaLabel": "显示将触发证书过期 TLS 告警的阈值的表达式", - "xpack.synthetics.alerts.tls.expirationExpression.description": "具有将在", "xpack.synthetics.alerts.tls.expiredLabel": "已过期", "xpack.synthetics.alerts.tls.expiringLabel": "将到期", "xpack.synthetics.alerts.tls.invalidLabel": "无效", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts index 430ebb8c3fdf6..f8a9639bb30bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts @@ -42,10 +42,12 @@ const transformExecutionStatus: RewriteRequestCase = ({ const transformLastRun: RewriteRequestCase = ({ outcome_msg: outcomeMsg, + outcome_order: outcomeOrder, alerts_count: alertsCount, ...rest }) => ({ outcomeMsg, + outcomeOrder, alertsCount, ...rest, }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts index 9bb87db3c7e30..e473394f26b8e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts @@ -173,6 +173,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }, outcome: 'succeeded', outcome_msg: null, + outcome_order: 0, warning: null, }, next_run: response.body.next_run, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts index 35ad717ad8096..20980f390c79b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts @@ -560,6 +560,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(alert?.lastRun).to.eql({ outcome: 'succeeded', outcomeMsg: null, + outcomeOrder: 0, warning: null, alertsCount: {}, }); diff --git a/x-pack/test/api_integration/apis/index.ts b/x-pack/test/api_integration/apis/index.ts index 3f2b5febb12ef..0278c439c5995 100644 --- a/x-pack/test/api_integration/apis/index.ts +++ b/x-pack/test/api_integration/apis/index.ts @@ -9,35 +9,35 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('apis', function () { - // loadTestFile(require.resolve('./search')); - // loadTestFile(require.resolve('./es')); + loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./es')); loadTestFile(require.resolve('./security')); - // loadTestFile(require.resolve('./spaces')); - // loadTestFile(require.resolve('./monitoring')); - // loadTestFile(require.resolve('./features')); - // loadTestFile(require.resolve('./telemetry')); - // loadTestFile(require.resolve('./logstash')); - // loadTestFile(require.resolve('./kibana')); - // loadTestFile(require.resolve('./metrics_ui')); - // loadTestFile(require.resolve('./console')); - // loadTestFile(require.resolve('./management')); - // loadTestFile(require.resolve('./uptime')); - // loadTestFile(require.resolve('./synthetics')); - // loadTestFile(require.resolve('./maps')); - // loadTestFile(require.resolve('./security_solution')); - // loadTestFile(require.resolve('./transform')); - // loadTestFile(require.resolve('./lists')); - // loadTestFile(require.resolve('./upgrade_assistant')); - // loadTestFile(require.resolve('./searchprofiler')); - // loadTestFile(require.resolve('./painless_lab')); - // loadTestFile(require.resolve('./file_upload')); - // loadTestFile(require.resolve('./aiops')); - // loadTestFile(require.resolve('./ml')); - // loadTestFile(require.resolve('./watcher')); - // loadTestFile(require.resolve('./logs_ui')); - // loadTestFile(require.resolve('./osquery')); - // loadTestFile(require.resolve('./cases')); - // loadTestFile(require.resolve('./monitoring_collection')); - // loadTestFile(require.resolve('./cloud_security_posture')); + loadTestFile(require.resolve('./spaces')); + loadTestFile(require.resolve('./monitoring')); + loadTestFile(require.resolve('./features')); + loadTestFile(require.resolve('./telemetry')); + loadTestFile(require.resolve('./logstash')); + loadTestFile(require.resolve('./kibana')); + loadTestFile(require.resolve('./metrics_ui')); + loadTestFile(require.resolve('./console')); + loadTestFile(require.resolve('./management')); + loadTestFile(require.resolve('./uptime')); + loadTestFile(require.resolve('./synthetics')); + loadTestFile(require.resolve('./maps')); + loadTestFile(require.resolve('./security_solution')); + loadTestFile(require.resolve('./transform')); + loadTestFile(require.resolve('./lists')); + loadTestFile(require.resolve('./upgrade_assistant')); + loadTestFile(require.resolve('./searchprofiler')); + loadTestFile(require.resolve('./painless_lab')); + loadTestFile(require.resolve('./file_upload')); + loadTestFile(require.resolve('./aiops')); + loadTestFile(require.resolve('./ml')); + loadTestFile(require.resolve('./watcher')); + loadTestFile(require.resolve('./logs_ui')); + loadTestFile(require.resolve('./osquery')); + loadTestFile(require.resolve('./cases')); + loadTestFile(require.resolve('./monitoring_collection')); + loadTestFile(require.resolve('./cloud_security_posture')); }); } diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts index e25f0099a608f..0b457365d4a8a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts @@ -34,7 +34,10 @@ import { ALERT_ORIGINAL_EVENT, } from '@kbn/security-solution-plugin/common/field_maps/field_names'; import { DETECTION_ENGINE_SIGNALS_STATUS_URL } from '@kbn/security-solution-plugin/common/constants'; +import { deleteAllExceptions } from '../../../lists_api_integration/utils'; import { + createExceptionList, + createExceptionListItem, createRule, deleteAllAlerts, deleteSignalsIndex, @@ -1285,6 +1288,95 @@ export default ({ getService }: FtrProviderContext) => { }); }); + it('should create and update alerts in the same rule run without errors', async () => { + const id = uuidv4(); + const timestamp = '2020-10-28T06:00:00.000Z'; + // agent-1 should create an alert on the first rule run, then the second rule run should update that + // alert and make a new alert for agent-2 + const firstDoc = { + id, + '@timestamp': timestamp, + agent: { + name: 'agent-1', + }, + }; + const laterTimestamp = '2020-10-28T07:00:00.000Z'; + const secondDoc = { + id, + '@timestamp': laterTimestamp, + agent: { + name: 'agent-1', + }, + }; + const thirdDoc = { + id, + '@timestamp': laterTimestamp, + agent: { + name: 'agent-2', + }, + }; + await indexDocuments([firstDoc, secondDoc, thirdDoc]); + + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['ecs_compliant']), + query: `id:${id}`, + alert_suppression: { + group_by: ['agent.name'], + duration: { + value: 300, + unit: 'm', + }, + }, + from: 'now-1h', + interval: '1h', + timestamp_override: 'event.ingested', + max_signals: 150, + }; + + const { previewId, logs } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T07:30:00.000Z'), + invocationCount: 2, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + size: 10, + sort: ['agent.name', ALERT_ORIGINAL_TIME], + }); + expect(previewAlerts.length).to.eql(2); + expect(previewAlerts[0]._source).to.eql({ + ...previewAlerts[0]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'agent.name', + value: 'agent-1', + }, + ], + [ALERT_ORIGINAL_TIME]: timestamp, + [ALERT_SUPPRESSION_START]: timestamp, + [ALERT_SUPPRESSION_END]: laterTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 1, + }); + expect(previewAlerts[1]._source).to.eql({ + ...previewAlerts[1]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'agent.name', + value: 'agent-2', + }, + ], + [ALERT_ORIGINAL_TIME]: laterTimestamp, + [ALERT_SUPPRESSION_START]: laterTimestamp, + [ALERT_SUPPRESSION_END]: laterTimestamp, + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }); + for (const logEntry of logs) { + expect(logEntry.errors.length).to.eql(0); + } + }); + describe('with host risk index', async () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/entity/host_risk'); @@ -1342,5 +1434,96 @@ export default ({ getService }: FtrProviderContext) => { }); }); }); + + describe('with exceptions', async () => { + afterEach(async () => { + await deleteAllExceptions(supertest, log); + }); + it('should correctly evaluate exceptions with expiration time in the past', async () => { + // create an exception list container of type "detection" + const { + id, + list_id: listId, + namespace_type: namespaceType, + type, + } = await createExceptionList(supertest, log, { + description: 'description', + list_id: '123', + name: 'test list', + type: 'detection', + }); + + await createExceptionListItem(supertest, log, { + description: 'endpoint description', + entries: [ + { + field: 'host.name', + operator: 'included', + type: 'match', + value: 'suricata-sensor-london', + }, + ], + list_id: listId, + name: 'endpoint_list', + os_types: [], + type: 'simple', + expire_time: new Date(Date.now() - 1000000).toISOString(), + }); + + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['auditbeat-*']), + query: `_id:${ID} or _id:GBbXBmkBR346wHgn5_eR or _id:x10zJ2oE9v5HJNSHhyxi`, + exceptions_list: [{ id, list_id: listId, type, namespace_type: namespaceType }], + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).equal(2); + }); + + it('should correctly evaluate exceptions with expiration time in the future', async () => { + // create an exception list container of type "detection" + const { + id, + list_id: listId, + namespace_type: namespaceType, + type, + } = await createExceptionList(supertest, log, { + description: 'description', + list_id: '123', + name: 'test list', + type: 'detection', + }); + + await createExceptionListItem(supertest, log, { + description: 'endpoint description', + entries: [ + { + field: 'host.name', + operator: 'included', + type: 'match', + value: 'suricata-sensor-london', + }, + ], + list_id: listId, + name: 'endpoint_list', + os_types: [], + type: 'simple', + expire_time: new Date(Date.now() + 1000000).toISOString(), + }); + + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['auditbeat-*']), + query: `_id:${ID} or _id:GBbXBmkBR346wHgn5_eR or _id:x10zJ2oE9v5HJNSHhyxi`, + exceptions_list: [{ id, list_id: listId, type, namespace_type: namespaceType }], + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).equal(1); + }); + }); }); }; diff --git a/x-pack/test/fleet_api_integration/apis/epm/get.ts b/x-pack/test/fleet_api_integration/apis/epm/get.ts index 21b55e0dd7454..7d33b00f54c89 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/get.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/get.ts @@ -38,7 +38,8 @@ export default function (providerContext: FtrProviderContext) { '../fixtures/direct_upload_packages/apache_0.1.4.zip' ); - describe('EPM - get', () => { + // Failing: See https://github.com/elastic/kibana/issues/149794 + describe.skip('EPM - get', () => { skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); diff --git a/x-pack/test/lists_api_integration/security_and_spaces/tests/create_exception_list_items.ts b/x-pack/test/lists_api_integration/security_and_spaces/tests/create_exception_list_items.ts index fd8039f4005be..cc916862a6313 100644 --- a/x-pack/test/lists_api_integration/security_and_spaces/tests/create_exception_list_items.ts +++ b/x-pack/test/lists_api_integration/security_and_spaces/tests/create_exception_list_items.ts @@ -112,6 +112,44 @@ export default ({ getService }: FtrProviderContext) => { status_code: 409, }); }); + + it('should create an exception list item if we pass a Date string for expire_time', async () => { + await supertest + .post(EXCEPTION_LIST_URL) + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListMinimalSchemaMock()) + .expect(200); + + const datetime = new Date().toISOString(); + const { body } = await supertest + .post(EXCEPTION_LIST_ITEM_URL) + .set('kbn-xsrf', 'true') + .send({ + ...getCreateExceptionListItemMinimalSchemaMock(), + expire_time: datetime, + }) + .expect(200); + + const bodyToCompare = removeExceptionListItemServerGeneratedProperties(body); + expect(bodyToCompare).to.eql({ + ...getExceptionListItemResponseMockWithoutAutoGeneratedValues(), + expire_time: datetime, + }); + }); + + it('should cause an error if we attempt to pass a non Date string for expire_time', async () => { + await supertest + .post(EXCEPTION_LIST_URL) + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListMinimalSchemaMock()) + .expect(200); + + await supertest + .post(EXCEPTION_LIST_ITEM_URL) + .set('kbn-xsrf', 'true') + .send({ ...getCreateExceptionListItemMinimalSchemaMock(), expire_time: 'abc' }) + .expect(400); + }); }); }); }; diff --git a/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts b/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts index b3f8e7c0697fe..bfde054eb8f1e 100644 --- a/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts +++ b/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts @@ -47,7 +47,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post( - `${EXCEPTION_LIST_URL}/_export?id=${body.id}&list_id=${body.list_id}&namespace_type=single` + `${EXCEPTION_LIST_URL}/_export?id=${body.id}&list_id=${body.list_id}&namespace_type=single&include_expired_exceptions=true` ) .set('kbn-xsrf', 'true') .expect('Content-Disposition', `attachment; filename="${body.list_id}"`) @@ -71,7 +71,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: exportBody } = await supertest .post( - `${EXCEPTION_LIST_URL}/_export?id=not_exist&list_id=not_exist&namespace_type=single` + `${EXCEPTION_LIST_URL}/_export?id=not_exist&list_id=not_exist&namespace_type=single&include_expired_exceptions=true` ) .set('kbn-xsrf', 'true') .expect(400); @@ -97,7 +97,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: exportResult } = await supertest .post( - `${EXCEPTION_LIST_URL}/_export?id=${body.id}&list_id=${body.list_id}&namespace_type=single` + `${EXCEPTION_LIST_URL}/_export?id=${body.id}&list_id=${body.list_id}&namespace_type=single&include_expired_exceptions=true` ) .set('kbn-xsrf', 'true') .expect(200) @@ -140,7 +140,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: exportResult } = await supertest .post( - `${EXCEPTION_LIST_URL}/_export?id=${body.id}&list_id=${body.list_id}&namespace_type=single` + `${EXCEPTION_LIST_URL}/_export?id=${body.id}&list_id=${body.list_id}&namespace_type=single&include_expired_exceptions=true` ) .set('kbn-xsrf', 'true') .expect(200) @@ -150,6 +150,42 @@ export default ({ getService }: FtrProviderContext): void => { expect(bodyString.includes('some-list-item-id-2')).to.be(true); expect(bodyString.includes('some-list-item-id')).to.be(true); }); + + it('should export a single list with no expired exception items', async () => { + const { body } = await supertest + .post(EXCEPTION_LIST_URL) + .set('kbn-xsrf', 'true') + .send(getCreateExceptionListMinimalSchemaMock()) + .expect(200); + + const { body: itemBody } = await supertest + .post(EXCEPTION_LIST_ITEM_URL) + .set('kbn-xsrf', 'true') + .send({ + ...getCreateExceptionListItemMinimalSchemaMock(), + expire_time: new Date().toISOString(), + }) + .expect(200); + + const { body: exportResult } = await supertest + .post( + `${EXCEPTION_LIST_URL}/_export?id=${body.id}&list_id=${body.list_id}&namespace_type=single&include_expired_exceptions=false` + ) + .set('kbn-xsrf', 'true') + .expect(200) + .parse(binaryToString); + + const exportedItemsToArray = exportResult.toString().split('\n'); + const list = JSON.parse(exportedItemsToArray[0]); + const item = JSON.parse(exportedItemsToArray[1]); + + expect(removeExceptionListServerGeneratedProperties(list)).to.eql( + removeExceptionListServerGeneratedProperties(body) + ); + expect(removeExceptionListItemServerGeneratedProperties(item)).to.not.contain( + removeExceptionListItemServerGeneratedProperties(itemBody) + ); + }); }); }); }; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts index cb43aa5db89a4..1c081b2d734f1 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts @@ -142,7 +142,7 @@ export default function ({ getService }: FtrProviderContext) { method: 'post', info: 'list export', get path() { - return `${EXCEPTION_LIST_URL}/_export?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&id=${blocklistData.artifact.id}`; + return `${EXCEPTION_LIST_URL}/_export?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&id=${blocklistData.artifact.id}&include_expired_exceptions=true`; }, getBody: () => undefined, }, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/event_filters.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/event_filters.ts index 2bcfdb5baa934..b425a10b75102 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/event_filters.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/event_filters.ts @@ -140,7 +140,7 @@ export default function ({ getService }: FtrProviderContext) { method: 'post', info: 'list export', get path() { - return `${EXCEPTION_LIST_URL}/_export?list_id=${eventFilterData.artifact.list_id}&namespace_type=${eventFilterData.artifact.namespace_type}&id=${eventFilterData.artifact.id}`; + return `${EXCEPTION_LIST_URL}/_export?list_id=${eventFilterData.artifact.list_id}&namespace_type=${eventFilterData.artifact.namespace_type}&id=${eventFilterData.artifact.id}&include_expired_exceptions=true`; }, getBody: () => undefined, }, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/host_isolation_exceptions.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/host_isolation_exceptions.ts index 08f0d33faca4c..250f2add8d48c 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/host_isolation_exceptions.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/host_isolation_exceptions.ts @@ -130,7 +130,7 @@ export default function ({ getService }: FtrProviderContext) { method: 'post', info: 'list export', get path() { - return `${EXCEPTION_LIST_URL}/_export?list_id=${hostIsolationExceptionData.artifact.list_id}&namespace_type=${hostIsolationExceptionData.artifact.namespace_type}&id=${hostIsolationExceptionData.artifact.id}`; + return `${EXCEPTION_LIST_URL}/_export?list_id=${hostIsolationExceptionData.artifact.list_id}&namespace_type=${hostIsolationExceptionData.artifact.namespace_type}&id=${hostIsolationExceptionData.artifact.id}&include_expired_exceptions=true`; }, getBody: () => undefined, }, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/trusted_apps.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/trusted_apps.ts index bd1f4c1b519af..dcc10b178d5e5 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/trusted_apps.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/trusted_apps.ts @@ -141,7 +141,7 @@ export default function ({ getService }: FtrProviderContext) { method: 'post', info: 'list export', get path() { - return `${EXCEPTION_LIST_URL}/_export?list_id=${trustedAppData.artifact.list_id}&namespace_type=${trustedAppData.artifact.namespace_type}&id=${trustedAppData.artifact.id}`; + return `${EXCEPTION_LIST_URL}/_export?list_id=${trustedAppData.artifact.list_id}&namespace_type=${trustedAppData.artifact.namespace_type}&id=${trustedAppData.artifact.id}&include_expired_exceptions=true`; }, getBody: () => undefined, },