diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.castestokbnfieldtypename.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.castestokbnfieldtypename.md index a90c0477e57fb..c6135d9313b23 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.castestokbnfieldtypename.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.castestokbnfieldtypename.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> import from "@kbn/field-types" instead +> Import from the "@kbn/field-types" package directly instead. 8.0 > Signature: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index ee04b37bb153c..c13f6e951be98 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -20,18 +20,18 @@ esFilters: { FILTERS: typeof import("@kbn/es-query").FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("@kbn/es-query").Filter; - buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: any[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; + buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: string[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; buildExistsFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").ExistsFilter; - buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: any, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; + buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: string | number | boolean, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("@kbn/es-query/target_types/filters/build_filters").QueryStringFilter; buildRangeFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: import("@kbn/es-query").RangeFilterParams, indexPattern: import("@kbn/es-query").IndexPatternBase, formattedValue?: string | undefined) => import("@kbn/es-query").RangeFilter; - isPhraseFilter: (filter: any) => filter is import("@kbn/es-query").PhraseFilter; - isExistsFilter: (filter: any) => filter is import("@kbn/es-query").ExistsFilter; - isPhrasesFilter: (filter: any) => filter is import("@kbn/es-query").PhrasesFilter; - isRangeFilter: (filter: any) => filter is import("@kbn/es-query").RangeFilter; - isMatchAllFilter: (filter: any) => filter is import("@kbn/es-query").MatchAllFilter; - isMissingFilter: (filter: any) => filter is import("@kbn/es-query").MissingFilter; - isQueryStringFilter: (filter: any) => filter is import("@kbn/es-query/target_types/filters/build_filters").QueryStringFilter; + isPhraseFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").PhraseFilter; + isExistsFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").ExistsFilter; + isPhrasesFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").PhrasesFilter; + isRangeFilter: (filter?: import("@kbn/es-query").ExistsFilter | import("@kbn/es-query").GeoPolygonFilter | import("@kbn/es-query").PhrasesFilter | import("@kbn/es-query").PhraseFilter | import("@kbn/es-query").MatchAllFilter | import("@kbn/es-query").MissingFilter | import("@kbn/es-query").RangeFilter | import("@kbn/es-query").GeoBoundingBoxFilter | undefined) => filter is import("@kbn/es-query").RangeFilter; + isMatchAllFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").MatchAllFilter; + isMissingFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").MissingFilter; + isQueryStringFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query/target_types/filters/build_filters").QueryStringFilter; isFilterPinned: (filter: import("@kbn/es-query").Filter) => boolean | undefined; toggleFilterNegated: (filter: import("@kbn/es-query").Filter) => { meta: { @@ -46,7 +46,9 @@ esFilters: { params?: any; value?: string | undefined; }; - $state?: import("@kbn/es-query").FilterState | undefined; + $state?: { + store: FilterStateStore; + } | undefined; query?: any; }; disableFilter: (filter: import("@kbn/es-query").Filter) => import("@kbn/es-query").Filter; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getkbntypenames.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getkbntypenames.md index eda4b141ec2cd..66effff722f3b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getkbntypenames.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getkbntypenames.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> import from "@kbn/field-types" instead +> Import from the "@kbn/field-types" package directly instead. 8.0 > Signature: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 37af5ea226086..49c9d1d143e6b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -30,9 +30,7 @@ | Enumeration | Description | | --- | --- | | [BUCKET\_TYPES](./kibana-plugin-plugins-data-public.bucket_types.md) | | -| [ES\_FIELD\_TYPES](./kibana-plugin-plugins-data-public.es_field_types.md) | \* | | [IndexPatternType](./kibana-plugin-plugins-data-public.indexpatterntype.md) | | -| [KBN\_FIELD\_TYPES](./kibana-plugin-plugins-data-public.kbn_field_types.md) | \* | | [METRIC\_TYPES](./kibana-plugin-plugins-data-public.metric_types.md) | | | [QuerySuggestionTypes](./kibana-plugin-plugins-data-public.querysuggestiontypes.md) | | | [SearchSessionState](./kibana-plugin-plugins-data-public.searchsessionstate.md) | Possible state that current session can be in | @@ -107,7 +105,7 @@ | [AggGroupNames](./kibana-plugin-plugins-data-public.agggroupnames.md) | | | [APPLY\_FILTER\_TRIGGER](./kibana-plugin-plugins-data-public.apply_filter_trigger.md) | | | [baseFormattersPublic](./kibana-plugin-plugins-data-public.baseformatterspublic.md) | | -| [castEsToKbnFieldTypeName](./kibana-plugin-plugins-data-public.castestokbnfieldtypename.md) | Get the KbnFieldType name for an esType string | +| [castEsToKbnFieldTypeName](./kibana-plugin-plugins-data-public.castestokbnfieldtypename.md) | | | [connectToQueryState](./kibana-plugin-plugins-data-public.connecttoquerystate.md) | Helper to setup two-way syncing of global data and a state container | | [createSavedQueryService](./kibana-plugin-plugins-data-public.createsavedqueryservice.md) | | | [ES\_SEARCH\_STRATEGY](./kibana-plugin-plugins-data-public.es_search_strategy.md) | | @@ -120,7 +118,7 @@ | [fieldList](./kibana-plugin-plugins-data-public.fieldlist.md) | | | [FilterItem](./kibana-plugin-plugins-data-public.filteritem.md) | | | [FilterLabel](./kibana-plugin-plugins-data-public.filterlabel.md) | | -| [getKbnTypeNames](./kibana-plugin-plugins-data-public.getkbntypenames.md) | Get the esTypes known by all kbnFieldTypes {Array} | +| [getKbnTypeNames](./kibana-plugin-plugins-data-public.getkbntypenames.md) | | | [INDEX\_PATTERN\_SAVED\_OBJECT\_TYPE](./kibana-plugin-plugins-data-public.index_pattern_saved_object_type.md) | \* | | [indexPatterns](./kibana-plugin-plugins-data-public.indexpatterns.md) | | | [injectSearchSourceReferences](./kibana-plugin-plugins-data-public.injectsearchsourcereferences.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.buildqueryfromfilters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.buildqueryfromfilters.md index 90fdb471d6280..fba0d9fe62c78 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.buildqueryfromfilters.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.buildqueryfromfilters.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> Please import from the package kbn/es-query directly. This import will be deprecated in v8.0.0. +> Please import from the package kbn/es-query directly. This import will be removed in v8.0.0. > Signature: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.castestokbnfieldtypename.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.castestokbnfieldtypename.md index 037c938415978..834fe302ad9f8 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.castestokbnfieldtypename.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.castestokbnfieldtypename.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> import from "@kbn/field-types" instead +> Import from the "@kbn/field-types" package directly instead. 8.0 > Signature: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md index b807fe7d66369..b37d0555194fc 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esfilters.md @@ -13,8 +13,8 @@ esFilters: { buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("@kbn/es-query").Filter; buildExistsFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").ExistsFilter; buildFilter: typeof import("@kbn/es-query").buildFilter; - buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: any, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; - buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: any[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; + buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: string | number | boolean, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; + buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: string[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; buildRangeFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: import("@kbn/es-query").RangeFilterParams, indexPattern: import("@kbn/es-query").IndexPatternBase, formattedValue?: string | undefined) => import("@kbn/es-query").RangeFilter; isFilterDisabled: (filter: import("@kbn/es-query").Filter) => boolean; } diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esqueryconfig.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esqueryconfig.md index 5c736f40cdbf4..6d8383ead501f 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esqueryconfig.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.esqueryconfig.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> Please import from the package kbn/es-query directly. This import will be deprecated in v8.0.0. +> Please import from the package kbn/es-query directly. This import will be removed in v8.0.0. > Signature: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md index f46ff36277d93..e7f3777cd2d3f 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> Please import from the package kbn/es-query directly. This import will be deprecated in v8.0.0. +> Please import from the package kbn/es-query directly. This import will be removed in v8.0.0. > Signature: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldsubtype.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldsubtype.md index e8e872577b46b..07a59d41fb0a3 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldsubtype.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.ifieldsubtype.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> Please import from the package kbn/es-query directly. This import will be deprecated in v8.0.0. +> Please import from the package kbn/es-query directly. This import will be removed in v8.0.0. > Signature: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kuerynode.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kuerynode.md index a5c14ee8627b1..28d64cf9e17da 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kuerynode.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.kuerynode.md @@ -6,7 +6,7 @@ > Warning: This API is now obsolete. > -> Please import from the package kbn/es-query directly. This import will be deprecated in v8.0.0. +> Please import from the package kbn/es-query directly. This import will be removed in v8.0.0. > Signature: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index ea0a6f8c64be0..755fa33bfe506 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -22,8 +22,6 @@ | Enumeration | Description | | --- | --- | | [BUCKET\_TYPES](./kibana-plugin-plugins-data-server.bucket_types.md) | | -| [ES\_FIELD\_TYPES](./kibana-plugin-plugins-data-server.es_field_types.md) | \* | -| [KBN\_FIELD\_TYPES](./kibana-plugin-plugins-data-server.kbn_field_types.md) | \* | | [METRIC\_TYPES](./kibana-plugin-plugins-data-server.metric_types.md) | | ## Functions @@ -74,7 +72,7 @@ | [AggGroupLabels](./kibana-plugin-plugins-data-server.agggrouplabels.md) | | | [AggGroupNames](./kibana-plugin-plugins-data-server.agggroupnames.md) | | | [buildQueryFromFilters](./kibana-plugin-plugins-data-server.buildqueryfromfilters.md) | | -| [castEsToKbnFieldTypeName](./kibana-plugin-plugins-data-server.castestokbnfieldtypename.md) | Get the KbnFieldType name for an esType string | +| [castEsToKbnFieldTypeName](./kibana-plugin-plugins-data-server.castestokbnfieldtypename.md) | | | [config](./kibana-plugin-plugins-data-server.config.md) | | | [ES\_SEARCH\_STRATEGY](./kibana-plugin-plugins-data-server.es_search_strategy.md) | | | [esFilters](./kibana-plugin-plugins-data-server.esfilters.md) | | diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index f3ca30eb860e8..23f79c1bbb480 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -514,6 +514,10 @@ Shows the Timelion tutorial to users when they first open the Timelion app. Used for calculating automatic intervals in visualizations, this is the number of buckets to try to represent. +[[timelion-legacyChartsLibrary]]`timelion:legacyChartsLibrary`:: +Enables the legacy charts library for timelion charts in Visualize. + + [float] [[kibana-visualization-settings]] ==== Visualization diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 2ae5160069f0a..4cd26dbc13e4d 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -18,6 +18,7 @@ If you are using an *on-premises* Elastic Stack deployment: If you are using an *on-premises* Elastic Stack deployment with <>: * You must enable Transport Layer Security (TLS) for communication <>. {kib} alerting uses <> to secure background rule checks and actions, and API keys require {ref}/configuring-tls.html#tls-http[TLS on the HTTP interface]. A proxy will not suffice. +* If you have enabled TLS and are still unable to access Alerting, ensure that you have not {ref}/security-settings.html#api-key-service-settings[explicitly disabled API keys]. [float] [[alerting-setup-production]] diff --git a/packages/kbn-es-query/src/es_query/build_es_query.ts b/packages/kbn-es-query/src/es_query/build_es_query.ts index e8a494ec1b8e4..955af1e4c185f 100644 --- a/packages/kbn-es-query/src/es_query/build_es_query.ts +++ b/packages/kbn-es-query/src/es_query/build_es_query.ts @@ -13,6 +13,10 @@ import { buildQueryFromLucene } from './from_lucene'; import { Filter, Query } from '../filters'; import { IndexPatternBase } from './types'; +/** + * Configurations to be used while constructing an ES query. + * @public + */ export interface EsQueryConfig { allowLeadingWildcards: boolean; queryStringOptions: Record; @@ -33,6 +37,8 @@ function removeMatchAll(filters: T[]) { * @param config - an objects with query:allowLeadingWildcards and query:queryString:options UI * settings in form of { allowLeadingWildcards, queryStringOptions } * config contains dateformat:tz + * + * @public */ export function buildEsQuery( indexPattern: IndexPatternBase | undefined, @@ -47,7 +53,7 @@ export function buildEsQuery( queries = Array.isArray(queries) ? queries : [queries]; filters = Array.isArray(filters) ? filters : [filters]; - const validQueries = queries.filter((query) => has(query, 'query')); + const validQueries = queries.filter((query: any) => has(query, 'query')); const queriesByLanguage = groupBy(validQueries, 'language'); const kueryQuery = buildQueryFromKuery( indexPattern, diff --git a/packages/kbn-es-query/src/es_query/decorate_query.ts b/packages/kbn-es-query/src/es_query/decorate_query.ts index 594e046a979de..b6623b9b1946c 100644 --- a/packages/kbn-es-query/src/es_query/decorate_query.ts +++ b/packages/kbn-es-query/src/es_query/decorate_query.ts @@ -16,6 +16,8 @@ import { DslQuery, isEsQueryString } from './es_query_dsl'; * @param queryStringOptions query:queryString:options from UI settings * @param dateFormatTZ dateFormat:tz from UI settings * @returns {object} + * + * @public */ export function decorateQuery( diff --git a/packages/kbn-es-query/src/es_query/es_query_dsl.ts b/packages/kbn-es-query/src/es_query/es_query_dsl.ts index 30f7693ee2a94..6cff8b0ff47c7 100644 --- a/packages/kbn-es-query/src/es_query/es_query_dsl.ts +++ b/packages/kbn-es-query/src/es_query/es_query_dsl.ts @@ -43,6 +43,9 @@ export interface DslTermQuery { term: Record; } +/** + * @public + */ export type DslQuery = | DslRangeQuery | DslMatchQuery @@ -50,5 +53,6 @@ export type DslQuery = | DslMatchAllQuery | DslTermQuery; +/** @internal */ export const isEsQueryString = (query: any): query is DslQueryStringQuery => has(query, 'query_string.query'); diff --git a/packages/kbn-es-query/src/es_query/filter_matches_index.ts b/packages/kbn-es-query/src/es_query/filter_matches_index.ts index 7df719533486a..541298ee0e19b 100644 --- a/packages/kbn-es-query/src/es_query/filter_matches_index.ts +++ b/packages/kbn-es-query/src/es_query/filter_matches_index.ts @@ -13,6 +13,8 @@ import { IndexPatternBase } from '..'; * TODO: We should base this on something better than `filter.meta.key`. We should probably modify * this to check if `filter.meta.index` matches `indexPattern.id` instead, but that's a breaking * change. + * + * @internal */ export function filterMatchesIndex(filter: Filter, indexPattern?: IndexPatternBase | null) { if (!filter.meta?.key || !indexPattern) { diff --git a/packages/kbn-es-query/src/es_query/from_filters.ts b/packages/kbn-es-query/src/es_query/from_filters.ts index 7b3c58d45a569..94def4008a2bc 100644 --- a/packages/kbn-es-query/src/es_query/from_filters.ts +++ b/packages/kbn-es-query/src/es_query/from_filters.ts @@ -43,6 +43,14 @@ const translateToQuery = (filter: Filter) => { return filter; }; +/** + * @param filters + * @param indexPattern + * @param ignoreFilterIfFieldNotInIndex by default filters that use fields that can't be found in the specified index pattern are not applied. Set this to true if you want to apply them any way. + * @returns An EQL query + * + * @public + */ export const buildQueryFromFilters = ( filters: Filter[] = [], indexPattern: IndexPatternBase | undefined, diff --git a/packages/kbn-es-query/src/es_query/from_kuery.ts b/packages/kbn-es-query/src/es_query/from_kuery.ts index efe8b26a81412..87382585181f8 100644 --- a/packages/kbn-es-query/src/es_query/from_kuery.ts +++ b/packages/kbn-es-query/src/es_query/from_kuery.ts @@ -10,6 +10,7 @@ import { Query } from '../filters'; import { fromKueryExpression, toElasticsearchQuery, nodeTypes, KueryNode } from '../kuery'; import { IndexPatternBase } from './types'; +/** @internal */ export function buildQueryFromKuery( indexPattern: IndexPatternBase | undefined, queries: Query[] = [], diff --git a/packages/kbn-es-query/src/es_query/from_lucene.ts b/packages/kbn-es-query/src/es_query/from_lucene.ts index cba789513c983..ef4becd1d1584 100644 --- a/packages/kbn-es-query/src/es_query/from_lucene.ts +++ b/packages/kbn-es-query/src/es_query/from_lucene.ts @@ -10,6 +10,7 @@ import { Query } from '..'; import { decorateQuery } from './decorate_query'; import { luceneStringToDsl } from './lucene_string_to_dsl'; +/** @internal */ export function buildQueryFromLucene( queries: Query[], queryStringOptions: Record, diff --git a/packages/kbn-es-query/src/es_query/handle_nested_filter.ts b/packages/kbn-es-query/src/es_query/handle_nested_filter.ts index 60e92769503fb..74c758c1c54bf 100644 --- a/packages/kbn-es-query/src/es_query/handle_nested_filter.ts +++ b/packages/kbn-es-query/src/es_query/handle_nested_filter.ts @@ -9,6 +9,7 @@ import { getFilterField, cleanFilter, Filter } from '../filters'; import { IndexPatternBase } from './types'; +/** @internal */ export const handleNestedFilter = (filter: Filter, indexPattern?: IndexPatternBase) => { if (!indexPattern) return filter; diff --git a/packages/kbn-es-query/src/es_query/lucene_string_to_dsl.ts b/packages/kbn-es-query/src/es_query/lucene_string_to_dsl.ts index 97160bd645797..2e4eb5ab7f7c4 100644 --- a/packages/kbn-es-query/src/es_query/lucene_string_to_dsl.ts +++ b/packages/kbn-es-query/src/es_query/lucene_string_to_dsl.ts @@ -9,6 +9,13 @@ import { isString } from 'lodash'; import { DslQuery } from './es_query_dsl'; +/** + * + * @param query + * @returns + * + * @public + */ export function luceneStringToDsl(query: string | any): DslQuery { if (isString(query)) { if (query.trim() === '') { diff --git a/packages/kbn-es-query/src/es_query/migrate_filter.ts b/packages/kbn-es-query/src/es_query/migrate_filter.ts index 9bd78b092fc18..5edab3e042f5c 100644 --- a/packages/kbn-es-query/src/es_query/migrate_filter.ts +++ b/packages/kbn-es-query/src/es_query/migrate_filter.ts @@ -11,6 +11,7 @@ import { getConvertedValueForField } from '../filters'; import { Filter } from '../filters'; import { IndexPatternBase } from './types'; +/** @internal */ export interface DeprecatedMatchPhraseFilter extends Filter { query: { match: { @@ -28,6 +29,7 @@ function isDeprecatedMatchPhraseFilter(filter: any): filter is DeprecatedMatchPh return Boolean(fieldName && get(filter, ['query', 'match', fieldName, 'type']) === 'phrase'); } +/** @internal */ export function migrateFilter(filter: Filter, indexPattern?: IndexPatternBase) { if (isDeprecatedMatchPhraseFilter(filter)) { const fieldName = Object.keys(filter.query.match)[0]; diff --git a/packages/kbn-es-query/src/es_query/types.ts b/packages/kbn-es-query/src/es_query/types.ts index ca6a542779053..d68d9e4a4da22 100644 --- a/packages/kbn-es-query/src/es_query/types.ts +++ b/packages/kbn-es-query/src/es_query/types.ts @@ -8,10 +8,19 @@ import type { estypes } from '@elastic/elasticsearch'; +/** + * A field's sub type + * @public + */ export interface IFieldSubType { multi?: { parent: string }; nested?: { path: string }; } + +/** + * A base interface for an index pattern field + * @public + */ export interface IndexPatternFieldBase { name: string; /** @@ -31,6 +40,10 @@ export interface IndexPatternFieldBase { scripted?: boolean; } +/** + * A base interface for an index pattern + * @public + */ export interface IndexPatternBase { fields: IndexPatternFieldBase[]; id?: string; diff --git a/packages/kbn-es-query/src/filters/build_filters/build_empty_filter.ts b/packages/kbn-es-query/src/filters/build_filters/build_empty_filter.ts index a7945da5829a9..09b58bb15147f 100644 --- a/packages/kbn-es-query/src/filters/build_filters/build_empty_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/build_empty_filter.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Filter, FilterMeta, FilterState, FilterStateStore } from './types'; +import { Filter, FilterMeta, FilterStateStore } from './types'; export const buildEmptyFilter = (isPinned: boolean, index?: string): Filter => { const meta: FilterMeta = { @@ -15,7 +15,7 @@ export const buildEmptyFilter = (isPinned: boolean, index?: string): Filter => { alias: null, index, }; - const $state: FilterState = { + const $state: Filter['$state'] = { store: isPinned ? FilterStateStore.GLOBAL_STATE : FilterStateStore.APP_STATE, }; diff --git a/packages/kbn-es-query/src/filters/build_filters/build_filters.ts b/packages/kbn-es-query/src/filters/build_filters/build_filters.ts index 9beee96fd2ee2..eba7a6a26c545 100644 --- a/packages/kbn-es-query/src/filters/build_filters/build_filters.ts +++ b/packages/kbn-es-query/src/filters/build_filters/build_filters.ts @@ -14,8 +14,22 @@ import { buildRangeFilter } from './range_filter'; import { buildExistsFilter } from './exists_filter'; import type { IndexPatternFieldBase, IndexPatternBase } from '../../es_query'; -import { FilterMeta, FilterStateStore } from './types'; +import { FilterStateStore } from './types'; +/** + * + * @param indexPattern + * @param field + * @param type + * @param negate whether the filter is negated (NOT filter) + * @param disabled whether the filter is disabled andwon't be applied to searches + * @param params + * @param alias a display name for the filter + * @param store whether the filter applies to the current application or should be applied to global context + * @returns + * + * @public + */ export function buildFilter( indexPattern: IndexPatternBase, field: IndexPatternFieldBase, @@ -36,26 +50,6 @@ export function buildFilter( return filter; } -export function buildCustomFilter( - indexPatternString: string, - queryDsl: any, - disabled: boolean, - negate: boolean, - alias: string | null, - store: FilterStateStore -): Filter { - const meta: FilterMeta = { - index: indexPatternString, - type: FILTERS.CUSTOM, - disabled, - negate, - alias, - }; - const filter: Filter = { ...queryDsl, meta }; - filter.$state = { store }; - return filter; -} - function buildBaseFilter( indexPattern: IndexPatternBase, field: IndexPatternFieldBase, diff --git a/packages/kbn-es-query/src/filters/build_filters/custom_filter.ts b/packages/kbn-es-query/src/filters/build_filters/custom_filter.ts index 0dc1fc86d25a1..60a128d58fee3 100644 --- a/packages/kbn-es-query/src/filters/build_filters/custom_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/custom_filter.ts @@ -6,8 +6,41 @@ * Side Public License, v 1. */ -import type { Filter } from './types'; +import { Filter, FilterMeta, FILTERS, FilterStateStore } from './types'; +/** @public */ export type CustomFilter = Filter & { query: any; }; + +/** + * + * @param indexPatternString + * @param queryDsl + * @param disabled + * @param negate + * @param alias + * @param store + * @returns + * + * @public + */ +export function buildCustomFilter( + indexPatternString: string, + queryDsl: any, + disabled: boolean, + negate: boolean, + alias: string | null, + store: FilterStateStore +): Filter { + const meta: FilterMeta = { + index: indexPatternString, + type: FILTERS.CUSTOM, + disabled, + negate, + alias, + }; + const filter: Filter = { ...queryDsl, meta }; + filter.$state = { store }; + return filter; +} diff --git a/packages/kbn-es-query/src/filters/build_filters/exists_filter.ts b/packages/kbn-es-query/src/filters/build_filters/exists_filter.ts index fdb4ab548fdc5..3a8935d057f4a 100644 --- a/packages/kbn-es-query/src/filters/build_filters/exists_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/exists_filter.ts @@ -6,26 +6,42 @@ * Side Public License, v 1. */ +import { has } from 'lodash'; import type { IndexPatternFieldBase, IndexPatternBase } from '../../es_query'; -import type { Filter, FilterMeta } from './types'; - -export type ExistsFilterMeta = FilterMeta; - -export interface FilterExistsProperty { - field: any; -} +import type { FieldFilter, Filter, FilterMeta } from './types'; +/** @public */ export type ExistsFilter = Filter & { - meta: ExistsFilterMeta; - exists?: FilterExistsProperty; + meta: FilterMeta; + exists?: { + field: string; + }; }; -export const isExistsFilter = (filter: any): filter is ExistsFilter => filter && filter.exists; +/** + * @param filter + * @returns `true` if a filter is an `ExistsFilter` + * + * @public + */ +export const isExistsFilter = (filter: FieldFilter): filter is ExistsFilter => + has(filter, 'exists'); +/** + * @internal + */ export const getExistsFilterField = (filter: ExistsFilter) => { return filter.exists && filter.exists.field; }; +/** + * Builds an `ExistsFilter` + * @param field field to validate the existence of + * @param indexPattern index pattern to look for the field in + * @returns An `ExistsFilter` + * + * @public + */ export const buildExistsFilter = (field: IndexPatternFieldBase, indexPattern: IndexPatternBase) => { return { meta: { diff --git a/packages/kbn-es-query/src/filters/build_filters/geo_bounding_box_filter.ts b/packages/kbn-es-query/src/filters/build_filters/geo_bounding_box_filter.ts index 323c7108ea3e5..9066b695c17fc 100644 --- a/packages/kbn-es-query/src/filters/build_filters/geo_bounding_box_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/geo_bounding_box_filter.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import type { Filter, FilterMeta, LatLon } from './types'; +import { has } from 'lodash'; +import type { FieldFilter, Filter, FilterMeta, LatLon } from './types'; export type GeoBoundingBoxFilterMeta = FilterMeta & { params: { @@ -20,9 +21,18 @@ export type GeoBoundingBoxFilter = Filter & { geo_bounding_box: any; }; -export const isGeoBoundingBoxFilter = (filter: any): filter is GeoBoundingBoxFilter => - filter && filter.geo_bounding_box; +/** + * @param filter + * @returns `true` if a filter is an `GeoBoundingBoxFilter` + * + * @public + */ +export const isGeoBoundingBoxFilter = (filter: FieldFilter): filter is GeoBoundingBoxFilter => + has(filter, 'geo_bounding_box'); +/** + * @internal + */ export const getGeoBoundingBoxFilterField = (filter: GeoBoundingBoxFilter) => { return ( filter.geo_bounding_box && diff --git a/packages/kbn-es-query/src/filters/build_filters/geo_polygon_filter.ts b/packages/kbn-es-query/src/filters/build_filters/geo_polygon_filter.ts index d25f2a87dcd8c..edeccdcf28b26 100644 --- a/packages/kbn-es-query/src/filters/build_filters/geo_polygon_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/geo_polygon_filter.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import type { Filter, FilterMeta, LatLon } from './types'; +import { has } from 'lodash'; +import type { FieldFilter, Filter, FilterMeta, LatLon } from './types'; export type GeoPolygonFilterMeta = FilterMeta & { params: { @@ -19,9 +20,18 @@ export type GeoPolygonFilter = Filter & { geo_polygon: any; }; -export const isGeoPolygonFilter = (filter: any): filter is GeoPolygonFilter => - filter && filter.geo_polygon; +/** + * @param filter + * @returns `true` if a filter is an `GeoPolygonFilter` + * + * @public + */ +export const isGeoPolygonFilter = (filter: FieldFilter): filter is GeoPolygonFilter => + has(filter, 'geo_polygon'); +/** + * @internal + */ export const getGeoPolygonFilterField = (filter: GeoPolygonFilter) => { return ( filter.geo_polygon && Object.keys(filter.geo_polygon).find((key) => key !== 'ignore_unmapped') diff --git a/packages/kbn-es-query/src/filters/build_filters/get_filter_field.ts b/packages/kbn-es-query/src/filters/build_filters/get_filter_field.ts index a949c0f7cd5b1..4ebed12e1237e 100644 --- a/packages/kbn-es-query/src/filters/build_filters/get_filter_field.ts +++ b/packages/kbn-es-query/src/filters/build_filters/get_filter_field.ts @@ -15,6 +15,7 @@ import { getPhraseFilterField, isPhraseFilter } from './phrase_filter'; import { getRangeFilterField, isRangeFilter } from './range_filter'; import type { Filter } from './types'; +/** @internal */ export const getFilterField = (filter: Filter) => { if (isExistsFilter(filter)) { return getExistsFilterField(filter); diff --git a/packages/kbn-es-query/src/filters/build_filters/get_filter_params.ts b/packages/kbn-es-query/src/filters/build_filters/get_filter_params.ts index 65a586db39789..46585acd9efc0 100644 --- a/packages/kbn-es-query/src/filters/build_filters/get_filter_params.ts +++ b/packages/kbn-es-query/src/filters/build_filters/get_filter_params.ts @@ -11,6 +11,9 @@ import type { PhraseFilter } from './phrase_filter'; import type { RangeFilter } from './range_filter'; import { Filter, FILTERS } from './types'; +/** + * @internal used only by the filter bar to create filter pills. + */ export function getFilterParams(filter: Filter) { switch (filter.meta.type) { case FILTERS.PHRASE: diff --git a/packages/kbn-es-query/src/filters/build_filters/match_all_filter.ts b/packages/kbn-es-query/src/filters/build_filters/match_all_filter.ts index 1a3fae4181379..b1b134313b746 100644 --- a/packages/kbn-es-query/src/filters/build_filters/match_all_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/match_all_filter.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ -import type { Filter, FilterMeta } from './types'; +import { has } from 'lodash'; +import type { FieldFilter, Filter, FilterMeta } from './types'; export interface MatchAllFilterMeta extends FilterMeta { - field: any; + field: string; formattedValue: string; } @@ -18,5 +19,11 @@ export type MatchAllFilter = Filter & { match_all: any; }; -export const isMatchAllFilter = (filter: any): filter is MatchAllFilter => - filter && filter.match_all; +/** + * @param filter + * @returns `true` if a filter is an `MatchAllFilter` + * + * @public + */ +export const isMatchAllFilter = (filter: FieldFilter): filter is MatchAllFilter => + has(filter, 'match_all'); diff --git a/packages/kbn-es-query/src/filters/build_filters/missing_filter.ts b/packages/kbn-es-query/src/filters/build_filters/missing_filter.ts index e52b7753c13a4..8f0364a565d79 100644 --- a/packages/kbn-es-query/src/filters/build_filters/missing_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/missing_filter.ts @@ -6,17 +6,30 @@ * Side Public License, v 1. */ -import type { Filter, FilterMeta } from './types'; +import { has } from 'lodash'; +import type { FieldFilter, Filter, FilterMeta } from './types'; export type MissingFilterMeta = FilterMeta; export type MissingFilter = Filter & { meta: MissingFilterMeta; - missing: any; + missing: { + field: string; + }; }; -export const isMissingFilter = (filter: any): filter is MissingFilter => filter && filter.missing; +/** + * @param filter + * @returns `true` if a filter is an `MissingFilter` + * + * @public + */ +export const isMissingFilter = (filter: FieldFilter): filter is MissingFilter => + has(filter, 'missing'); +/** + * @internal + */ export const getMissingFilterField = (filter: MissingFilter) => { return filter.missing && filter.missing.field; }; diff --git a/packages/kbn-es-query/src/filters/build_filters/phrase_filter.ts b/packages/kbn-es-query/src/filters/build_filters/phrase_filter.ts index bc60043d0887e..7b60548f912ba 100644 --- a/packages/kbn-es-query/src/filters/build_filters/phrase_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/phrase_filter.ts @@ -6,33 +6,39 @@ * Side Public License, v 1. */ import type { estypes } from '@elastic/elasticsearch'; -import { get, isPlainObject } from 'lodash'; -import type { Filter, FilterMeta } from './types'; +import { has, isPlainObject } from 'lodash'; +import type { FieldFilter, Filter, FilterMeta } from './types'; import type { IndexPatternFieldBase, IndexPatternBase } from '../../es_query'; import { getConvertedValueForField } from './get_converted_value_for_field'; +type PhraseFilterValue = string | number | boolean; + export type PhraseFilterMeta = FilterMeta & { params?: { - query: string; // The unformatted value + query: PhraseFilterValue; // The unformatted value }; - field?: any; - index?: any; + field?: string; + index?: string; }; export type PhraseFilter = Filter & { meta: PhraseFilterMeta; script?: { script: { - source?: any; + source?: string; lang?: estypes.ScriptLanguage; - params: any; + params: { [key: string]: PhraseFilterValue }; }; }; }; -type PhraseFilterValue = string | number | boolean; - -export const isPhraseFilter = (filter: any): filter is PhraseFilter => { +/** + * @param filter + * @returns `true` if a filter is a `PhraseFilter` + * + * @public + */ +export const isPhraseFilter = (filter: FieldFilter): filter is PhraseFilter => { const isMatchPhraseQuery = filter && filter.query && filter.query.match_phrase; const isDeprecatedMatchPhraseQuery = @@ -44,23 +50,42 @@ export const isPhraseFilter = (filter: any): filter is PhraseFilter => { return Boolean(isMatchPhraseQuery || isDeprecatedMatchPhraseQuery); }; -export const isScriptedPhraseFilter = (filter: any): filter is PhraseFilter => - Boolean(get(filter, 'script.script.params.value')); +/** + * @param filter + * @returns `true` if a filter is a scripted `PhrasesFilter` + * + * @public + */ +export const isScriptedPhraseFilter = (filter: FieldFilter): filter is PhraseFilter => + has(filter, 'script.script.params.value'); +/** @internal */ export const getPhraseFilterField = (filter: PhraseFilter) => { const queryConfig = filter.query.match_phrase || filter.query.match; return Object.keys(queryConfig)[0]; }; +/** + * @internal + */ export const getPhraseFilterValue = (filter: PhraseFilter): PhraseFilterValue => { const queryConfig = filter.query.match_phrase || filter.query.match; const queryValue = Object.values(queryConfig)[0] as any; return isPlainObject(queryValue) ? queryValue.query : queryValue; }; +/** + * Creates a filter where the given field matches a given value + * @param field + * @param params + * @param indexPattern + * @returns `PhraseFilter` + * + * @public + */ export const buildPhraseFilter = ( field: IndexPatternFieldBase, - value: any, + value: PhraseFilterValue, indexPattern: IndexPatternBase ): PhraseFilter => { const convertedValue = getConvertedValueForField(field, value); @@ -82,7 +107,8 @@ export const buildPhraseFilter = ( } }; -export const getPhraseScript = (field: IndexPatternFieldBase, value: string) => { +/** @internal */ +export const getPhraseScript = (field: IndexPatternFieldBase, value: PhraseFilterValue) => { const convertedValue = getConvertedValueForField(field, value); const script = buildInlineScriptForPhraseFilter(field); diff --git a/packages/kbn-es-query/src/filters/build_filters/phrases_filter.ts b/packages/kbn-es-query/src/filters/build_filters/phrases_filter.ts index 6f8c3815a5c63..526d3802337ea 100644 --- a/packages/kbn-es-query/src/filters/build_filters/phrases_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/phrases_filter.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Filter, FilterMeta, FILTERS } from './types'; +import { FieldFilter, Filter, FilterMeta, FILTERS } from './types'; import { getPhraseScript } from './phrase_filter'; import type { IndexPatternFieldBase, IndexPatternBase } from '../../es_query'; @@ -19,20 +19,35 @@ export type PhrasesFilter = Filter & { meta: PhrasesFilterMeta; }; -export const isPhrasesFilter = (filter: any): filter is PhrasesFilter => +/** + * @param filter + * @returns `true` if a filter is a `PhrasesFilter` + * + * @public + */ +export const isPhrasesFilter = (filter: FieldFilter): filter is PhrasesFilter => filter?.meta?.type === FILTERS.PHRASES; +/** @internal */ export const getPhrasesFilterField = (filter: PhrasesFilter) => { // Phrases is a newer filter type that has always been created via a constructor that ensures // `meta.key` is set to the field name return filter.meta.key; }; -// Creates a filter where the given field matches one or more of the given values -// params should be an array of values +/** + * Creates a filter where the given field matches one or more of the given values + * params should be an array of values + * @param field + * @param params + * @param indexPattern + * @returns + * + * @public + */ export const buildPhrasesFilter = ( field: IndexPatternFieldBase, - params: any[], + params: string[], indexPattern: IndexPatternBase ) => { const index = indexPattern.id; @@ -41,11 +56,11 @@ export const buildPhrasesFilter = ( let should; if (field.scripted) { - should = params.map((v: any) => ({ + should = params.map((v) => ({ script: getPhraseScript(field, v), })); } else { - should = params.map((v: any) => ({ + should = params.map((v) => ({ match_phrase: { [field.name]: v, }, diff --git a/packages/kbn-es-query/src/filters/build_filters/query_string_filter.ts b/packages/kbn-es-query/src/filters/build_filters/query_string_filter.ts index 4fa084f181827..c083775b8db44 100644 --- a/packages/kbn-es-query/src/filters/build_filters/query_string_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/query_string_filter.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import type { Filter, FilterMeta } from './types'; +import { has } from 'lodash'; +import type { FieldFilter, Filter, FilterMeta } from './types'; export type QueryStringFilterMeta = FilterMeta; @@ -19,10 +20,24 @@ export type QueryStringFilter = Filter & { }; }; -export const isQueryStringFilter = (filter: any): filter is QueryStringFilter => - filter && filter.query && filter.query.query_string; +/** + * @param filter + * @returns `true` if a filter is a `QueryStringFilter` + * + * @public + */ +export const isQueryStringFilter = (filter: FieldFilter): filter is QueryStringFilter => + has(filter, 'query.query_string'); -// Creates a filter corresponding to a raw Elasticsearch query DSL object +/** + * Creates a filter corresponding to a raw Elasticsearch query DSL object + * @param query + * @param index + * @param alias + * @returns `QueryStringFilter` + * + * @public + */ export const buildQueryFilter = (query: QueryStringFilter['query'], index: string, alias: string) => ({ query, diff --git a/packages/kbn-es-query/src/filters/build_filters/range_filter.ts b/packages/kbn-es-query/src/filters/build_filters/range_filter.ts index 6ed560ccc831e..ae26c6ae48db8 100644 --- a/packages/kbn-es-query/src/filters/build_filters/range_filter.ts +++ b/packages/kbn-es-query/src/filters/build_filters/range_filter.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ import type { estypes } from '@elastic/elasticsearch'; -import { map, reduce, mapValues, get, keys, pickBy } from 'lodash'; -import type { Filter, FilterMeta } from './types'; +import { map, reduce, mapValues, has, get, keys, pickBy } from 'lodash'; +import type { FieldFilter, Filter, FilterMeta } from './types'; import type { IndexPatternBase, IndexPatternFieldBase } from '../../es_query'; const OPERANDS_IN_RANGE = 2; @@ -32,6 +32,10 @@ const dateComparators = { lt: 'boolean lt(Supplier s, def v) {return s.get().toInstant().isBefore(Instant.parse(v))}', }; +/** + * An interface for all possible range filter params + * @public + */ export interface RangeFilterParams { from?: number | string; to?: number | string; @@ -49,7 +53,7 @@ const hasRangeKeys = (params: RangeFilterParams) => export type RangeFilterMeta = FilterMeta & { params: RangeFilterParams; - field?: any; + field?: string; formattedValue?: string; }; @@ -57,6 +61,9 @@ export interface EsRangeFilter { range: { [key: string]: RangeFilterParams }; } +/** + * @public + */ export type RangeFilter = Filter & EsRangeFilter & { meta: RangeFilterMeta; @@ -64,20 +71,36 @@ export type RangeFilter = Filter & script: { params: any; lang: estypes.ScriptLanguage; - source: any; + source: string; }; }; match_all?: any; }; -export const isRangeFilter = (filter: any): filter is RangeFilter => filter && filter.range; - -export const isScriptedRangeFilter = (filter: any): filter is RangeFilter => { +/** + * @param filter + * @returns `true` if a filter is an `RangeFilter` + * + * @public + */ +export const isRangeFilter = (filter?: FieldFilter): filter is RangeFilter => has(filter, 'range'); + +/** + * + * @param filter + * @returns `true` if a filter is a scripted `RangeFilter` + * + * @public + */ +export const isScriptedRangeFilter = (filter: FieldFilter): filter is RangeFilter => { const params: RangeFilterParams = get(filter, 'script.script.params', {}); return hasRangeKeys(params); }; +/** + * @internal + */ export const getRangeFilterField = (filter: RangeFilter) => { return filter.range && Object.keys(filter.range)[0]; }; @@ -85,8 +108,18 @@ export const getRangeFilterField = (filter: RangeFilter) => { const formatValue = (params: any[]) => map(params, (val: any, key: string) => get(operators, key) + val).join(' '); -// Creates a filter where the value for the given field is in the given range -// params should be an object containing `lt`, `lte`, `gt`, and/or `gte` +/** + * Creates a filter where the value for the given field is in the given range + * params should be an object containing `lt`, `lte`, `gt`, and/or `gte` + * + * @param field + * @param params + * @param indexPattern + * @param formattedValue + * @returns + * + * @public + */ export const buildRangeFilter = ( field: IndexPatternFieldBase, params: RangeFilterParams, @@ -134,6 +167,9 @@ export const buildRangeFilter = ( return filter as RangeFilter; }; +/** + * @internal + */ export const getRangeScript = (field: IndexPatternFieldBase, params: RangeFilterParams) => { const knownParams = mapValues( pickBy(params, (val, key: any) => key in operators), diff --git a/packages/kbn-es-query/src/filters/build_filters/types.ts b/packages/kbn-es-query/src/filters/build_filters/types.ts index 13e4a941b9166..4bf53303c1544 100644 --- a/packages/kbn-es-query/src/filters/build_filters/types.ts +++ b/packages/kbn-es-query/src/filters/build_filters/types.ts @@ -15,7 +15,10 @@ import { RangeFilter } from './range_filter'; import { MatchAllFilter } from './match_all_filter'; import { MissingFilter } from './missing_filter'; -// Any filter associated with a field (used in the filter bar/editor) +/** + * A common type for filters supported by this package + * @public + **/ export type FieldFilter = | ExistsFilter | GeoBoundingBoxFilter @@ -26,6 +29,16 @@ export type FieldFilter = | MatchAllFilter | MissingFilter; +/** + * A common type for filters supported by this package + * @public + **/ +export type FilterParams = any; + +/** + * An enum of all types of filters supported by this package + * @public + */ export enum FILTERS { CUSTOM = 'custom', PHRASES = 'phrases', @@ -41,16 +54,15 @@ export enum FILTERS { SPATIAL_FILTER = 'spatial_filter', } +/** + * An enum to denote whether a filter is specific to an application's context or whether it should be applied globally. + * @public + */ export enum FilterStateStore { APP_STATE = 'appState', GLOBAL_STATE = 'globalState', } -// eslint-disable-next-line -export type FilterState = { - store: FilterStateStore; -}; - // eslint-disable-next-line export type FilterMeta = { alias: string | null; @@ -69,7 +81,9 @@ export type FilterMeta = { // eslint-disable-next-line export type Filter = { - $state?: FilterState; + $state?: { + store: FilterStateStore; + }; meta: FilterMeta; query?: any; // TODO: can we use the Query type her? }; @@ -80,6 +94,10 @@ export type Query = { language: string; }; +/** + * An interface for a latitude-longitude pair + * @public + */ export interface LatLon { lat: number; lon: number; diff --git a/packages/kbn-es-query/src/filters/helpers/compare_filters.ts b/packages/kbn-es-query/src/filters/helpers/compare_filters.ts index 1ca9143247eea..ffc7461bc6cdd 100644 --- a/packages/kbn-es-query/src/filters/helpers/compare_filters.ts +++ b/packages/kbn-es-query/src/filters/helpers/compare_filters.ts @@ -9,6 +9,7 @@ import { defaults, isEqual, omit, map } from 'lodash'; import type { FilterMeta, Filter } from '../build_filters'; +/** @public */ export interface FilterCompareOptions { index?: boolean; disabled?: boolean; @@ -19,6 +20,7 @@ export interface FilterCompareOptions { /** * Include disabled, negate and store when comparing filters + * @public */ export const COMPARE_ALL_OPTIONS: FilterCompareOptions = { index: true, @@ -60,6 +62,8 @@ const mapFilterArray = ( * @param {FilterCompareOptions} comparatorOptions Parameters to use for comparison * * @returns {bool} Filters are the same + * + * @public */ export const compareFilters = ( first: Filter | Filter[], diff --git a/packages/kbn-es-query/src/filters/helpers/dedup_filters.ts b/packages/kbn-es-query/src/filters/helpers/dedup_filters.ts index 564354d012371..f43ef31837ae2 100644 --- a/packages/kbn-es-query/src/filters/helpers/dedup_filters.ts +++ b/packages/kbn-es-query/src/filters/helpers/dedup_filters.ts @@ -18,6 +18,8 @@ import { compareFilters, FilterCompareOptions } from './compare_filters'; * @param {object} comparatorOptions - Parameters to use for comparison * * @returns {object} An array of filters that were not in existing + * + * @internal */ export const dedupFilters = ( existingFilters: Filter[], diff --git a/packages/kbn-es-query/src/filters/helpers/meta_filter.ts b/packages/kbn-es-query/src/filters/helpers/meta_filter.ts index 91f5186570195..61b89d45d1962 100644 --- a/packages/kbn-es-query/src/filters/helpers/meta_filter.ts +++ b/packages/kbn-es-query/src/filters/helpers/meta_filter.ts @@ -9,10 +9,32 @@ import { omit, get } from 'lodash'; import { Filter, FilterStateStore } from '../build_filters'; +/** + * + * @param filter + * @returns `true` if the filter should be applied to global scope + * + * @public + */ export const isFilterPinned = (filter: Filter) => { return filter.$state && filter.$state.store === FilterStateStore.GLOBAL_STATE; }; +/** + * @param filter + * @returns `true` if the filter is disabled + * + * @public + */ +export const isFilterDisabled = (filter: Filter): boolean => get(filter, 'meta.disabled', false); + +/** + * + * @param filter + * @returns A copy of the filter with a toggled disabled state + * + * @public + */ export const toggleFilterDisabled = (filter: Filter) => { const disabled = !filter.meta.disabled; const meta = { ...filter.meta, disabled }; @@ -20,6 +42,13 @@ export const toggleFilterDisabled = (filter: Filter) => { return { ...filter, meta }; }; +/** + * + * @param filter + * @returns A copy of the filter with a toggled negated state + * + * @public + */ export const toggleFilterNegated = (filter: Filter) => { const negate = !filter.meta.negate; const meta = { ...filter.meta, negate }; @@ -27,6 +56,13 @@ export const toggleFilterNegated = (filter: Filter) => { return { ...filter, meta }; }; +/** + * + * @param filter + * @returns A copy of the filter with a toggled pinned state (toggles store from app to global and vice versa) + * + * @public + */ export const toggleFilterPinned = (filter: Filter) => { const store = isFilterPinned(filter) ? FilterStateStore.APP_STATE : FilterStateStore.GLOBAL_STATE; const $state = { ...filter.$state, store }; @@ -34,18 +70,48 @@ export const toggleFilterPinned = (filter: Filter) => { return { ...filter, $state }; }; +/** + * @param filter + * @returns An enabled copy of the filter + * + * @public + */ export const enableFilter = (filter: Filter) => !filter.meta.disabled ? filter : toggleFilterDisabled(filter); +/** + * @param filter + * @returns A disabled copy of the filter + * + * @public + */ export const disableFilter = (filter: Filter) => filter.meta.disabled ? filter : toggleFilterDisabled(filter); +/** + * @param filter + * @returns A pinned (global) copy of the filter + * + * @public + */ export const pinFilter = (filter: Filter) => isFilterPinned(filter) ? filter : toggleFilterPinned(filter); +/** + * @param filter + * @returns An unpinned (app scoped) copy of the filter + * + * @public + */ export const unpinFilter = (filter: Filter) => !isFilterPinned(filter) ? filter : toggleFilterPinned(filter); +/** + * @param {unknown} filter + * @returns `true` if the given object is a filter + * + * @public + */ export const isFilter = (x: unknown): x is Filter => !!x && typeof x === 'object' && @@ -53,6 +119,12 @@ export const isFilter = (x: unknown): x is Filter => typeof (x as Filter).meta === 'object' && typeof (x as Filter).meta.disabled === 'boolean'; +/** + * @param {unknown} filters + * @returns `true` if the given object is an array of filters + * + * @public + */ export const isFilters = (x: unknown): x is Filter[] => Array.isArray(x) && !x.find((y) => !isFilter(y)); @@ -60,7 +132,7 @@ export const isFilters = (x: unknown): x is Filter[] => * Clean out any invalid attributes from the filters * @param {object} filter The filter to clean * @returns {object} + * + * @public */ export const cleanFilter = (filter: Filter): Filter => omit(filter, ['meta', '$state']) as Filter; - -export const isFilterDisabled = (filter: Filter): boolean => get(filter, 'meta.disabled', false); diff --git a/packages/kbn-es-query/src/filters/helpers/only_disabled.ts b/packages/kbn-es-query/src/filters/helpers/only_disabled.ts index d090c411934f7..d605d8b0be628 100644 --- a/packages/kbn-es-query/src/filters/helpers/only_disabled.ts +++ b/packages/kbn-es-query/src/filters/helpers/only_disabled.ts @@ -14,8 +14,9 @@ const isEnabled = (f: Filter) => f && f.meta && !f.meta.disabled; /** * Checks to see if only disabled filters have been changed - * * @returns {bool} Only disabled filters + * + * @public */ export const onlyDisabledFiltersChanged = (newFilters?: Filter[], oldFilters?: Filter[]) => { // If it's the same - compare only enabled filters diff --git a/packages/kbn-es-query/src/filters/helpers/uniq_filters.ts b/packages/kbn-es-query/src/filters/helpers/uniq_filters.ts index e502cd5dbba5d..1e388ed7a8c96 100644 --- a/packages/kbn-es-query/src/filters/helpers/uniq_filters.ts +++ b/packages/kbn-es-query/src/filters/helpers/uniq_filters.ts @@ -15,8 +15,8 @@ import { dedupFilters } from './dedup_filters'; * * @param {array} filters The filters to remove duplicates from * @param {object} comparatorOptions - Parameters to use for comparison - * @returns {object} The original filters array with duplicates removed + * @public */ export const uniqFilters = (filters: Filter[], comparatorOptions: any = {}) => { let results: Filter[] = []; diff --git a/packages/kbn-es-query/src/filters/index.ts b/packages/kbn-es-query/src/filters/index.ts index 57139dc5110a4..61ee6ce6f0da6 100644 --- a/packages/kbn-es-query/src/filters/index.ts +++ b/packages/kbn-es-query/src/filters/index.ts @@ -59,7 +59,6 @@ export { export { Query, Filter, - FilterState, LatLon, FieldFilter, FilterMeta, diff --git a/packages/kbn-es-query/src/kuery/kuery_syntax_error.ts b/packages/kbn-es-query/src/kuery/kuery_syntax_error.ts index aa4440579eb49..e7ea0b5c2dd34 100644 --- a/packages/kbn-es-query/src/kuery/kuery_syntax_error.ts +++ b/packages/kbn-es-query/src/kuery/kuery_syntax_error.ts @@ -52,6 +52,10 @@ interface KQLSyntaxErrorExpected { type: string; } +/** + * A type of error indicating KQL syntax errors + * @public + */ export class KQLSyntaxError extends Error { shortMessage: string; diff --git a/packages/kbn-es-query/src/kuery/node_types/index.ts b/packages/kbn-es-query/src/kuery/node_types/index.ts index dc284bd64273b..a9298f27d28be 100644 --- a/packages/kbn-es-query/src/kuery/node_types/index.ts +++ b/packages/kbn-es-query/src/kuery/node_types/index.ts @@ -15,6 +15,9 @@ import { NodeTypes } from './types'; export { NodeTypes }; export { nodeBuilder } from './node_builder'; +/** + * @public + */ export const nodeTypes: NodeTypes = { // This requires better typing of the different typings and their return types. // @ts-ignore diff --git a/packages/kbn-es-query/src/kuery/types.ts b/packages/kbn-es-query/src/kuery/types.ts index fe1496ead7ab6..f188eab61c546 100644 --- a/packages/kbn-es-query/src/kuery/types.ts +++ b/packages/kbn-es-query/src/kuery/types.ts @@ -8,13 +8,19 @@ import { NodeTypes } from './node_types'; +/** @public */ export interface KueryNode { type: keyof NodeTypes; [key: string]: any; } +/** + * TODO: Replace with real type + * @public + */ export type DslQuery = any; +/** @internal */ export interface KueryParseOptions { helpers: { [key: string]: any; diff --git a/packages/kbn-es-query/src/utils.ts b/packages/kbn-es-query/src/utils.ts index 5e8d60a4b66fe..48d17e75e346c 100644 --- a/packages/kbn-es-query/src/utils.ts +++ b/packages/kbn-es-query/src/utils.ts @@ -8,6 +8,7 @@ import moment from 'moment-timezone'; +/** @internal */ export function getTimeZoneFromSettings(dateFormatTZ: string) { const detectedTimezone = moment.tz.guess(); diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7edca22dcad2f..5a0b376c4cbc5 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -13,7 +13,7 @@ pageLoadAssetSize: dashboard: 374194 dashboardEnhanced: 65646 dashboardMode: 22716 - data: 943821 + data: 900000 dataEnhanced: 50420 devTools: 38637 discover: 99999 diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index fb748d1e76661..1f6b549c0110c 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -228,6 +228,7 @@ export class DocLinksService { indexManagement: `${ELASTICSEARCH_DOCS}index-mgmt.html`, kibanaSearchSettings: `${KIBANA_DOCS}advanced-options.html#kibana-search-settings`, visualizationSettings: `${KIBANA_DOCS}advanced-options.html#kibana-visualization-settings`, + timelionSettings: `${KIBANA_DOCS}advanced-options.html#kibana-timelion-settings`, }, ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, diff --git a/src/core/public/fatal_errors/fatal_errors_service.tsx b/src/core/public/fatal_errors/fatal_errors_service.tsx index 52ec32b1d2d3d..975c0160d83b2 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.tsx +++ b/src/core/public/fatal_errors/fatal_errors_service.tsx @@ -51,7 +51,7 @@ export interface FatalErrorsSetup { */ export type FatalErrorsStart = FatalErrorsSetup; -/** @interal */ +/** @internal */ export class FatalErrorsService { private readonly errorInfo$ = new Rx.ReplaySubject(); private fatalErrors?: FatalErrorsSetup; diff --git a/src/plugins/bfetch/public/streaming/inflate_response.ts b/src/plugins/bfetch/public/streaming/inflate_response.ts index 73cb52285987c..860b77016cd00 100644 --- a/src/plugins/bfetch/public/streaming/inflate_response.ts +++ b/src/plugins/bfetch/public/streaming/inflate_response.ts @@ -7,9 +7,10 @@ */ import { unzlibSync, strFromU8 } from 'fflate'; +import { toByteArray } from 'base64-js'; export function inflateResponse(response: string) { - const buff = Buffer.from(response, 'base64'); + const buff = toByteArray(response); const unzip = unzlibSync(buff); return strFromU8(unzip); } diff --git a/src/plugins/charts/public/services/theme/theme.test.tsx b/src/plugins/charts/public/services/theme/theme.test.tsx index 07e28a7cfabc3..079acbb5fefbc 100644 --- a/src/plugins/charts/public/services/theme/theme.test.tsx +++ b/src/plugins/charts/public/services/theme/theme.test.tsx @@ -6,9 +6,11 @@ * Side Public License, v 1. */ +import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { take } from 'rxjs/operators'; import { renderHook, act } from '@testing-library/react-hooks'; +import { render, act as renderAct } from '@testing-library/react'; import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; @@ -105,6 +107,30 @@ describe('ThemeService', () => { act(() => darkMode$.next(false)); expect(result.current).toBe(EUI_CHARTS_THEME_LIGHT.theme); }); + + it('should not rerender when emitting the same value', () => { + const darkMode$ = new BehaviorSubject(false); + setupMockUiSettings.get$.mockReturnValue(darkMode$); + const themeService = new ThemeService(); + themeService.init(setupMockUiSettings); + const { useChartsTheme } = themeService; + + const renderCounter = jest.fn(); + const Wrapper = () => { + useChartsTheme(); + renderCounter(); + return null; + }; + + render(); + expect(renderCounter).toHaveBeenCalledTimes(1); + renderAct(() => darkMode$.next(true)); + expect(renderCounter).toHaveBeenCalledTimes(2); + renderAct(() => darkMode$.next(true)); + renderAct(() => darkMode$.next(true)); + renderAct(() => darkMode$.next(true)); + expect(renderCounter).toHaveBeenCalledTimes(2); + }); }); describe('useBaseChartTheme', () => { @@ -123,5 +149,29 @@ describe('ThemeService', () => { act(() => darkMode$.next(false)); expect(result.current).toBe(LIGHT_THEME); }); + + it('should not rerender when emitting the same value', () => { + const darkMode$ = new BehaviorSubject(false); + setupMockUiSettings.get$.mockReturnValue(darkMode$); + const themeService = new ThemeService(); + themeService.init(setupMockUiSettings); + const { useChartsBaseTheme } = themeService; + + const renderCounter = jest.fn(); + const Wrapper = () => { + useChartsBaseTheme(); + renderCounter(); + return null; + }; + + render(); + expect(renderCounter).toHaveBeenCalledTimes(1); + renderAct(() => darkMode$.next(true)); + expect(renderCounter).toHaveBeenCalledTimes(2); + renderAct(() => darkMode$.next(true)); + renderAct(() => darkMode$.next(true)); + renderAct(() => darkMode$.next(true)); + expect(renderCounter).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/plugins/charts/public/services/theme/theme.ts b/src/plugins/charts/public/services/theme/theme.ts index 694922ca6f958..dfea367327e7a 100644 --- a/src/plugins/charts/public/services/theme/theme.ts +++ b/src/plugins/charts/public/services/theme/theme.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Observable, BehaviorSubject } from 'rxjs'; import { CoreSetup } from 'kibana/public'; @@ -54,11 +54,18 @@ export class ThemeService { /** A React hook for consuming the charts theme */ public useChartsTheme = (): PartialTheme => { // eslint-disable-next-line react-hooks/rules-of-hooks - const [value, update] = useState(this.chartsDefaultTheme); + const [value, update] = useState(this._chartsTheme$.getValue()); + // eslint-disable-next-line react-hooks/rules-of-hooks + const ref = useRef(value); // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { - const s = this.chartsTheme$.subscribe(update); + const s = this.chartsTheme$.subscribe((val) => { + if (val !== ref.current) { + ref.current = val; + update(val); + } + }); return () => s.unsubscribe(); }, []); @@ -68,11 +75,18 @@ export class ThemeService { /** A React hook for consuming the charts theme */ public useChartsBaseTheme = (): Theme => { // eslint-disable-next-line react-hooks/rules-of-hooks - const [value, update] = useState(this.chartsDefaultBaseTheme); + const [value, update] = useState(this._chartsBaseTheme$.getValue()); + // eslint-disable-next-line react-hooks/rules-of-hooks + const ref = useRef(value); // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { - const s = this.chartsBaseTheme$.subscribe(update); + const s = this.chartsBaseTheme$.subscribe((val) => { + if (val !== ref.current) { + ref.current = val; + update(val); + } + }); return () => s.unsubscribe(); }, []); diff --git a/src/plugins/data/common/field_formats/converters/string.ts b/src/plugins/data/common/field_formats/converters/string.ts index 816754a2a9298..c6aba38cf376d 100644 --- a/src/plugins/data/common/field_formats/converters/string.ts +++ b/src/plugins/data/common/field_formats/converters/string.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import escape from 'lodash/escape'; +import { escape } from 'lodash'; import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { asPrettyString, getHighlightHtml } from '../utils'; @@ -95,7 +95,9 @@ export class StringFormat extends FieldFormat { private base64Decode(val: string) { try { - return Buffer.from(val, 'base64').toString('utf8'); + if (window && window.atob) return window.atob(val); + // referencing from `global` tricks webpack to not include `Buffer` polyfill into this bundle + return global.Buffer.from(val, 'base64').toString('utf8'); } catch (e) { return asPrettyString(val); } diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index c8769141a71d6..a0897a2dacf2a 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -42,13 +42,17 @@ export const searchSourceCommonMock: jest.Mocked = { createEmpty: jest.fn().mockReturnValue(searchSourceInstanceMock), }; -export const createSearchSourceMock = (fields?: SearchSourceFields) => +export const createSearchSourceMock = (fields?: SearchSourceFields, response?: any) => new SearchSource(fields, { getConfig: uiSettingsServiceMock.createStartContract().get, - search: jest - .fn() - .mockReturnValue( - of({ rawResponse: { hits: { hits: [], total: 0 } }, isPartial: false, isRunning: false }) - ), + search: jest.fn().mockReturnValue( + of( + response ?? { + rawResponse: { hits: { hits: [], total: 0 } }, + isPartial: false, + isRunning: false, + } + ) + ), onResponse: jest.fn().mockImplementation((req, res) => res), }); diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index e896fb9a5d4e2..e3ecacfbda5ad 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -6,19 +6,15 @@ * Side Public License, v 1. */ -/* - * esQuery and esKuery: - */ - import { PluginInitializerContext } from '../../../core/public'; import { ConfigSchema } from '../config'; +export * from './deprecated'; + /* * Filters: */ -export * from './deprecated'; - export { getEsQueryConfig } from '../common'; export { FilterLabel, FilterItem } from './ui'; export { getDisplayValueFromFilter, generateFilters, extractTimeRange } from './query'; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index ad1db8c1a4b6e..7409163c5d766 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -750,18 +750,18 @@ export const esFilters: { FILTERS: typeof import("@kbn/es-query").FILTERS; FilterStateStore: typeof FilterStateStore; buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("@kbn/es-query").Filter; - buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: any[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; + buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: string[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; buildExistsFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").ExistsFilter; - buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: any, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; + buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: string | number | boolean, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; buildQueryFilter: (query: any, index: string, alias: string) => import("@kbn/es-query/target_types/filters/build_filters").QueryStringFilter; buildRangeFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: import("@kbn/es-query").RangeFilterParams, indexPattern: import("@kbn/es-query").IndexPatternBase, formattedValue?: string | undefined) => import("@kbn/es-query").RangeFilter; - isPhraseFilter: (filter: any) => filter is import("@kbn/es-query").PhraseFilter; - isExistsFilter: (filter: any) => filter is import("@kbn/es-query").ExistsFilter; - isPhrasesFilter: (filter: any) => filter is import("@kbn/es-query").PhrasesFilter; - isRangeFilter: (filter: any) => filter is import("@kbn/es-query").RangeFilter; - isMatchAllFilter: (filter: any) => filter is import("@kbn/es-query").MatchAllFilter; - isMissingFilter: (filter: any) => filter is import("@kbn/es-query").MissingFilter; - isQueryStringFilter: (filter: any) => filter is import("@kbn/es-query/target_types/filters/build_filters").QueryStringFilter; + isPhraseFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").PhraseFilter; + isExistsFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").ExistsFilter; + isPhrasesFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").PhrasesFilter; + isRangeFilter: (filter?: import("@kbn/es-query").ExistsFilter | import("@kbn/es-query").GeoPolygonFilter | import("@kbn/es-query").PhrasesFilter | import("@kbn/es-query").PhraseFilter | import("@kbn/es-query").MatchAllFilter | import("@kbn/es-query").MissingFilter | import("@kbn/es-query").RangeFilter | import("@kbn/es-query").GeoBoundingBoxFilter | undefined) => filter is import("@kbn/es-query").RangeFilter; + isMatchAllFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").MatchAllFilter; + isMissingFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query").MissingFilter; + isQueryStringFilter: (filter: import("@kbn/es-query").FieldFilter) => filter is import("@kbn/es-query/target_types/filters/build_filters").QueryStringFilter; isFilterPinned: (filter: import("@kbn/es-query").Filter) => boolean | undefined; toggleFilterNegated: (filter: import("@kbn/es-query").Filter) => { meta: { @@ -776,7 +776,9 @@ export const esFilters: { params?: any; value?: string | undefined; }; - $state?: import("@kbn/es-query").FilterState | undefined; + $state?: { + store: FilterStateStore; + } | undefined; query?: any; }; disableFilter: (filter: import("@kbn/es-query").Filter) => import("@kbn/es-query").Filter; @@ -2620,41 +2622,41 @@ export interface WaitUntilNextSessionCompletesOptions { // src/plugins/data/public/deprecated.ts:93:23 - (ae-forgotten-export) The symbol "convertRangeFilterToTimeRangeString" needs to be exported by the entry point index.d.ts // src/plugins/data/public/deprecated.ts:93:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:51:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:57:26 - (ae-forgotten-export) The symbol "HistogramFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:100:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:127:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:127:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:127:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:298:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:298:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:298:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:300:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:301:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:310:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:311:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:312:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:313:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:317:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:318:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:321:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:322:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:325:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:53:26 - (ae-forgotten-export) The symbol "HistogramFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:96:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:123:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:123:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:123:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:123:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:123:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:294:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:294:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:294:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:296:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:297:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:306:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:307:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:308:1 - (ae-forgotten-export) The symbol "IpAddress" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:309:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:313:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:314:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:317:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:318:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:321:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:62:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 08159a4025b6d..2700cb1ebc659 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -395,8 +395,8 @@ export const esFilters: { buildEmptyFilter: (isPinned: boolean, index?: string | undefined) => import("@kbn/es-query").Filter; buildExistsFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").ExistsFilter; buildFilter: typeof import("@kbn/es-query").buildFilter; - buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: any, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; - buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: any[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; + buildPhraseFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, value: string | number | boolean, indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhraseFilter; + buildPhrasesFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: string[], indexPattern: import("@kbn/es-query").IndexPatternBase) => import("@kbn/es-query").PhrasesFilter; buildRangeFilter: (field: import("@kbn/es-query").IndexPatternFieldBase, params: import("@kbn/es-query").RangeFilterParams, indexPattern: import("@kbn/es-query").IndexPatternBase, formattedValue?: string | undefined) => import("@kbn/es-query").RangeFilter; isFilterDisabled: (filter: import("@kbn/es-query").Filter) => boolean; }; diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index f1c3c537dd5e1..0262bb942b8a5 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -71,6 +71,7 @@ const indexPattern = ({ getSourceFiltering: () => ({}), getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', + getFormatterForField: () => ({ convert: () => 'formatted' }), } as unknown) as IndexPattern; indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); diff --git a/src/plugins/discover/public/__mocks__/saved_search.ts b/src/plugins/discover/public/__mocks__/saved_search.ts index 6f48c56b0810e..c2d9bfeb1b4b5 100644 --- a/src/plugins/discover/public/__mocks__/saved_search.ts +++ b/src/plugins/discover/public/__mocks__/saved_search.ts @@ -9,6 +9,7 @@ import { SavedSearch } from '../saved_searches'; import { createSearchSourceMock } from '../../../data/public/mocks'; import { indexPatternMock } from './index_pattern'; +import { indexPatternWithTimefieldMock } from './index_pattern_with_timefield'; export const savedSearchMock = ({ id: 'the-saved-search-id', @@ -31,3 +32,25 @@ export const savedSearchMock = ({ error: undefined, searchSource: createSearchSourceMock({ index: indexPatternMock }), } as unknown) as SavedSearch; + +export const savedSearchMockWithTimeField = ({ + id: 'the-saved-search-id-with-timefield', + type: 'search', + attributes: { + title: 'the-saved-search-title', + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"highlightAll":true,"version":true,"query":{"query":"foo : \\"bar\\" ","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: 'the-index-pattern-id', + }, + ], + migrationVersion: { search: '7.5.0' }, + error: undefined, + searchSource: createSearchSourceMock({ index: indexPatternWithTimefieldMock }), +} as unknown) as SavedSearch; diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 8a047625303d2..e137955674457 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -5,10 +5,15 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; import { DiscoverServices } from '../build_services'; import { dataPluginMock } from '../../../data/public/mocks'; import { chromeServiceMock, coreMock, docLinksServiceMock } from '../../../../core/public/mocks'; -import { DEFAULT_COLUMNS_SETTING } from '../../common'; +import { + DEFAULT_COLUMNS_SETTING, + SAMPLE_SIZE_SETTING, + SORT_DEFAULT_ORDER_SETTING, +} from '../../common'; import { savedSearchMock } from './saved_search'; import { UI_SETTINGS } from '../../../data/common'; import { TopNavMenu } from '../../../navigation/public'; @@ -44,8 +49,15 @@ export const discoverServiceMock = ({ return []; } else if (key === UI_SETTINGS.META_FIELDS) { return []; + } else if (key === SAMPLE_SIZE_SETTING) { + return 250; + } else if (key === SORT_DEFAULT_ORDER_SETTING) { + return 'desc'; } }, + isDefault: (key: string) => { + return true; + }, }, indexPatternFieldEditor: { openEditor: jest.fn(), @@ -60,4 +72,8 @@ export const discoverServiceMock = ({ metadata: { branch: 'test', }, + theme: { + useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme), + useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme), + }, } as unknown) as DiscoverServices; diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx index 85980ab96b647..48ea7ffc46384 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx @@ -11,7 +11,7 @@ import { mountWithIntl } from '@kbn/test/jest'; import { TableHeader } from './table_header'; import { findTestSubject } from '@elastic/eui/lib/test'; import { SortOrder } from './helpers'; -import { IndexPattern, IFieldType } from '../../../../../kibana_services'; +import { IndexPattern, IndexPatternField } from '../../../../../kibana_services'; function getMockIndexPattern() { return ({ @@ -29,7 +29,7 @@ function getMockIndexPattern() { aggregatable: false, searchable: true, sortable: true, - } as IFieldType; + } as IndexPatternField; } else { return { name, @@ -38,7 +38,7 @@ function getMockIndexPattern() { aggregatable: false, searchable: true, sortable: false, - } as IFieldType; + } as IndexPatternField; } }, } as unknown) as IndexPattern; diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx index 681418c1bd7cf..73a67310bf4be 100644 --- a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx @@ -12,7 +12,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; import type { estypes } from '@elastic/elasticsearch'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getServices, IIndexPattern } from '../../../kibana_services'; +import { getServices, IndexPattern } from '../../../kibana_services'; import { IndexPatternField } from '../../../../../data/common'; import { SkipBottomButton } from '../../apps/main/components/skip_bottom_button'; @@ -22,7 +22,7 @@ export interface DocTableLegacyProps { searchTitle?: string; onFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; rows: estypes.SearchHit[]; - indexPattern: IIndexPattern; + indexPattern: IndexPattern; minimumVisibleRows?: number; onAddColumn?: (column: string) => void; onBackToTop: () => void; diff --git a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.test.tsx b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.test.tsx new file mode 100644 index 0000000000000..7a13f18997b86 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Subject, BehaviorSubject } from 'rxjs'; +import { mountWithIntl } from '@kbn/test/jest'; +import { setHeaderActionMenuMounter } from '../../../../../kibana_services'; +import { esHits } from '../../../../../__mocks__/es_hits'; +import { savedSearchMock } from '../../../../../__mocks__/saved_search'; +import { createSearchSourceMock } from '../../../../../../../data/common/search/search_source/mocks'; +import { GetStateReturn } from '../../services/discover_state'; +import { DataCharts$, DataTotalHits$ } from '../../services/use_saved_search'; +import { discoverServiceMock } from '../../../../../__mocks__/services'; +import { FetchStatus } from '../../../../types'; +import { Chart } from './point_series'; +import { DiscoverChart } from './discover_chart'; + +setHeaderActionMenuMounter(jest.fn()); + +function getProps(timefield?: string) { + const searchSourceMock = createSearchSourceMock({}); + const services = discoverServiceMock; + services.data.query.timefilter.timefilter.getTime = () => { + return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; + }; + + const totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: Number(esHits.length), + }) as DataTotalHits$; + + const chartData = ({ + xAxisOrderedValues: [ + 1623880800000, + 1623967200000, + 1624053600000, + 1624140000000, + 1624226400000, + 1624312800000, + 1624399200000, + 1624485600000, + 1624572000000, + 1624658400000, + 1624744800000, + 1624831200000, + 1624917600000, + 1625004000000, + 1625090400000, + ], + xAxisFormat: { id: 'date', params: { pattern: 'YYYY-MM-DD' } }, + xAxisLabel: 'order_date per day', + yAxisFormat: { id: 'number' }, + ordered: { + date: true, + interval: { + asMilliseconds: jest.fn(), + }, + intervalESUnit: 'd', + intervalESValue: 1, + min: '2021-03-18T08:28:56.411Z', + max: '2021-07-01T07:28:56.411Z', + }, + yAxisLabel: 'Count', + values: [ + { x: 1623880800000, y: 134 }, + { x: 1623967200000, y: 152 }, + { x: 1624053600000, y: 141 }, + { x: 1624140000000, y: 138 }, + { x: 1624226400000, y: 142 }, + { x: 1624312800000, y: 157 }, + { x: 1624399200000, y: 149 }, + { x: 1624485600000, y: 146 }, + { x: 1624572000000, y: 170 }, + { x: 1624658400000, y: 137 }, + { x: 1624744800000, y: 150 }, + { x: 1624831200000, y: 144 }, + { x: 1624917600000, y: 147 }, + { x: 1625004000000, y: 137 }, + { x: 1625090400000, y: 66 }, + ], + } as unknown) as Chart; + + const charts$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + chartData, + bucketInterval: { + scaled: true, + description: 'test', + scale: 2, + }, + }) as DataCharts$; + + return { + isLegacy: false, + resetQuery: jest.fn(), + savedSearch: savedSearchMock, + savedSearchDataChart$: charts$, + savedSearchDataTotalHits$: totalHits$, + savedSearchRefetch$: new Subject(), + searchSource: searchSourceMock, + services, + state: { columns: [] }, + stateContainer: {} as GetStateReturn, + timefield, + }; +} + +describe('Discover chart', () => { + test('render without timefield', () => { + const component = mountWithIntl(); + expect(component.find('[data-test-subj="discoverChartToggle"]').exists()).toBeFalsy(); + }); + test('render with filefield', () => { + const component = mountWithIntl(); + expect(component.find('[data-test-subj="discoverChartToggle"]').exists()).toBeTruthy(); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx index f1967d5b10b3e..165f708bf7083 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx @@ -5,48 +5,43 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, memo } from 'react'; import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; -import { IUiSettingsClient } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { HitsCounter } from '../hits_counter'; -import { DataPublicPluginStart, IndexPattern, search } from '../../../../../../../data/public'; +import { search } from '../../../../../../../data/public'; import { TimechartHeader } from '../timechart_header'; import { SavedSearch } from '../../../../../saved_searches'; import { AppState, GetStateReturn } from '../../services/discover_state'; -import { TimechartBucketInterval } from '../timechart_header/timechart_header'; -import { Chart as IChart } from './point_series'; import { DiscoverHistogram } from './histogram'; +import { DataCharts$, DataTotalHits$ } from '../../services/use_saved_search'; +import { DiscoverServices } from '../../../../../build_services'; -const TimechartHeaderMemoized = React.memo(TimechartHeader); -const DiscoverHistogramMemoized = React.memo(DiscoverHistogram); +const TimechartHeaderMemoized = memo(TimechartHeader); +const DiscoverHistogramMemoized = memo(DiscoverHistogram); export function DiscoverChart({ - config, - data, - bucketInterval, - chartData, - hits, isLegacy, resetQuery, savedSearch, + savedSearchDataChart$, + savedSearchDataTotalHits$, + services, state, stateContainer, timefield, }: { - config: IUiSettingsClient; - data: DataPublicPluginStart; - bucketInterval?: TimechartBucketInterval; - chartData?: IChart; - hits?: number; - indexPattern: IndexPattern; isLegacy: boolean; resetQuery: () => void; savedSearch: SavedSearch; + savedSearchDataChart$: DataCharts$; + savedSearchDataTotalHits$: DataTotalHits$; + services: DiscoverServices; state: AppState; stateContainer: GetStateReturn; timefield?: string; }) { + const { data, uiSettings: config } = services; const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({ element: null, moveFocus: false, @@ -93,7 +88,7 @@ export function DiscoverChart({ className="dscResuntCount__title eui-textTruncate eui-textNoWrap" > @@ -106,7 +101,7 @@ export function DiscoverChart({ options={search.aggs.intervalOptions} onChangeInterval={onChangeInterval} stateInterval={state.interval || ''} - bucketInterval={bucketInterval} + savedSearchData$={savedSearchDataChart$} /> )} @@ -130,7 +125,7 @@ export function DiscoverChart({ )} - {!state.hideChart && chartData && ( + {timefield && !state.hideChart && (
(chartRef.current.element = element)} @@ -145,8 +140,9 @@ export function DiscoverChart({ data-test-subj="discoverChart" >
diff --git a/src/plugins/discover/public/application/apps/main/components/chart/histogram.scss b/src/plugins/discover/public/application/apps/main/components/chart/histogram.scss new file mode 100644 index 0000000000000..10bec21a937ad --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/chart/histogram.scss @@ -0,0 +1,9 @@ +.dscChart__loading { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1 0 100%; + text-align: center; + height: 100%; + width: 100%; +} diff --git a/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx b/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx index 92b212d5739b5..df63c4c94ae86 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/histogram.tsx @@ -5,45 +5,41 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import './histogram.scss'; import moment, { unitOfTime } from 'moment-timezone'; -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { EuiLoadingChart, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { Axis, + BrushEndListener, Chart, + ElementClickListener, HistogramBarSeries, Position, ScaleType, Settings, TooltipType, - ElementClickListener, XYChartElementEvent, - BrushEndListener, - Theme, } from '@elastic/charts'; import { IUiSettingsClient } from 'kibana/public'; -import { EuiChartThemeType } from '@elastic/eui/dist/eui_charts_theme'; -import { Subscription, combineLatest } from 'rxjs'; -import { getServices } from '../../../../../kibana_services'; -import { Chart as IChart } from './point_series'; import { CurrentTime, Endzones, getAdjustedInterval, renderEndzoneTooltip, } from '../../../../../../../charts/public'; +import { DataCharts$, DataChartsMessage } from '../../services/use_saved_search'; +import { FetchStatus } from '../../../../types'; +import { DiscoverServices } from '../../../../../build_services'; +import { useDataState } from '../../utils/use_data_state'; export interface DiscoverHistogramProps { - chartData: IChart; + savedSearchData$: DataCharts$; timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; -} - -interface DiscoverHistogramState { - chartsTheme: EuiChartThemeType['theme']; - chartsBaseTheme: Theme; + services: DiscoverServices; } function getTimezone(uiSettings: IUiSettingsClient) { @@ -56,154 +52,149 @@ function getTimezone(uiSettings: IUiSettingsClient) { } } -export class DiscoverHistogram extends Component { - public static propTypes = { - chartData: PropTypes.object, - timefilterUpdateHandler: PropTypes.func, - }; - - private subscription?: Subscription; - public state = { - chartsTheme: getServices().theme.chartsDefaultTheme, - chartsBaseTheme: getServices().theme.chartsDefaultBaseTheme, - }; - - componentDidMount() { - this.subscription = combineLatest([ - getServices().theme.chartsTheme$, - getServices().theme.chartsBaseTheme$, - ]).subscribe(([chartsTheme, chartsBaseTheme]) => - this.setState({ chartsTheme, chartsBaseTheme }) +export function DiscoverHistogram({ + savedSearchData$, + timefilterUpdateHandler, + services, +}: DiscoverHistogramProps) { + const chartTheme = services.theme.useChartsTheme(); + const chartBaseTheme = services.theme.useChartsBaseTheme(); + + const dataState: DataChartsMessage = useDataState(savedSearchData$); + + const uiSettings = services.uiSettings; + const timeZone = getTimezone(uiSettings); + const { chartData, fetchStatus } = dataState; + + const onBrushEnd: BrushEndListener = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [from, to] = x; + timefilterUpdateHandler({ from, to }); + }, + [timefilterUpdateHandler] + ); + + const onElementClick = useCallback( + (xInterval: number): ElementClickListener => ([elementData]) => { + const startRange = (elementData as XYChartElementEvent)[0].x; + + const range = { + from: startRange, + to: startRange + xInterval, + }; + + timefilterUpdateHandler(range); + }, + [timefilterUpdateHandler] + ); + + if (!chartData && fetchStatus === FetchStatus.LOADING) { + return ( +
+ + + + + +
); } - componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - } + if (!chartData) { + return null; } - public onBrushEnd: BrushEndListener = ({ x }) => { - if (!x) { - return; - } - const [from, to] = x; - this.props.timefilterUpdateHandler({ from, to }); + const formatXValue = (val: string) => { + const xAxisFormat = chartData.xAxisFormat.params!.pattern; + return moment(val).format(xAxisFormat); }; - public onElementClick = (xInterval: number): ElementClickListener => ([elementData]) => { - const startRange = (elementData as XYChartElementEvent)[0].x; - - const range = { - from: startRange, - to: startRange + xInterval, - }; - - this.props.timefilterUpdateHandler(range); + const data = chartData.values; + const isDarkMode = uiSettings.get('theme:darkMode'); + + /* + * Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval]. + * see https://github.com/elastic/kibana/issues/27410 + * TODO: Once the Discover query has been update, we should change the below to use the new field + */ + const { intervalESValue, intervalESUnit, interval } = chartData.ordered; + const xInterval = interval.asMilliseconds(); + + const xValues = chartData.xAxisOrderedValues; + const lastXValue = xValues[xValues.length - 1]; + + const domain = chartData.ordered; + const domainStart = domain.min.valueOf(); + const domainEnd = domain.max.valueOf(); + + const domainMin = Math.min(data[0]?.x, domainStart); + const domainMax = Math.max(domainEnd - xInterval, lastXValue); + + const xDomain = { + min: domainMin, + max: domainMax, + minInterval: getAdjustedInterval( + xValues, + intervalESValue, + intervalESUnit as unitOfTime.Base, + timeZone + ), }; - - public formatXValue = (val: string) => { - const xAxisFormat = this.props.chartData.xAxisFormat.params!.pattern; - - return moment(val).format(xAxisFormat); + const tooltipProps = { + headerFormatter: renderEndzoneTooltip(xInterval, domainStart, domainEnd, formatXValue), + type: TooltipType.VerticalCursor, }; - public render() { - const uiSettings = getServices().uiSettings; - const timeZone = getTimezone(uiSettings); - const { chartData } = this.props; - const { chartsTheme, chartsBaseTheme } = this.state; - - if (!chartData) { - return null; - } - - const data = chartData.values; - const isDarkMode = uiSettings.get('theme:darkMode'); - - /* - * Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval]. - * see https://github.com/elastic/kibana/issues/27410 - * TODO: Once the Discover query has been update, we should change the below to use the new field - */ - const { intervalESValue, intervalESUnit, interval } = chartData.ordered; - const xInterval = interval.asMilliseconds(); - - const xValues = chartData.xAxisOrderedValues; - const lastXValue = xValues[xValues.length - 1]; - - const domain = chartData.ordered; - const domainStart = domain.min.valueOf(); - const domainEnd = domain.max.valueOf(); - - const domainMin = Math.min(data[0]?.x, domainStart); - const domainMax = Math.max(domainEnd - xInterval, lastXValue); - - const xDomain = { - min: domainMin, - max: domainMax, - minInterval: getAdjustedInterval( - xValues, - intervalESValue, - intervalESUnit as unitOfTime.Base, - timeZone - ), - }; - const tooltipProps = { - headerFormatter: renderEndzoneTooltip(xInterval, domainStart, domainEnd, this.formatXValue), - type: TooltipType.VerticalCursor, - }; - - const xAxisFormatter = getServices().data.fieldFormats.deserialize( - this.props.chartData.yAxisFormat - ); - - return ( - - - xAxisFormatter.convert(value)} - /> - - - - - - ); - } + const xAxisFormatter = services.data.fieldFormats.deserialize(chartData.yAxisFormat); + + return ( + + + xAxisFormatter.convert(value)} + /> + + + + + + ); } diff --git a/src/plugins/discover/public/application/apps/main/components/chart/point_series.test.ts b/src/plugins/discover/public/application/apps/main/components/chart/point_series.test.ts new file mode 100644 index 0000000000000..37fa894f7f671 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/chart/point_series.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 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 { buildPointSeriesData } from './point_series'; +import moment from 'moment'; +import { Unit } from '@elastic/datemath'; + +describe('buildPointSeriesData', () => { + test('with valid data', () => { + const table = { + type: 'datatable', + columns: [ + { + id: 'col-0-2', + name: 'order_date per 30 days', + meta: { + type: 'date', + field: 'order_date', + index: 'kibana_sample_data_ecommerce', + params: { id: 'date', params: { pattern: 'YYYY-MM-DD' } }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + id: '2', + enabled: true, + type: 'date_histogram', + params: { + field: 'order_date', + timeRange: { from: 'now-15y', to: 'now' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: 'auto', + used_interval: '30d', + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + schema: 'segment', + }, + }, + }, + { + id: 'col-1-1', + name: 'Count', + meta: { + type: 'number', + index: 'kibana_sample_data_ecommerce', + params: { id: 'number' }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + }, + }, + ], + rows: [{ 'col-0-2': 1625176800000, 'col-1-1': 2139 }], + }; + const dimensions = { + x: { + accessor: 0, + label: 'order_date per 30 days', + format: { id: 'date', params: { pattern: 'YYYY-MM-DD' } }, + params: { + date: true, + interval: moment.duration(30, 'd'), + intervalESValue: 30, + intervalESUnit: 'd' as Unit, + format: 'YYYY-MM-DD', + bounds: { + min: moment('2006-07-29T11:08:13.078Z'), + max: moment('2021-07-29T11:08:13.078Z'), + }, + }, + }, + y: { accessor: 1, format: { id: 'number' }, label: 'Count' }, + } as const; + expect(buildPointSeriesData(table, dimensions)).toMatchInlineSnapshot(` + Object { + "ordered": Object { + "date": true, + "interval": "P30D", + "intervalESUnit": "d", + "intervalESValue": 30, + "max": "2021-07-29T11:08:13.078Z", + "min": "2006-07-29T11:08:13.078Z", + }, + "values": Array [ + Object { + "x": 1625176800000, + "y": 2139, + }, + ], + "xAxisFormat": Object { + "id": "date", + "params": Object { + "pattern": "YYYY-MM-DD", + }, + }, + "xAxisLabel": "order_date per 30 days", + "xAxisOrderedValues": Array [ + 1625176800000, + ], + "yAxisFormat": Object { + "id": "number", + }, + "yAxisLabel": "Count", + } + `); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.test.tsx b/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.test.tsx index 6434863dfc416..02644fbf507dd 100644 --- a/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.test.tsx @@ -11,6 +11,9 @@ import { mountWithIntl } from '@kbn/test/jest'; import { ReactWrapper } from 'enzyme'; import { HitsCounter, HitsCounterProps } from './hits_counter'; import { findTestSubject } from '@elastic/eui/lib/test'; +import { BehaviorSubject } from 'rxjs'; +import { FetchStatus } from '../../../../types'; +import { DataTotalHits$ } from '../../services/use_saved_search'; describe('hits counter', function () { let props: HitsCounterProps; @@ -20,7 +23,10 @@ describe('hits counter', function () { props = { onResetQuery: jest.fn(), showResetButton: true, - hits: 2, + savedSearchData$: new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: 2, + }) as DataTotalHits$, }; }); @@ -30,9 +36,7 @@ describe('hits counter', function () { }); it('HitsCounter not renders a button when the showResetButton property is false', () => { - component = mountWithIntl( - - ); + component = mountWithIntl(); expect(findTestSubject(component, 'resetSavedSearch').length).toBe(0); }); @@ -43,8 +47,17 @@ describe('hits counter', function () { }); it('expect to render 1,899 hits if 1899 hits given', function () { + const data$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: 1899, + }) as DataTotalHits$; component = mountWithIntl( - + ); const hits = findTestSubject(component, 'discoverQueryHits'); expect(hits.text()).toBe('1,899'); diff --git a/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.tsx b/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.tsx index 0850a8ff03d96..ab6617cbe79de 100644 --- a/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.tsx +++ b/src/plugins/discover/public/application/apps/main/components/hits_counter/hits_counter.tsx @@ -7,18 +7,21 @@ */ import './hits_counter.scss'; - import React from 'react'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { FormattedMessage, FormattedNumber } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { formatNumWithCommas } from '../../../../helpers'; +import { DataTotalHits$, DataTotalHitsMsg } from '../../services/use_saved_search'; +import { FetchStatus } from '../../../../types'; +import { useDataState } from '../../utils/use_data_state'; export interface HitsCounterProps { - /** - * the number of query hits - */ - hits?: number; /** * displays the reset button */ @@ -27,52 +30,81 @@ export interface HitsCounterProps { * resets the query */ onResetQuery: () => void; + /** + * saved search data observable + */ + savedSearchData$: DataTotalHits$; } -export function HitsCounter({ hits, showResetButton, onResetQuery }: HitsCounterProps) { - if (typeof hits === 'undefined') { +export function HitsCounter({ showResetButton, onResetQuery, savedSearchData$ }: HitsCounterProps) { + const data: DataTotalHitsMsg = useDataState(savedSearchData$); + + const hits = data.result || 0; + if (!hits && data.fetchStatus === FetchStatus.LOADING) { return null; } + + const formattedHits = ( + + + + ); + return ( - - - - - {formatNumWithCommas(hits)}{' '} + + + + {data.fetchStatus === FetchStatus.PARTIAL && ( + + )} + {data.fetchStatus !== FetchStatus.PARTIAL && ( - + )} + + + {data.fetchStatus === FetchStatus.PARTIAL && ( + + + + )} + {showResetButton && ( + + + + - {showResetButton && ( - - - - - - )} - - + )} + ); } diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx new file mode 100644 index 0000000000000..1136b693c9e74 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { mountWithIntl } from '@kbn/test/jest'; +import { setHeaderActionMenuMounter } from '../../../../../kibana_services'; +import { esHits } from '../../../../../__mocks__/es_hits'; +import { savedSearchMock } from '../../../../../__mocks__/saved_search'; +import { GetStateReturn } from '../../services/discover_state'; +import { DataDocuments$ } from '../../services/use_saved_search'; +import { discoverServiceMock } from '../../../../../__mocks__/services'; +import { FetchStatus } from '../../../../types'; +import { DiscoverDocuments } from './discover_documents'; +import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; +import { indexPatternMock } from '../../../../../__mocks__/index_pattern'; + +setHeaderActionMenuMounter(jest.fn()); + +function getProps(fetchStatus: FetchStatus, hits: ElasticSearchHit[]) { + const services = discoverServiceMock; + services.data.query.timefilter.timefilter.getTime = () => { + return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; + }; + + const documents$ = new BehaviorSubject({ + fetchStatus, + result: hits, + }) as DataDocuments$; + + return { + expandedDoc: undefined, + indexPattern: indexPatternMock, + isMobile: jest.fn(() => false), + onAddFilter: jest.fn(), + savedSearch: savedSearchMock, + documents$, + searchSource: documents$, + services, + setExpandedDoc: jest.fn(), + state: { columns: [] }, + stateContainer: {} as GetStateReturn, + navigateTo: jest.fn(), + }; +} + +describe('Discover documents layout', () => { + test('render loading when loading and no documents', () => { + const component = mountWithIntl(); + expect(component.find('.dscDocuments__loading').exists()).toBeTruthy(); + expect(component.find('.dscTable').exists()).toBeFalsy(); + }); + + test('render complete when loading but documents were already fetched', () => { + const component = mountWithIntl( + + ); + expect(component.find('.dscDocuments__loading').exists()).toBeFalsy(); + expect(component.find('.dscTable').exists()).toBeTruthy(); + }); + + test('render complete', () => { + const component = mountWithIntl( + + ); + expect(component.find('.dscDocuments__loading').exists()).toBeFalsy(); + expect(component.find('.dscTable').exists()).toBeTruthy(); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.tsx new file mode 100644 index 0000000000000..13cf021ff2573 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.tsx @@ -0,0 +1,194 @@ +/* + * Copyright 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, { useRef, useMemo, useCallback, memo } from 'react'; +import { EuiFlexItem, EuiSpacer, EuiText, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DocTableLegacy } from '../../../../angular/doc_table/create_doc_table_react'; +import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort'; +import { DocViewFilterFn, ElasticSearchHit } from '../../../../doc_views/doc_views_types'; +import { DiscoverGrid } from '../../../../components/discover_grid/discover_grid'; +import { FetchStatus } from '../../../../types'; +import { + DOC_HIDE_TIME_COLUMN_SETTING, + DOC_TABLE_LEGACY, + SAMPLE_SIZE_SETTING, + SEARCH_FIELDS_FROM_SOURCE, +} from '../../../../../../common'; +import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns'; +import { IndexPattern } from '../../../../../../../data/common'; +import { SavedSearch } from '../../../../../saved_searches'; +import { DataDocumentsMsg, DataDocuments$ } from '../../services/use_saved_search'; +import { DiscoverServices } from '../../../../../build_services'; +import { AppState, GetStateReturn } from '../../services/discover_state'; +import { useDataState } from '../../utils/use_data_state'; + +const DocTableLegacyMemoized = React.memo(DocTableLegacy); +const DataGridMemoized = React.memo(DiscoverGrid); + +function DiscoverDocumentsComponent({ + documents$, + expandedDoc, + indexPattern, + isMobile, + onAddFilter, + savedSearch, + services, + setExpandedDoc, + state, + stateContainer, +}: { + documents$: DataDocuments$; + expandedDoc?: ElasticSearchHit; + indexPattern: IndexPattern; + isMobile: () => boolean; + navigateTo: (url: string) => void; + onAddFilter: DocViewFilterFn; + savedSearch: SavedSearch; + services: DiscoverServices; + setExpandedDoc: (doc: ElasticSearchHit | undefined) => void; + state: AppState; + stateContainer: GetStateReturn; +}) { + const { capabilities, indexPatterns, uiSettings } = services; + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); + + const scrollableDesktop = useRef(null); + const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); + const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]); + + const documentState: DataDocumentsMsg = useDataState(documents$); + + const rows = useMemo(() => documentState.result || [], [documentState.result]); + + const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({ + capabilities, + config: uiSettings, + indexPattern, + indexPatterns, + setAppState: stateContainer.setAppState, + state, + useNewFieldsApi, + }); + + /** + * Legacy function, remove once legacy grid is removed + */ + const onBackToTop = useCallback(() => { + if (scrollableDesktop && scrollableDesktop.current) { + scrollableDesktop.current.focus(); + } + // Only the desktop one needs to target a specific container + if (!isMobile() && scrollableDesktop.current) { + scrollableDesktop.current.scrollTo(0, 0); + } else if (window) { + window.scrollTo(0, 0); + } + }, [scrollableDesktop, isMobile]); + + const onResize = useCallback( + (colSettings: { columnId: string; width: number }) => { + const grid = { ...state.grid } || {}; + const newColumns = { ...grid.columns } || {}; + newColumns[colSettings.columnId] = { + width: colSettings.width, + }; + const newGrid = { ...grid, columns: newColumns }; + stateContainer.setAppState({ grid: newGrid }); + }, + [stateContainer, state] + ); + + const onSort = useCallback( + (sort: string[][]) => { + stateContainer.setAppState({ sort }); + }, + [stateContainer] + ); + + const showTimeCol = useMemo( + () => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, + [uiSettings, indexPattern.timeFieldName] + ); + + if ( + (!documentState.result || documentState.result.length === 0) && + documentState.fetchStatus === FetchStatus.LOADING + ) { + return ( +
+ + + + + +
+ ); + } + + return ( + +
+

+ +

+ {isLegacy && rows && rows.length && ( + + )} + {!isLegacy && ( +
+ +
+ )} +
+
+ ); +} + +export const DiscoverDocuments = memo(DiscoverDocumentsComponent); diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.scss b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.scss index 8ca11e9e044e8..b3a4453425ac5 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.scss +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.scss @@ -103,3 +103,12 @@ discover-app { padding: $euiSizeXS $euiSizeS; text-align: center; } + +.dscDocuments__loading { + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + height: 100%; + width: 100%; +} diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx index 57a9d518f838e..7343760f32d13 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx @@ -20,9 +20,17 @@ import { SavedObject } from '../../../../../../../../core/types'; import { indexPatternWithTimefieldMock } from '../../../../../__mocks__/index_pattern_with_timefield'; import { GetStateReturn } from '../../services/discover_state'; import { DiscoverLayoutProps } from './types'; -import { SavedSearchDataSubject } from '../../services/use_saved_search'; +import { + DataCharts$, + DataDocuments$, + DataMain$, + DataTotalHits$, +} from '../../services/use_saved_search'; import { discoverServiceMock } from '../../../../../__mocks__/services'; import { FetchStatus } from '../../../../types'; +import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; +import { RequestAdapter } from '../../../../../../../inspector'; +import { Chart } from '../chart/point_series'; setHeaderActionMenuMounter(jest.fn()); @@ -37,23 +45,99 @@ function getProps(indexPattern: IndexPattern): DiscoverLayoutProps { return { ...ip, ...{ attributes: { title: ip.title } } }; }) as unknown) as Array>; - const savedSearch$ = new BehaviorSubject({ - state: FetchStatus.COMPLETE, - rows: esHits, - fetchCounter: 1, - fieldCounts: {}, - hits: Number(esHits.length), - }) as SavedSearchDataSubject; + const main$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + foundDocuments: true, + }) as DataMain$; + + const documents$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: esHits as ElasticSearchHit[], + }) as DataDocuments$; + + const totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: Number(esHits.length), + }) as DataTotalHits$; + + const chartData = ({ + xAxisOrderedValues: [ + 1623880800000, + 1623967200000, + 1624053600000, + 1624140000000, + 1624226400000, + 1624312800000, + 1624399200000, + 1624485600000, + 1624572000000, + 1624658400000, + 1624744800000, + 1624831200000, + 1624917600000, + 1625004000000, + 1625090400000, + ], + xAxisFormat: { id: 'date', params: { pattern: 'YYYY-MM-DD' } }, + xAxisLabel: 'order_date per day', + yAxisFormat: { id: 'number' }, + ordered: { + date: true, + interval: { + asMilliseconds: jest.fn(), + }, + intervalESUnit: 'd', + intervalESValue: 1, + min: '2021-03-18T08:28:56.411Z', + max: '2021-07-01T07:28:56.411Z', + }, + yAxisLabel: 'Count', + values: [ + { x: 1623880800000, y: 134 }, + { x: 1623967200000, y: 152 }, + { x: 1624053600000, y: 141 }, + { x: 1624140000000, y: 138 }, + { x: 1624226400000, y: 142 }, + { x: 1624312800000, y: 157 }, + { x: 1624399200000, y: 149 }, + { x: 1624485600000, y: 146 }, + { x: 1624572000000, y: 170 }, + { x: 1624658400000, y: 137 }, + { x: 1624744800000, y: 150 }, + { x: 1624831200000, y: 144 }, + { x: 1624917600000, y: 147 }, + { x: 1625004000000, y: 137 }, + { x: 1625090400000, y: 66 }, + ], + } as unknown) as Chart; + + const charts$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + chartData, + bucketInterval: { + scaled: true, + description: 'test', + scale: 2, + }, + }) as DataCharts$; + + const savedSearchData$ = { + main$, + documents$, + totalHits$, + charts$, + }; return { indexPattern, indexPatternList, + inspectorAdapters: { requests: new RequestAdapter() }, navigateTo: jest.fn(), onChangeIndexPattern: jest.fn(), onUpdateQuery: jest.fn(), resetQuery: jest.fn(), savedSearch: savedSearchMock, - savedSearchData$: savedSearch$, + savedSearchData$, savedSearchRefetch$: new Subject(), searchSource: searchSourceMock, services, diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx index 8cc3be810a7ee..785cde6fd311d 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx @@ -20,11 +20,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider } from '@kbn/i18n/react'; import classNames from 'classnames'; import { DiscoverNoResults } from '../no_results'; import { LoadingSpinner } from '../loading_spinner/loading_spinner'; -import { DocTableLegacy } from '../../../../angular/doc_table/create_doc_table_react'; import { esFilters, IndexPatternField, @@ -32,42 +31,28 @@ import { } from '../../../../../../../data/public'; import { DiscoverSidebarResponsive } from '../sidebar'; import { DiscoverLayoutProps } from './types'; -import { SortPairArr } from '../../../../angular/doc_table/lib/get_sort'; -import { - DOC_HIDE_TIME_COLUMN_SETTING, - DOC_TABLE_LEGACY, - SAMPLE_SIZE_SETTING, - SEARCH_FIELDS_FROM_SOURCE, -} from '../../../../../../common'; +import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../../../common'; import { popularizeField } from '../../../../helpers/popularize_field'; -import { DocViewFilterFn } from '../../../../doc_views/doc_views_types'; -import { DiscoverGrid } from '../../../../components/discover_grid/discover_grid'; import { DiscoverTopNav } from '../top_nav/discover_topnav'; -import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; +import { DocViewFilterFn, ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { DiscoverChart } from '../chart'; import { getResultState } from '../../utils/get_result_state'; import { InspectorSession } from '../../../../../../../inspector/public'; import { DiscoverUninitialized } from '../uninitialized/uninitialized'; -import { SavedSearchDataMessage } from '../../services/use_saved_search'; +import { DataMainMsg } from '../../services/use_saved_search'; import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns'; +import { DiscoverDocuments } from './discover_documents'; import { FetchStatus } from '../../../../types'; +import { useDataState } from '../../utils/use_data_state'; -const DocTableLegacyMemoized = React.memo(DocTableLegacy); const SidebarMemoized = React.memo(DiscoverSidebarResponsive); -const DataGridMemoized = React.memo(DiscoverGrid); const TopNavMemoized = React.memo(DiscoverTopNav); const DiscoverChartMemoized = React.memo(DiscoverChart); -interface DiscoverLayoutFetchState extends SavedSearchDataMessage { - state: FetchStatus; - fetchCounter: number; - fieldCounts: Record; - rows: ElasticSearchHit[]; -} - export function DiscoverLayout({ indexPattern, indexPatternList, + inspectorAdapters, navigateTo, onChangeIndexPattern, onUpdateQuery, @@ -82,38 +67,22 @@ export function DiscoverLayout({ }: DiscoverLayoutProps) { const { trackUiMetric, capabilities, indexPatterns, data, uiSettings, filterManager } = services; - const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]); const [expandedDoc, setExpandedDoc] = useState(undefined); const [inspectorSession, setInspectorSession] = useState(undefined); - const scrollableDesktop = useRef(null); const collapseIcon = useRef(null); + const fetchCounter = useRef(0); + const { main$, charts$, totalHits$ } = savedSearchData$; - const [fetchState, setFetchState] = useState({ - state: savedSearchData$.getValue().state, - fetchCounter: 0, - fieldCounts: {}, - rows: [], - }); - const { state: fetchStatus, fetchCounter, inspectorAdapters, rows } = fetchState; + const dataState: DataMainMsg = useDataState(main$); useEffect(() => { - const subscription = savedSearchData$.subscribe((next) => { - if ( - (next.state && next.state !== fetchState.state) || - (next.fetchCounter && next.fetchCounter !== fetchState.fetchCounter) || - (next.rows && next.rows !== fetchState.rows) || - (next.chartData && next.chartData !== fetchState.chartData) - ) { - setFetchState({ ...fetchState, ...next }); - } - }); - return () => { - subscription.unsubscribe(); - }; - }, [savedSearchData$, fetchState]); + if (dataState.fetchStatus === FetchStatus.LOADING) { + fetchCounter.current++; + } + }, [dataState.fetchStatus]); // collapse icon isn't displayed in mobile view, use it to detect which view is displayed - const isMobile = () => collapseIcon && !collapseIcon.current; + const isMobile = useCallback(() => collapseIcon && !collapseIcon.current, []); const timeField = useMemo(() => { return indexPatternsUtils.isDefault(indexPattern) ? indexPattern.timeFieldName : undefined; }, [indexPattern]); @@ -122,27 +91,18 @@ export function DiscoverLayout({ const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); - const resultState = useMemo(() => getResultState(fetchStatus, rows!), [fetchStatus, rows]); - - const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useDataGridColumns({ - capabilities, - config: uiSettings, - indexPattern, - indexPatterns, - setAppState: stateContainer.setAppState, - state, - useNewFieldsApi, - }); + const resultState = useMemo( + () => getResultState(dataState.fetchStatus, dataState.foundDocuments!), + [dataState.fetchStatus, dataState.foundDocuments] + ); const onOpenInspector = useCallback(() => { // prevent overlapping - if (inspectorAdapters) { - setExpandedDoc(undefined); - const session = services.inspector.open(inspectorAdapters, { - title: savedSearch.title, - }); - setInspectorSession(session); - } + setExpandedDoc(undefined); + const session = services.inspector.open(inspectorAdapters, { + title: savedSearch.title, + }); + setInspectorSession(session); }, [setExpandedDoc, inspectorAdapters, savedSearch, services.inspector]); useEffect(() => { @@ -154,12 +114,15 @@ export function DiscoverLayout({ }; }, [inspectorSession]); - const onSort = useCallback( - (sort: string[][]) => { - stateContainer.setAppState({ sort }); - }, - [stateContainer] - ); + const { columns, onAddColumn, onRemoveColumn } = useDataGridColumns({ + capabilities, + config: uiSettings, + indexPattern, + indexPatterns, + setAppState: stateContainer.setAppState, + state, + useNewFieldsApi, + }); const onAddFilter = useCallback( (field: IndexPatternField | string, values: string, operation: '+' | '-') => { @@ -179,33 +142,6 @@ export function DiscoverLayout({ }, [filterManager, indexPattern, indexPatterns, trackUiMetric] ); - /** - * Legacy function, remove once legacy grid is removed - */ - const onBackToTop = useCallback(() => { - if (scrollableDesktop && scrollableDesktop.current) { - scrollableDesktop.current.focus(); - } - // Only the desktop one needs to target a specific container - if (!isMobile() && scrollableDesktop.current) { - scrollableDesktop.current.scrollTo(0, 0); - } else if (window) { - window.scrollTo(0, 0); - } - }, [scrollableDesktop]); - - const onResize = useCallback( - (colSettings: { columnId: string; width: number }) => { - const grid = { ...state.grid } || {}; - const newColumns = { ...grid.columns } || {}; - newColumns[colSettings.columnId] = { - width: colSettings.width, - }; - const newGrid = { ...grid, columns: newColumns }; - stateContainer.setAppState({ grid: newGrid }); - }, - [stateContainer, state] - ); const onEditRuntimeField = useCallback(() => { savedSearchRefetch$.next('reset'); @@ -219,14 +155,10 @@ export function DiscoverLayout({ }, [filterManager]); const contentCentered = resultState === 'uninitialized' || resultState === 'none'; - const showTimeCol = useMemo( - () => !uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false) && !!indexPattern.timeFieldName, - [uiSettings, indexPattern.timeFieldName] - ); return ( - + !f.meta.disabled).length > 0 @@ -320,82 +251,31 @@ export function DiscoverLayout({ > - - -
-

- -

- {isLegacy && rows && rows.length && ( - - )} - {!isLegacy && rows && rows.length && ( -
- -
- )} -
-
+ )} diff --git a/src/plugins/discover/public/application/apps/main/components/layout/types.ts b/src/plugins/discover/public/application/apps/main/components/layout/types.ts index 04744c56d14d4..03d4471db94b6 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/types.ts +++ b/src/plugins/discover/public/application/apps/main/components/layout/types.ts @@ -15,20 +15,22 @@ import { } from '../../../../../../../data/common'; import { ISearchSource } from '../../../../../../../data/public'; import { AppState, GetStateReturn } from '../../services/discover_state'; -import { SavedSearchRefetchSubject, SavedSearchDataSubject } from '../../services/use_saved_search'; +import { DataRefetch$, SavedSearchData } from '../../services/use_saved_search'; import { DiscoverServices } from '../../../../../build_services'; import { SavedSearch } from '../../../../../saved_searches'; +import { RequestAdapter } from '../../../../../../../inspector'; export interface DiscoverLayoutProps { indexPattern: IndexPattern; indexPatternList: Array>; + inspectorAdapters: { requests: RequestAdapter }; navigateTo: (url: string) => void; onChangeIndexPattern: (id: string) => void; onUpdateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; resetQuery: () => void; savedSearch: SavedSearch; - savedSearchData$: SavedSearchDataSubject; - savedSearchRefetch$: SavedSearchRefetchSubject; + savedSearchData$: SavedSearchData; + savedSearchRefetch$: DataRefetch$; searchSource: ISearchSource; services: DiscoverServices; state: AppState; diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx index 304ab05516f92..1e00e81f788e6 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx @@ -63,7 +63,7 @@ function getCompProps(): DiscoverSidebarProps { return { columns: ['extension'], fieldCounts, - hits, + documents: hits, indexPatternList, onChangeIndexPattern: jest.fn(), onAddFilter: jest.fn(), diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx index 7f8866a2ee369..938b0a49b29a7 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx @@ -36,13 +36,14 @@ import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './l import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive'; import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; +import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; /** * Default number of available fields displayed and added on scroll */ const FIELDS_PER_PAGE = 50; -export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps { +export interface DiscoverSidebarProps extends Omit { /** * Current state of the field filter, filtering fields by name, type, ... */ @@ -64,6 +65,15 @@ export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps { setFieldEditorRef?: (ref: () => void | undefined) => void; editField: (fieldName?: string) => void; + + /** + * a statistics of the distribution of fields in the given hits + */ + fieldCounts: Record; + /** + * hits fetched from ES, displayed in the doc table + */ + documents: ElasticSearchHit[]; } export function DiscoverSidebar({ @@ -71,7 +81,7 @@ export function DiscoverSidebar({ columns, fieldCounts, fieldFilter, - hits, + documents, indexPatternList, onAddField, onAddFilter, @@ -101,7 +111,7 @@ export function DiscoverSidebar({ useEffect(() => { const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts); setFields(newFields); - }, [selectedIndexPattern, fieldCounts, hits]); + }, [selectedIndexPattern, fieldCounts, documents]); const scrollDimensions = useResizeObserver(scrollContainer); @@ -115,8 +125,8 @@ export function DiscoverSidebar({ ); const getDetailsByField = useCallback( - (ipField: IndexPatternField) => getDetails(ipField, hits, columns, selectedIndexPattern), - [hits, columns, selectedIndexPattern] + (ipField: IndexPatternField) => getDetails(ipField, documents, columns, selectedIndexPattern), + [documents, columns, selectedIndexPattern] ); const popularLimit = useMemo(() => services.uiSettings.get(FIELDS_LIMIT_SETTING), [ diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx index 6973221fd3624..32f7656c73762 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -7,6 +7,7 @@ */ import { each, cloneDeep } from 'lodash'; +import { BehaviorSubject } from 'rxjs'; import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-expect-error @@ -25,6 +26,8 @@ import { } from './discover_sidebar_responsive'; import { DiscoverServices } from '../../../../../build_services'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; +import { FetchStatus } from '../../../../types'; +import { DataDocuments$ } from '../../services/use_saved_search'; const mockServices = ({ history: () => ({ @@ -86,8 +89,10 @@ function getCompProps(): DiscoverSidebarResponsiveProps { } return { columns: ['extension'], - fieldCounts, - hits, + documents$: new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: hits as ElasticSearchHit[], + }) as DataDocuments$, indexPatternList, onChangeIndexPattern: jest.fn(), onAddFilter: jest.fn(), diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx index 003bb22599e48..bbc2328e057d3 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { sortBy } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -33,9 +33,10 @@ import { IndexPatternField, IndexPattern } from '../../../../../../../data/publi import { getDefaultFieldFilter } from './lib/field_filter'; import { DiscoverSidebar } from './discover_sidebar'; import { DiscoverServices } from '../../../../../build_services'; -import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { AppState } from '../../services/discover_state'; import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; +import { DataDocuments$ } from '../../services/use_saved_search'; +import { calcFieldCounts } from '../../utils/calc_field_counts'; export interface DiscoverSidebarResponsiveProps { /** @@ -46,14 +47,10 @@ export interface DiscoverSidebarResponsiveProps { * the selected columns displayed in the doc table in discover */ columns: string[]; - /** - * a statistics of the distribution of fields in the given hits - */ - fieldCounts: Record; /** * hits fetched from ES, displayed in the doc table */ - hits: ElasticSearchHit[]; + documents$: DataDocuments$; /** * List of available index patterns */ @@ -119,6 +116,36 @@ export interface DiscoverSidebarResponsiveProps { export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) { const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter()); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + /** + * needed for merging new with old field counts, high likely legacy, but kept this behavior + * because not 100% sure in this case + */ + const fieldCounts = useRef>( + calcFieldCounts({}, props.documents$.getValue().result, props.selectedIndexPattern) + ); + + const [documentState, setDocumentState] = useState(props.documents$.getValue()); + useEffect(() => { + const subscription = props.documents$.subscribe((next) => { + if (next.fetchStatus !== documentState.fetchStatus) { + if (next.result) { + fieldCounts.current = calcFieldCounts( + next.result.length ? fieldCounts.current : {}, + next.result, + props.selectedIndexPattern! + ); + } + setDocumentState({ ...documentState, ...next }); + } + }); + return () => subscription.unsubscribe(); + }, [props.documents$, props.selectedIndexPattern, documentState, setDocumentState]); + + useEffect(() => { + // when index pattern changes fieldCounts needs to be cleaned up to prevent displaying + // fields of the previous index pattern + fieldCounts.current = {}; + }, [props.selectedIndexPattern]); const closeFieldEditor = useRef<() => void | undefined>(); @@ -134,17 +161,17 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) }; }, []); - if (!props.selectedIndexPattern) { - return null; - } - - const setFieldEditorRef = (ref: () => void | undefined) => { + const setFieldEditorRef = useCallback((ref: () => void | undefined) => { closeFieldEditor.current = ref; - }; + }, []); - const closeFlyout = () => { + const closeFlyout = useCallback(() => { setIsFlyoutVisible(false); - }; + }, []); + + if (!props.selectedIndexPattern) { + return null; + } const { indexPatternFieldEditor } = props.services; const indexPatternFieldEditPermission = indexPatternFieldEditor?.userPermissions.editIndexPattern(); @@ -177,7 +204,9 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) @@ -262,6 +291,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
; @@ -48,11 +102,16 @@ describe('timechart header', function () { }, ], onChangeInterval: jest.fn(), - bucketInterval: { - scaled: undefined, - description: 'second', - scale: undefined, - }, + + savedSearchData$: new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + chartData, + bucketInterval: { + scaled: false, + description: 'second', + scale: undefined, + }, + }) as DataCharts$, }; }); @@ -62,7 +121,15 @@ describe('timechart header', function () { }); it('TimechartHeader renders an info when bucketInterval.scale is set to true', () => { - props.bucketInterval!.scaled = true; + props.savedSearchData$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + chartData, + bucketInterval: { + scaled: true, + description: 'second', + scale: undefined, + }, + }) as DataCharts$; component = mountWithIntl(); expect(component.find(EuiIconTip).length).toBe(1); }); diff --git a/src/plugins/discover/public/application/apps/main/components/timechart_header/timechart_header.tsx b/src/plugins/discover/public/application/apps/main/components/timechart_header/timechart_header.tsx index ec94679ece675..5c0b12cfe2534 100644 --- a/src/plugins/discover/public/application/apps/main/components/timechart_header/timechart_header.tsx +++ b/src/plugins/discover/public/application/apps/main/components/timechart_header/timechart_header.tsx @@ -20,6 +20,8 @@ import { i18n } from '@kbn/i18n'; import dateMath from '@elastic/datemath'; import './timechart_header.scss'; import { DataPublicPluginStart } from '../../../../../../../data/public'; +import { DataCharts$, DataChartsMessage } from '../../services/use_saved_search'; +import { useDataState } from '../../utils/use_data_state'; export interface TimechartBucketInterval { scaled?: boolean; @@ -32,10 +34,7 @@ export interface TimechartHeaderProps { * Format of date to be displayed */ dateFormat?: string; - /** - * Interval for the buckets of the recent request - */ - bucketInterval?: TimechartBucketInterval; + data: DataPublicPluginStart; /** * Interval Options @@ -49,17 +48,23 @@ export interface TimechartHeaderProps { * selected interval */ stateInterval: string; + + savedSearchData$: DataCharts$; } export function TimechartHeader({ - bucketInterval, dateFormat, - data, + data: dataPluginStart, options, onChangeInterval, stateInterval, + savedSearchData$, }: TimechartHeaderProps) { - const { timefilter } = data.query.timefilter; + const { timefilter } = dataPluginStart.query.timefilter; + + const data: DataChartsMessage = useDataState(savedSearchData$); + + const { bucketInterval } = data; const { from, to } = timefilter.getTime(); const timeRange = { from: dateMath.parse(from), diff --git a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx index 07939fff6e7f4..0195421fd568d 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_app.tsx @@ -58,6 +58,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) { const { data$, indexPattern, + inspectorAdapters, onChangeIndexPattern, onUpdateQuery, refetch$, @@ -105,6 +106,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) { ; /** - * Function starting state sync when Discover main is loaded + * Initialize state with filters and query, start state syncing */ initializeAndSync: ( indexPattern: IndexPattern, diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts index 3c736f09a8296..1bf9710def03c 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts @@ -41,7 +41,7 @@ export function useDiscoverState({ const [indexPattern, setIndexPattern] = useState(initialIndexPattern); const [savedSearch, setSavedSearch] = useState(initialSavedSearch); const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); - const timefilter = data.query.timefilter.timefilter; + const { timefilter } = data.query.timefilter; const searchSource = useMemo(() => { savedSearch.searchSource.setField('index', indexPattern); @@ -88,8 +88,7 @@ export function useDiscoverState({ /** * Data fetching logic */ - const { data$, refetch$, reset } = useSavedSearchData({ - indexPattern, + const { data$, refetch$, reset, inspectorAdapters } = useSavedSearchData({ initialFetchStatus, searchSessionManager, searchSource, @@ -100,9 +99,7 @@ export function useDiscoverState({ useEffect(() => { const stopSync = stateContainer.initializeAndSync(indexPattern, filterManager, data); - return () => { - stopSync(); - }; + return () => stopSync(); }, [stateContainer, filterManager, data, indexPattern]); /** @@ -138,8 +135,11 @@ export function useDiscoverState({ setState(nextState); }); return () => unsubscribe(); - }, [config, indexPatterns, appStateContainer, setState, state, refetch$, data$, reset]); + }, [config, indexPatterns, appStateContainer, setState, state, refetch$, reset]); + /** + * function to revert any changes to a given saved search + */ const resetSavedSearch = useCallback( async (id?: string) => { const newSavedSearch = await services.getSavedSearchById(id); @@ -201,11 +201,12 @@ export function useDiscoverState({ if (initialFetchStatus === FetchStatus.LOADING) { refetch$.next(); } - }, [initialFetchStatus, refetch$, indexPattern, data$]); + }, [initialFetchStatus, refetch$, indexPattern]); return { data$, indexPattern, + inspectorAdapters, refetch$, resetSavedSearch, onChangeIndexPattern, diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts index 128c94f284f56..f4d05e551a4a4 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.test.ts @@ -28,7 +28,6 @@ describe('test useSavedSearch', () => { const { result } = renderHook(() => { return useSavedSearch({ - indexPattern: indexPatternMock, initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: savedSearchMock.searchSource.createCopy(), @@ -39,11 +38,10 @@ describe('test useSavedSearch', () => { }); expect(result.current.refetch$).toBeInstanceOf(Subject); - expect(result.current.data$.value).toMatchInlineSnapshot(` - Object { - "state": "loading", - } - `); + expect(result.current.data$.main$.getValue().fetchStatus).toBe(FetchStatus.LOADING); + expect(result.current.data$.documents$.getValue().fetchStatus).toBe(FetchStatus.LOADING); + expect(result.current.data$.totalHits$.getValue().fetchStatus).toBe(FetchStatus.LOADING); + expect(result.current.data$.charts$.getValue().fetchStatus).toBe(FetchStatus.LOADING); }); test('refetch$ triggers a search', async () => { const { history, searchSessionManager } = createSearchSessionMock(); @@ -68,7 +66,6 @@ describe('test useSavedSearch', () => { const { result, waitForValueToChange } = renderHook(() => { return useSavedSearch({ - indexPattern: indexPatternMock, initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: resultState.current.searchSource, @@ -81,11 +78,11 @@ describe('test useSavedSearch', () => { result.current.refetch$.next(); await waitForValueToChange(() => { - return result.current.data$.value.state === 'complete'; + return result.current.data$.main$.value.fetchStatus === 'complete'; }); - expect(result.current.data$.value.hits).toBe(0); - expect(result.current.data$.value.rows).toEqual([]); + expect(result.current.data$.totalHits$.value.result).toBe(0); + expect(result.current.data$.documents$.value.result).toEqual([]); }); test('reset sets back to initial state', async () => { @@ -111,7 +108,6 @@ describe('test useSavedSearch', () => { const { result, waitForValueToChange } = renderHook(() => { return useSavedSearch({ - indexPattern: indexPatternMock, initialFetchStatus: FetchStatus.LOADING, searchSessionManager, searchSource: resultState.current.searchSource, @@ -124,10 +120,10 @@ describe('test useSavedSearch', () => { result.current.refetch$.next(); await waitForValueToChange(() => { - return result.current.data$.value.state === FetchStatus.COMPLETE; + return result.current.data$.main$.value.fetchStatus === FetchStatus.COMPLETE; }); result.current.reset(); - expect(result.current.data$.value.state).toBe(FetchStatus.LOADING); + expect(result.current.data$.main$.value.fetchStatus).toBe(FetchStatus.LOADING); }); }); diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts index b449d35dca9ad..8ed6667f8acce 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts @@ -5,53 +5,71 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useEffect, useRef, useCallback } from 'react'; -import { i18n } from '@kbn/i18n'; -import { merge, Subject, BehaviorSubject } from 'rxjs'; -import { debounceTime, tap, filter } from 'rxjs/operators'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { BehaviorSubject, merge, Subject } from 'rxjs'; +import { debounceTime, filter, tap } from 'rxjs/operators'; import { DiscoverServices } from '../../../../build_services'; import { DiscoverSearchSessionManager } from './discover_search_session'; -import { - IndexPattern, - isCompleteResponse, - SearchSource, - tabifyAggResponse, -} from '../../../../../../data/common'; +import { SearchSource } from '../../../../../../data/common'; import { GetStateReturn } from './discover_state'; import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { RequestAdapter } from '../../../../../../inspector/public'; -import { AutoRefreshDoneFn, search } from '../../../../../../data/public'; -import { calcFieldCounts } from '../utils/calc_field_counts'; +import { AutoRefreshDoneFn } from '../../../../../../data/public'; import { validateTimeRange } from '../utils/validate_time_range'; -import { updateSearchSource } from '../utils/update_search_source'; -import { SortOrder } from '../../../../saved_searches/types'; -import { getDimensions, getChartAggConfigs } from '../utils'; -import { buildPointSeriesData, Chart } from '../components/chart/point_series'; +import { Chart } from '../components/chart/point_series'; import { TimechartBucketInterval } from '../components/timechart_header/timechart_header'; import { useSingleton } from '../utils/use_singleton'; import { FetchStatus } from '../../../types'; -export type SavedSearchDataSubject = BehaviorSubject; -export type SavedSearchRefetchSubject = Subject; +import { fetchAll } from '../utils/fetch_all'; +import { useBehaviorSubject } from '../utils/use_behavior_subject'; +import { sendResetMsg } from './use_saved_search_messages'; + +export interface SavedSearchData { + main$: DataMain$; + documents$: DataDocuments$; + totalHits$: DataTotalHits$; + charts$: DataCharts$; +} + +export type DataMain$ = BehaviorSubject; +export type DataDocuments$ = BehaviorSubject; +export type DataTotalHits$ = BehaviorSubject; +export type DataCharts$ = BehaviorSubject; + +export type DataRefetch$ = Subject; export interface UseSavedSearch { - refetch$: SavedSearchRefetchSubject; - data$: SavedSearchDataSubject; + refetch$: DataRefetch$; + data$: SavedSearchData; reset: () => void; + inspectorAdapters: { requests: RequestAdapter }; } -export type SavedSearchRefetchMsg = 'reset' | undefined; +export type DataRefetchMsg = 'reset' | undefined; + +export interface DataMsg { + fetchStatus: FetchStatus; + error?: Error; +} + +export interface DataMainMsg extends DataMsg { + foundDocuments?: boolean; +} -export interface SavedSearchDataMessage { +export interface DataDocumentsMsg extends DataMsg { + result?: ElasticSearchHit[]; +} + +export interface DataTotalHitsMsg extends DataMsg { + fetchStatus: FetchStatus; + error?: Error; + result?: number; +} + +export interface DataChartsMessage extends DataMsg { bucketInterval?: TimechartBucketInterval; chartData?: Chart; - fetchCounter?: number; - fetchError?: Error; - fieldCounts?: Record; - hits?: number; - inspectorAdapters?: { requests: RequestAdapter }; - rows?: ElasticSearchHit[]; - state: FetchStatus; } /** @@ -59,7 +77,6 @@ export interface SavedSearchDataMessage { * to the data fetching */ export const useSavedSearch = ({ - indexPattern, initialFetchStatus, searchSessionManager, searchSource, @@ -67,244 +84,148 @@ export const useSavedSearch = ({ stateContainer, useNewFieldsApi, }: { - indexPattern: IndexPattern; initialFetchStatus: FetchStatus; searchSessionManager: DiscoverSearchSessionManager; searchSource: SearchSource; services: DiscoverServices; stateContainer: GetStateReturn; useNewFieldsApi: boolean; -}): UseSavedSearch => { +}) => { const { data, filterManager } = services; const timefilter = data.query.timefilter.timefilter; + const inspectorAdapters = useMemo(() => ({ requests: new RequestAdapter() }), []); + /** - * The observable the UI (aka React component) subscribes to get notified about + * The observables the UI (aka React component) subscribes to get notified about * the changes in the data fetching process (high level: fetching started, data was received) */ - const data$: SavedSearchDataSubject = useSingleton( - () => - new BehaviorSubject({ - state: initialFetchStatus, - }) - ); + const main$: DataMain$ = useBehaviorSubject({ fetchStatus: initialFetchStatus }); + + const documents$: DataDocuments$ = useBehaviorSubject({ fetchStatus: initialFetchStatus }); + + const totalHits$: DataTotalHits$ = useBehaviorSubject({ fetchStatus: initialFetchStatus }); + + const charts$: DataCharts$ = useBehaviorSubject({ fetchStatus: initialFetchStatus }); + + const dataSubjects = useMemo(() => { + return { + main$, + documents$, + totalHits$, + charts$, + }; + }, [main$, charts$, documents$, totalHits$]); + /** * The observable to trigger data fetching in UI * By refetch$.next('reset') rows and fieldcounts are reset to allow e.g. editing of runtime fields * to be processed correctly */ - const refetch$ = useSingleton(() => new Subject()); + const refetch$ = useSingleton(() => new Subject()); /** * Values that shouldn't trigger re-rendering when changed */ const refs = useRef<{ abortController?: AbortController; - /** - * handler emitted by `timefilter.getAutoRefreshFetch$()` - * to notify when data completed loading and to start a new autorefresh loop - */ - autoRefreshDoneCb?: AutoRefreshDoneFn; - /** - * Number of fetches used for functional testing - */ - fetchCounter: number; - /** - * needed to right auto refresh behavior, a new auto refresh shouldnt be triggered when - * loading is still ongoing - */ - fetchStatus: FetchStatus; - /** - * needed for merging new with old field counts, high likely legacy, but kept this behavior - * because not 100% sure in this case - */ - fieldCounts: Record; - }>({ - fetchCounter: 0, - fieldCounts: {}, - fetchStatus: initialFetchStatus, - }); - - /** - * Resets the fieldCounts cache and sends a reset message - * It is set to initial state (no documents, fetchCounter to 0) - * Needed when index pattern is switched or a new runtime field is added - */ - const sendResetMsg = useCallback( - (fetchStatus?: FetchStatus) => { - refs.current.fieldCounts = {}; - refs.current.fetchStatus = fetchStatus ?? initialFetchStatus; - data$.next({ - state: initialFetchStatus, - fetchCounter: 0, - rows: [], - fieldCounts: {}, - chartData: undefined, - bucketInterval: undefined, - }); - }, - [data$, initialFetchStatus] - ); - /** - * Function to fetch data from ElasticSearch - */ - const fetchAll = useCallback( - (reset = false) => { - if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { - return Promise.reject(); - } - const inspectorAdapters = { requests: new RequestAdapter() }; - - if (refs.current.abortController) refs.current.abortController.abort(); - refs.current.abortController = new AbortController(); - const sessionId = searchSessionManager.getNextSearchSessionId(); - - if (reset) { - sendResetMsg(FetchStatus.LOADING); - } else { - // Let the UI know, data fetching started - data$.next({ - state: FetchStatus.LOADING, - fetchCounter: ++refs.current.fetchCounter, - }); - refs.current.fetchStatus = FetchStatus.LOADING; - } - - const { sort, hideChart, interval } = stateContainer.appStateContainer.getState(); - updateSearchSource(searchSource, false, { - indexPattern, - services, - sort: sort as SortOrder[], - useNewFieldsApi, - }); - const chartAggConfigs = - indexPattern.timeFieldName && !hideChart && interval - ? getChartAggConfigs(searchSource, interval, data) - : undefined; - - if (!chartAggConfigs) { - searchSource.removeField('aggs'); - } else { - searchSource.setField('aggs', chartAggConfigs.toDsl()); - } - - const searchSourceFetch$ = searchSource.fetch$({ - abortSignal: refs.current.abortController.signal, - sessionId, - inspector: { - adapter: inspectorAdapters.requests, - title: i18n.translate('discover.inspectorRequestDataTitle', { - defaultMessage: 'data', - }), - description: i18n.translate('discover.inspectorRequestDescriptionDocument', { - defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', - }), - }, - }); - - searchSourceFetch$.pipe(filter((res) => isCompleteResponse(res))).subscribe( - (res) => { - const documents = res.rawResponse.hits.hits; - - const message: SavedSearchDataMessage = { - state: FetchStatus.COMPLETE, - rows: documents, - inspectorAdapters, - fieldCounts: calcFieldCounts(refs.current.fieldCounts, documents, indexPattern), - hits: res.rawResponse.hits.total as number, - }; - - if (chartAggConfigs) { - const bucketAggConfig = chartAggConfigs.aggs[1]; - const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse); - const dimensions = getDimensions(chartAggConfigs, data); - if (dimensions) { - if (bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)) { - message.bucketInterval = bucketAggConfig.buckets?.getInterval(); - } - message.chartData = buildPointSeriesData(tabifiedData, dimensions); - } - } - refs.current.fieldCounts = message.fieldCounts!; - refs.current.fetchStatus = message.state; - data$.next(message); - }, - (error) => { - if (error instanceof Error && error.name === 'AbortError') return; - data.search.showError(error); - refs.current.fetchStatus = FetchStatus.ERROR; - data$.next({ - state: FetchStatus.ERROR, - inspectorAdapters, - fetchError: error, - }); - }, - () => { - refs.current.autoRefreshDoneCb?.(); - refs.current.autoRefreshDoneCb = undefined; - } - ); - }, - [ - timefilter, - services, - searchSessionManager, - stateContainer.appStateContainer, - searchSource, - indexPattern, - useNewFieldsApi, - data, - sendResetMsg, - data$, - ] - ); + }>({}); /** * This part takes care of triggering the data fetching by creating and subscribing * to an observable of various possible changes in state */ useEffect(() => { + /** + * handler emitted by `timefilter.getAutoRefreshFetch$()` + * to notify when data completed loading and to start a new autorefresh loop + */ + let autoRefreshDoneCb: AutoRefreshDoneFn | undefined; const fetch$ = merge( refetch$, filterManager.getFetches$(), timefilter.getFetch$(), timefilter.getAutoRefreshFetch$().pipe( tap((done) => { - refs.current.autoRefreshDoneCb = done; + autoRefreshDoneCb = done; }), - filter(() => refs.current.fetchStatus !== FetchStatus.LOADING) + filter(() => { + /** + * filter to prevent auto-refresh triggered fetch when + * loading is still ongoing + */ + const currentFetchStatus = main$.getValue().fetchStatus; + return ( + currentFetchStatus !== FetchStatus.LOADING && currentFetchStatus !== FetchStatus.PARTIAL + ); + }) ), data.query.queryString.getUpdates$(), searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId)) ).pipe(debounceTime(100)); const subscription = fetch$.subscribe((val) => { + if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { + return; + } + inspectorAdapters.requests.reset(); + + refs.current.abortController?.abort(); + refs.current.abortController = new AbortController(); try { - fetchAll(val === 'reset'); + fetchAll(dataSubjects, searchSource, val === 'reset', { + abortController: refs.current.abortController, + appStateContainer: stateContainer.appStateContainer, + inspectorAdapters, + data, + initialFetchStatus, + searchSessionId: searchSessionManager.getNextSearchSessionId(), + services, + useNewFieldsApi, + }).subscribe({ + complete: () => { + // if this function was set and is executed, another refresh fetch can be triggered + autoRefreshDoneCb?.(); + autoRefreshDoneCb = undefined; + }, + }); } catch (error) { - data$.next({ - state: FetchStatus.ERROR, - fetchError: error, + main$.next({ + fetchStatus: FetchStatus.ERROR, + error, }); } }); - return () => { - subscription.unsubscribe(); - }; + return () => subscription.unsubscribe(); }, [ - data$, + data, data.query.queryString, + dataSubjects, filterManager, + initialFetchStatus, + inspectorAdapters, + main$, refetch$, + searchSessionManager, searchSessionManager.newSearchSessionIdFromURL$, + searchSource, + services, + services.toastNotifications, + stateContainer.appStateContainer, timefilter, - fetchAll, + useNewFieldsApi, + ]); + + const reset = useCallback(() => sendResetMsg(dataSubjects, initialFetchStatus), [ + dataSubjects, + initialFetchStatus, ]); return { refetch$, - data$, - reset: sendResetMsg, + data$: dataSubjects, + reset, + inspectorAdapters, }; }; diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search_messages.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search_messages.ts new file mode 100644 index 0000000000000..b42d699f344cc --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search_messages.ts @@ -0,0 +1,90 @@ +/* + * Copyright 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 { FetchStatus } from '../../../types'; +import { + DataCharts$, + DataDocuments$, + DataMain$, + DataTotalHits$, + SavedSearchData, +} from './use_saved_search'; + +/** + * Send COMPLETE message via main observable used when + * 1.) first fetch resolved, and there are no documents + * 2.) all fetches resolved, and there are documents + */ +export function sendCompleteMsg(main$: DataMain$, foundDocuments = true) { + if (main$.getValue().fetchStatus === FetchStatus.COMPLETE) { + return; + } + main$.next({ + fetchStatus: FetchStatus.COMPLETE, + foundDocuments, + }); +} + +/** + * Send PARTIAL message via main observable when first result is returned + */ +export function sendPartialMsg(main$: DataMain$) { + if (main$.getValue().fetchStatus === FetchStatus.LOADING) { + main$.next({ + fetchStatus: FetchStatus.PARTIAL, + }); + } +} + +/** + * Send LOADING message via main observable + */ +export function sendLoadingMsg(data$: DataMain$ | DataDocuments$ | DataTotalHits$ | DataCharts$) { + if (data$.getValue().fetchStatus !== FetchStatus.LOADING) { + data$.next({ + fetchStatus: FetchStatus.LOADING, + }); + } +} + +/** + * Send ERROR message + */ +export function sendErrorMsg( + data$: DataMain$ | DataDocuments$ | DataTotalHits$ | DataCharts$, + error: Error +) { + data$.next({ + fetchStatus: FetchStatus.ERROR, + error, + }); +} + +/** + * Sends a RESET message to all data subjects + * Needed when index pattern is switched or a new runtime field is added + */ +export function sendResetMsg(data: SavedSearchData, initialFetchStatus: FetchStatus) { + data.main$.next({ + fetchStatus: initialFetchStatus, + foundDocuments: undefined, + }); + data.documents$.next({ + fetchStatus: initialFetchStatus, + result: [], + }); + data.charts$.next({ + fetchStatus: initialFetchStatus, + chartData: undefined, + bucketInterval: undefined, + }); + data.totalHits$.next({ + fetchStatus: initialFetchStatus, + result: undefined, + }); +} diff --git a/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts b/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts index 43c9a57a70f22..57178776a97d4 100644 --- a/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts +++ b/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts @@ -15,9 +15,12 @@ import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; */ export function calcFieldCounts( counts = {} as Record, - rows: ElasticSearchHit[], - indexPattern: IndexPattern + rows?: ElasticSearchHit[], + indexPattern?: IndexPattern ) { + if (!rows || !indexPattern) { + return {}; + } for (const hit of rows) { const fields = Object.keys(indexPattern.flattenHit(hit)); for (const fieldName of fields) { diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_all.test.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_all.test.ts new file mode 100644 index 0000000000000..88830b2946b5f --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_all.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { FetchStatus } from '../../../types'; +import { BehaviorSubject } from 'rxjs'; +import { RequestAdapter } from '../../../../../../inspector'; +import { savedSearchMock } from '../../../../__mocks__/saved_search'; +import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common'; +import { AppState } from '../services/discover_state'; +import { discoverServiceMock } from '../../../../__mocks__/services'; +import { fetchAll } from './fetch_all'; + +describe('test fetchAll', () => { + test('changes of fetchStatus when starting with FetchStatus.UNINITIALIZED', async (done) => { + const subjects = { + main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + }; + const deps = { + appStateContainer: { + getState: () => { + return { interval: 'auto' }; + }, + } as ReduxLikeStateContainer, + abortController: new AbortController(), + data: discoverServiceMock.data, + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + initialFetchStatus: FetchStatus.UNINITIALIZED, + useNewFieldsApi: true, + services: discoverServiceMock, + }; + + const stateArr: FetchStatus[] = []; + + subjects.main$.subscribe((value) => stateArr.push(value.fetchStatus)); + + const parentSearchSource = savedSearchMock.searchSource; + const childSearchSource = parentSearchSource.createChild(); + + fetchAll(subjects, childSearchSource, false, deps).subscribe({ + complete: () => { + expect(stateArr).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.COMPLETE, + ]); + done(); + }, + }); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_all.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_all.ts new file mode 100644 index 0000000000000..53d13ee547b0f --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_all.ts @@ -0,0 +1,96 @@ +/* + * Copyright 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 { forkJoin, of } from 'rxjs'; +import { + sendCompleteMsg, + sendErrorMsg, + sendLoadingMsg, + sendPartialMsg, + sendResetMsg, +} from '../services/use_saved_search_messages'; +import { updateSearchSource } from './update_search_source'; +import { SortOrder } from '../../../../saved_searches/types'; +import { fetchDocuments } from './fetch_documents'; +import { fetchTotalHits } from './fetch_total_hits'; +import { fetchChart } from './fetch_chart'; +import { SearchSource } from '../../../../../../data/common'; +import { Adapters } from '../../../../../../inspector'; +import { AppState } from '../services/discover_state'; +import { FetchStatus } from '../../../types'; +import { DataPublicPluginStart } from '../../../../../../data/public'; +import { SavedSearchData } from '../services/use_saved_search'; +import { DiscoverServices } from '../../../../build_services'; +import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common'; + +export function fetchAll( + dataSubjects: SavedSearchData, + searchSource: SearchSource, + reset = false, + fetchDeps: { + abortController: AbortController; + appStateContainer: ReduxLikeStateContainer; + inspectorAdapters: Adapters; + data: DataPublicPluginStart; + initialFetchStatus: FetchStatus; + searchSessionId: string; + services: DiscoverServices; + useNewFieldsApi: boolean; + } +) { + const { initialFetchStatus, appStateContainer, services, useNewFieldsApi, data } = fetchDeps; + + const indexPattern = searchSource.getField('index')!; + + if (reset) { + sendResetMsg(dataSubjects, initialFetchStatus); + } + + sendLoadingMsg(dataSubjects.main$); + + const { hideChart, sort } = appStateContainer.getState(); + // Update the base searchSource, base for all child fetches + updateSearchSource(searchSource, false, { + indexPattern, + services, + sort: sort as SortOrder[], + useNewFieldsApi, + }); + + const subFetchDeps = { + ...fetchDeps, + onResults: (foundDocuments: boolean) => { + if (!foundDocuments) { + sendCompleteMsg(dataSubjects.main$, foundDocuments); + } else { + sendPartialMsg(dataSubjects.main$); + } + }, + }; + + const all = forkJoin({ + documents: fetchDocuments(dataSubjects, searchSource.createCopy(), subFetchDeps), + totalHits: + hideChart || !indexPattern.timeFieldName + ? fetchTotalHits(dataSubjects, searchSource.createCopy(), subFetchDeps) + : of(null), + chart: + !hideChart && indexPattern.timeFieldName + ? fetchChart(dataSubjects, searchSource.createCopy(), subFetchDeps) + : of(null), + }); + + all.subscribe( + () => sendCompleteMsg(dataSubjects.main$, true), + (error) => { + if (error instanceof Error && error.name === 'AbortError') return; + data.search.showError(error); + sendErrorMsg(dataSubjects.main$, error); + } + ); + return all; +} diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_chart.test.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_chart.test.ts new file mode 100644 index 0000000000000..07228bf0e4c62 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_chart.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright 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 { FetchStatus } from '../../../types'; +import { BehaviorSubject, of, throwError as throwErrorRx } from 'rxjs'; +import { RequestAdapter } from '../../../../../../inspector'; +import { savedSearchMockWithTimeField } from '../../../../__mocks__/saved_search'; +import { fetchChart, updateSearchSource } from './fetch_chart'; +import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common'; +import { AppState } from '../services/discover_state'; +import { discoverServiceMock } from '../../../../__mocks__/services'; +import { calculateBounds, IKibanaSearchResponse } from '../../../../../../data/common'; +import { estypes } from '@elastic/elasticsearch'; + +function getDataSubjects() { + return { + main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + }; +} + +describe('test fetchCharts', () => { + test('updateSearchSource helper function', () => { + const chartAggConfigs = updateSearchSource( + savedSearchMockWithTimeField.searchSource, + 'auto', + discoverServiceMock.data + ); + expect(chartAggConfigs.aggs).toMatchInlineSnapshot(` + Array [ + Object { + "enabled": true, + "id": "1", + "params": Object {}, + "schema": "metric", + "type": "count", + }, + Object { + "enabled": true, + "id": "2", + "params": Object { + "drop_partials": false, + "extended_bounds": Object {}, + "field": "timestamp", + "interval": "auto", + "min_doc_count": 1, + "scaleMetricValues": false, + "useNormalizedEsInterval": true, + "used_interval": "0ms", + }, + "schema": "segment", + "type": "date_histogram", + }, + ] + `); + }); + + test('changes of fetchStatus when starting with FetchStatus.UNINITIALIZED', async (done) => { + const subjects = getDataSubjects(); + const deps = { + appStateContainer: { + getState: () => { + return { interval: 'auto' }; + }, + } as ReduxLikeStateContainer, + abortController: new AbortController(), + data: discoverServiceMock.data, + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + }; + deps.data.query.timefilter.timefilter.getTime = () => { + return { from: '2021-07-07T00:05:13.590', to: '2021-07-07T11:20:13.590' }; + }; + + deps.data.query.timefilter.timefilter.calculateBounds = (timeRange) => + calculateBounds(timeRange); + + const stateArrChart: FetchStatus[] = []; + const stateArrHits: FetchStatus[] = []; + + subjects.charts$.subscribe((value) => stateArrChart.push(value.fetchStatus)); + subjects.totalHits$.subscribe((value) => stateArrHits.push(value.fetchStatus)); + + savedSearchMockWithTimeField.searchSource.fetch$ = () => + of(({ + id: 'Fjk5bndxTHJWU2FldVRVQ0tYR0VqOFEcRWtWNDhOdG5SUzJYcFhONVVZVTBJQToxMDMwOQ==', + rawResponse: { + took: 2, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { max_score: null, hits: [] }, + aggregations: { + '2': { + buckets: [ + { + key_as_string: '2021-07-07T06:36:00.000+02:00', + key: 1625632560000, + doc_count: 1, + }, + ], + }, + }, + }, + isPartial: false, + isRunning: false, + total: 1, + loaded: 1, + isRestored: false, + } as unknown) as IKibanaSearchResponse>); + + fetchChart(subjects, savedSearchMockWithTimeField.searchSource, deps).subscribe({ + complete: () => { + expect(stateArrChart).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.COMPLETE, + ]); + expect(stateArrHits).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.COMPLETE, + ]); + done(); + }, + }); + }); + test('change of fetchStatus on fetch error', async (done) => { + const subjects = getDataSubjects(); + + const deps = { + appStateContainer: { + getState: () => { + return { interval: 'auto' }; + }, + } as ReduxLikeStateContainer, + abortController: new AbortController(), + data: discoverServiceMock.data, + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + }; + + savedSearchMockWithTimeField.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' }); + + const stateArrChart: FetchStatus[] = []; + const stateArrHits: FetchStatus[] = []; + + subjects.charts$.subscribe((value) => stateArrChart.push(value.fetchStatus)); + subjects.totalHits$.subscribe((value) => stateArrHits.push(value.fetchStatus)); + + fetchChart(subjects, savedSearchMockWithTimeField.searchSource, deps).subscribe({ + error: () => { + expect(stateArrChart).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.ERROR, + ]); + expect(stateArrHits).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.ERROR, + ]); + done(); + }, + }); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_chart.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_chart.ts new file mode 100644 index 0000000000000..25a2a964a778f --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_chart.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import { filter } from 'rxjs/operators'; +import { + DataPublicPluginStart, + isCompleteResponse, + search, + SearchSource, +} from '../../../../../../data/public'; +import { Adapters } from '../../../../../../inspector'; +import { getChartAggConfigs, getDimensions } from './index'; +import { tabifyAggResponse } from '../../../../../../data/common'; +import { buildPointSeriesData } from '../components/chart/point_series'; +import { FetchStatus } from '../../../types'; +import { SavedSearchData } from '../services/use_saved_search'; +import { AppState } from '../services/discover_state'; +import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common'; +import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messages'; + +export function fetchChart( + data$: SavedSearchData, + searchSource: SearchSource, + { + abortController, + appStateContainer, + data, + inspectorAdapters, + onResults, + searchSessionId, + }: { + abortController: AbortController; + appStateContainer: ReduxLikeStateContainer; + data: DataPublicPluginStart; + inspectorAdapters: Adapters; + onResults: (foundDocuments: boolean) => void; + searchSessionId: string; + } +) { + const { charts$, totalHits$ } = data$; + + const interval = appStateContainer.getState().interval ?? 'auto'; + const chartAggConfigs = updateSearchSource(searchSource, interval, data); + + sendLoadingMsg(charts$); + sendLoadingMsg(totalHits$); + + const fetch$ = searchSource + .fetch$({ + abortSignal: abortController.signal, + sessionId: searchSessionId, + inspector: { + adapter: inspectorAdapters.requests, + title: i18n.translate('discover.inspectorRequestDataTitleChart', { + defaultMessage: 'Chart data', + }), + description: i18n.translate('discover.inspectorRequestDescriptionChart', { + defaultMessage: + 'This request queries Elasticsearch to fetch the aggregation data for the chart.', + }), + }, + }) + .pipe(filter((res) => isCompleteResponse(res))); + + fetch$.subscribe( + (res) => { + try { + const totalHitsNr = res.rawResponse.hits.total as number; + totalHits$.next({ fetchStatus: FetchStatus.COMPLETE, result: totalHitsNr }); + onResults(totalHitsNr > 0); + + const bucketAggConfig = chartAggConfigs.aggs[1]; + const tabifiedData = tabifyAggResponse(chartAggConfigs, res.rawResponse); + const dimensions = getDimensions(chartAggConfigs, data); + const bucketInterval = search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig) + ? bucketAggConfig?.buckets?.getInterval() + : undefined; + const chartData = buildPointSeriesData(tabifiedData, dimensions!); + charts$.next({ + fetchStatus: FetchStatus.COMPLETE, + chartData, + bucketInterval, + }); + } catch (e) { + charts$.next({ + fetchStatus: FetchStatus.ERROR, + error: e, + }); + } + }, + (error) => { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + sendErrorMsg(charts$, error); + sendErrorMsg(totalHits$, error); + } + ); + return fetch$; +} + +export function updateSearchSource( + searchSource: SearchSource, + interval: string, + data: DataPublicPluginStart +) { + const indexPattern = searchSource.getField('index')!; + searchSource.setField('filter', data.query.timefilter.timefilter.createFilter(indexPattern)); + searchSource.setField('size', 0); + searchSource.setField('trackTotalHits', true); + const chartAggConfigs = getChartAggConfigs(searchSource, interval, data); + searchSource.setField('aggs', chartAggConfigs.toDsl()); + searchSource.removeField('sort'); + searchSource.removeField('fields'); + return chartAggConfigs; +} diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_documents.test.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_documents.test.ts new file mode 100644 index 0000000000000..6c6c7595b166e --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_documents.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { fetchDocuments } from './fetch_documents'; +import { FetchStatus } from '../../../types'; +import { BehaviorSubject, throwError as throwErrorRx } from 'rxjs'; +import { RequestAdapter } from '../../../../../../inspector'; +import { savedSearchMock } from '../../../../__mocks__/saved_search'; +import { discoverServiceMock } from '../../../../__mocks__/services'; + +function getDataSubjects() { + return { + main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + }; +} + +describe('test fetchDocuments', () => { + test('changes of fetchStatus are correct when starting with FetchStatus.UNINITIALIZED', async (done) => { + const subjects = getDataSubjects(); + const { documents$ } = subjects; + const deps = { + abortController: new AbortController(), + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + services: discoverServiceMock, + }; + + const stateArr: FetchStatus[] = []; + + documents$.subscribe((value) => stateArr.push(value.fetchStatus)); + + fetchDocuments(subjects, savedSearchMock.searchSource, deps).subscribe({ + complete: () => { + expect(stateArr).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.COMPLETE, + ]); + done(); + }, + }); + }); + test('change of fetchStatus on fetch error', async (done) => { + const subjects = getDataSubjects(); + const { documents$ } = subjects; + const deps = { + abortController: new AbortController(), + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + services: discoverServiceMock, + }; + + savedSearchMock.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' }); + + const stateArr: FetchStatus[] = []; + + documents$.subscribe((value) => stateArr.push(value.fetchStatus)); + + fetchDocuments(subjects, savedSearchMock.searchSource, deps).subscribe({ + error: () => { + expect(stateArr).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.ERROR, + ]); + done(); + }, + }); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_documents.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_documents.ts new file mode 100644 index 0000000000000..edaf86cef6874 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_documents.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import { filter } from 'rxjs/operators'; +import { Adapters } from '../../../../../../inspector/common'; +import { isCompleteResponse, SearchSource } from '../../../../../../data/common'; +import { FetchStatus } from '../../../types'; +import { SavedSearchData } from '../services/use_saved_search'; +import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messages'; +import { SAMPLE_SIZE_SETTING } from '../../../../../common'; +import { DiscoverServices } from '../../../../build_services'; + +export const fetchDocuments = ( + data$: SavedSearchData, + searchSource: SearchSource, + { + abortController, + inspectorAdapters, + onResults, + searchSessionId, + services, + }: { + abortController: AbortController; + inspectorAdapters: Adapters; + onResults: (foundDocuments: boolean) => void; + searchSessionId: string; + services: DiscoverServices; + } +) => { + const { documents$, totalHits$ } = data$; + + searchSource.setField('size', services.uiSettings.get(SAMPLE_SIZE_SETTING)); + searchSource.setField('trackTotalHits', false); + searchSource.setField('highlightAll', true); + searchSource.setField('version', true); + + sendLoadingMsg(documents$); + + const fetch$ = searchSource + .fetch$({ + abortSignal: abortController.signal, + sessionId: searchSessionId, + inspector: { + adapter: inspectorAdapters.requests, + title: i18n.translate('discover.inspectorRequestDataTitleDocuments', { + defaultMessage: 'Documents', + }), + description: i18n.translate('discover.inspectorRequestDescriptionDocument', { + defaultMessage: 'This request queries Elasticsearch to fetch the documents.', + }), + }, + }) + .pipe(filter((res) => isCompleteResponse(res))); + + fetch$.subscribe( + (res) => { + const documents = res.rawResponse.hits.hits; + + // If the total hits query is still loading for hits, emit a partial + // hit count that's at least our document count + if (totalHits$.getValue().fetchStatus === FetchStatus.LOADING) { + totalHits$.next({ + fetchStatus: FetchStatus.PARTIAL, + result: documents.length, + }); + } + + documents$.next({ + fetchStatus: FetchStatus.COMPLETE, + result: documents, + }); + onResults(documents.length > 0); + }, + (error) => { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + + sendErrorMsg(documents$, error); + } + ); + return fetch$; +}; diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.test.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.test.ts new file mode 100644 index 0000000000000..82a3a2fee6912 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 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 { FetchStatus } from '../../../types'; +import { BehaviorSubject, throwError as throwErrorRx } from 'rxjs'; +import { RequestAdapter } from '../../../../../../inspector'; +import { savedSearchMock } from '../../../../__mocks__/saved_search'; +import { fetchTotalHits } from './fetch_total_hits'; +import { discoverServiceMock } from '../../../../__mocks__/services'; + +function getDataSubjects() { + return { + main$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + documents$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + totalHits$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + charts$: new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }), + }; +} + +describe('test fetchTotalHits', () => { + test('changes of fetchStatus are correct when starting with FetchStatus.UNINITIALIZED', async (done) => { + const subjects = getDataSubjects(); + const { totalHits$ } = subjects; + + const deps = { + abortController: new AbortController(), + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + data: discoverServiceMock.data, + }; + + const stateArr: FetchStatus[] = []; + + totalHits$.subscribe((value) => stateArr.push(value.fetchStatus)); + + fetchTotalHits(subjects, savedSearchMock.searchSource, deps).subscribe({ + complete: () => { + expect(stateArr).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.COMPLETE, + ]); + done(); + }, + }); + }); + test('change of fetchStatus on fetch error', async (done) => { + const subjects = getDataSubjects(); + const { totalHits$ } = subjects; + const deps = { + abortController: new AbortController(), + inspectorAdapters: { requests: new RequestAdapter() }, + onResults: jest.fn(), + searchSessionId: '123', + data: discoverServiceMock.data, + }; + + savedSearchMock.searchSource.fetch$ = () => throwErrorRx({ msg: 'Oh noes!' }); + + const stateArr: FetchStatus[] = []; + + totalHits$.subscribe((value) => stateArr.push(value.fetchStatus)); + + fetchTotalHits(subjects, savedSearchMock.searchSource, deps).subscribe({ + error: () => { + expect(stateArr).toEqual([ + FetchStatus.UNINITIALIZED, + FetchStatus.LOADING, + FetchStatus.ERROR, + ]); + done(); + }, + }); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.ts new file mode 100644 index 0000000000000..4fb43652f28c3 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { filter } from 'rxjs/operators'; +import { + DataPublicPluginStart, + isCompleteResponse, + SearchSource, +} from '../../../../../../data/public'; +import { Adapters } from '../../../../../../inspector/common'; +import { FetchStatus } from '../../../types'; +import { SavedSearchData } from '../services/use_saved_search'; +import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messages'; + +export function fetchTotalHits( + data$: SavedSearchData, + searchSource: SearchSource, + { + abortController, + data, + inspectorAdapters, + onResults, + searchSessionId, + }: { + abortController: AbortController; + data: DataPublicPluginStart; + onResults: (foundDocuments: boolean) => void; + inspectorAdapters: Adapters; + searchSessionId: string; + } +) { + const { totalHits$ } = data$; + const indexPattern = searchSource.getField('index'); + searchSource.setField('trackTotalHits', true); + searchSource.setField('filter', data.query.timefilter.timefilter.createFilter(indexPattern!)); + searchSource.setField('size', 0); + searchSource.removeField('sort'); + searchSource.removeField('fields'); + searchSource.removeField('aggs'); + + sendLoadingMsg(totalHits$); + + const fetch$ = searchSource + .fetch$({ + inspector: { + adapter: inspectorAdapters.requests, + title: i18n.translate('discover.inspectorRequestDataTitleTotalHits', { + defaultMessage: 'Total hits', + }), + description: i18n.translate('discover.inspectorRequestDescriptionTotalHits', { + defaultMessage: 'This request queries Elasticsearch to fetch the total hits.', + }), + }, + abortSignal: abortController.signal, + sessionId: searchSessionId, + }) + .pipe(filter((res) => isCompleteResponse(res))); + + fetch$.subscribe( + (res) => { + const totalHitsNr = res.rawResponse.hits.total as number; + totalHits$.next({ fetchStatus: FetchStatus.COMPLETE, result: totalHitsNr }); + onResults(totalHitsNr > 0); + }, + (error) => { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + sendErrorMsg(totalHits$, error); + } + ); + + return fetch$; +} diff --git a/src/plugins/discover/public/application/apps/main/utils/get_result_state.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_result_state.test.ts index 512bc4240352c..7066d22d6aac7 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_result_state.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_result_state.test.ts @@ -6,40 +6,36 @@ * Side Public License, v 1. */ import { getResultState, resultStatuses } from './get_result_state'; -import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { FetchStatus } from '../../../types'; describe('getResultState', () => { test('fetching uninitialized', () => { - const actual = getResultState(FetchStatus.UNINITIALIZED, []); + const actual = getResultState(FetchStatus.UNINITIALIZED, false); expect(actual).toBe(resultStatuses.UNINITIALIZED); }); test('fetching complete with no records', () => { - const actual = getResultState(FetchStatus.COMPLETE, []); + const actual = getResultState(FetchStatus.COMPLETE, false); expect(actual).toBe(resultStatuses.NO_RESULTS); }); test('fetching ongoing aka loading', () => { - const actual = getResultState(FetchStatus.LOADING, []); + const actual = getResultState(FetchStatus.LOADING, false); expect(actual).toBe(resultStatuses.LOADING); }); test('fetching ready', () => { - const record = ({ _id: 123 } as unknown) as ElasticSearchHit; - const actual = getResultState(FetchStatus.COMPLETE, [record]); + const actual = getResultState(FetchStatus.COMPLETE, true); expect(actual).toBe(resultStatuses.READY); }); test('re-fetching after already data is available', () => { - const record = ({ _id: 123 } as unknown) as ElasticSearchHit; - const actual = getResultState(FetchStatus.LOADING, [record]); + const actual = getResultState(FetchStatus.LOADING, true); expect(actual).toBe(resultStatuses.READY); }); test('after a fetch error when data was successfully fetched before ', () => { - const record = ({ _id: 123 } as unknown) as ElasticSearchHit; - const actual = getResultState(FetchStatus.ERROR, [record]); + const actual = getResultState(FetchStatus.ERROR, true); expect(actual).toBe(resultStatuses.READY); }); }); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_result_state.ts b/src/plugins/discover/public/application/apps/main/utils/get_result_state.ts index 80d25566ea578..424d2feabd830 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_result_state.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_result_state.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { FetchStatus } from '../../../types'; export const resultStatuses = { @@ -19,13 +18,13 @@ export const resultStatuses = { * Returns the current state of the result, depends on fetchStatus and the given fetched rows * Determines what is displayed in Discover main view (loading view, data view, empty data view, ...) */ -export function getResultState(fetchStatus: FetchStatus, rows: ElasticSearchHit[]) { +export function getResultState(fetchStatus: FetchStatus, foundDocuments: boolean = false) { if (fetchStatus === FetchStatus.UNINITIALIZED) { return resultStatuses.UNINITIALIZED; } - const rowsEmpty = !Array.isArray(rows) || rows.length === 0; - if (rowsEmpty && fetchStatus === FetchStatus.LOADING) return resultStatuses.LOADING; - else if (!rowsEmpty) return resultStatuses.READY; + if (!foundDocuments && fetchStatus === FetchStatus.LOADING) return resultStatuses.LOADING; + else if (foundDocuments) return resultStatuses.READY; + else if (fetchStatus === FetchStatus.PARTIAL) return resultStatuses.READY; else return resultStatuses.NO_RESULTS; } diff --git a/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts b/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts index 9deabf96732b1..945140e0586ab 100644 --- a/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts @@ -9,39 +9,21 @@ import { updateSearchSource } from './update_search_source'; import { createSearchSourceMock } from '../../../../../../data/common/search/search_source/mocks'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; -import { IUiSettingsClient } from 'kibana/public'; -import { DiscoverServices } from '../../../../build_services'; -import { dataPluginMock } from '../../../../../../data/public/mocks'; -import { SAMPLE_SIZE_SETTING } from '../../../../../common'; import { SortOrder } from '../../../../saved_searches/types'; +import { discoverServiceMock } from '../../../../__mocks__/services'; describe('updateSearchSource', () => { test('updates a given search source', async () => { const persistentSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({}); volatileSearchSourceMock.setParent(persistentSearchSourceMock); - const sampleSize = 250; updateSearchSource(volatileSearchSourceMock, false, { indexPattern: indexPatternMock, - services: ({ - data: dataPluginMock.createStartContract(), - timefilter: { - createFilter: jest.fn(), - }, - uiSettings: ({ - get: (key: string) => { - if (key === SAMPLE_SIZE_SETTING) { - return sampleSize; - } - return false; - }, - } as unknown) as IUiSettingsClient, - } as unknown) as DiscoverServices, + services: discoverServiceMock, sort: [] as SortOrder[], useNewFieldsApi: false, }); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); - expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); expect(volatileSearchSourceMock.getField('fields')).toBe(undefined); }); @@ -49,28 +31,13 @@ describe('updateSearchSource', () => { const persistentSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({}); volatileSearchSourceMock.setParent(persistentSearchSourceMock); - const sampleSize = 250; updateSearchSource(volatileSearchSourceMock, false, { indexPattern: indexPatternMock, - services: ({ - data: dataPluginMock.createStartContract(), - timefilter: { - createFilter: jest.fn(), - }, - uiSettings: ({ - get: (key: string) => { - if (key === SAMPLE_SIZE_SETTING) { - return sampleSize; - } - return false; - }, - } as unknown) as IUiSettingsClient, - } as unknown) as DiscoverServices, + services: discoverServiceMock, sort: [] as SortOrder[], useNewFieldsApi: true, }); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); - expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); expect(volatileSearchSourceMock.getField('fields')).toEqual([ { field: '*', include_unmapped: 'true' }, ]); @@ -81,28 +48,13 @@ describe('updateSearchSource', () => { const persistentSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({}); volatileSearchSourceMock.setParent(persistentSearchSourceMock); - const sampleSize = 250; updateSearchSource(volatileSearchSourceMock, false, { indexPattern: indexPatternMock, - services: ({ - data: dataPluginMock.createStartContract(), - timefilter: { - createFilter: jest.fn(), - }, - uiSettings: ({ - get: (key: string) => { - if (key === SAMPLE_SIZE_SETTING) { - return sampleSize; - } - return false; - }, - } as unknown) as IUiSettingsClient, - } as unknown) as DiscoverServices, + services: discoverServiceMock, sort: [] as SortOrder[], useNewFieldsApi: true, }); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); - expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); expect(volatileSearchSourceMock.getField('fields')).toEqual([ { field: '*', include_unmapped: 'true' }, ]); @@ -113,28 +65,13 @@ describe('updateSearchSource', () => { const persistentSearchSourceMock = createSearchSourceMock({}); const volatileSearchSourceMock = createSearchSourceMock({}); volatileSearchSourceMock.setParent(persistentSearchSourceMock); - const sampleSize = 250; updateSearchSource(volatileSearchSourceMock, false, { indexPattern: indexPatternMock, - services: ({ - data: dataPluginMock.createStartContract(), - timefilter: { - createFilter: jest.fn(), - }, - uiSettings: ({ - get: (key: string) => { - if (key === SAMPLE_SIZE_SETTING) { - return sampleSize; - } - return false; - }, - } as unknown) as IUiSettingsClient, - } as unknown) as DiscoverServices, + services: discoverServiceMock, sort: [] as SortOrder[], useNewFieldsApi: false, }); expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); - expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); expect(volatileSearchSourceMock.getField('fields')).toEqual(undefined); expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined); }); diff --git a/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts b/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts index c72e207102e81..3fac75a198d53 100644 --- a/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts +++ b/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts @@ -7,7 +7,7 @@ */ import { getSortForSearchSource } from '../../../angular/doc_table'; -import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; +import { SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import { IndexPattern, ISearchSource } from '../../../../../../data/common'; import { SortOrder } from '../../../../saved_searches/types'; import { DiscoverServices } from '../../../../build_services'; @@ -32,25 +32,22 @@ export function updateSearchSource( } ) { const { uiSettings, data } = services; - const usedSort = getSortForSearchSource( - sort, - indexPattern, - uiSettings.get(SORT_DEFAULT_ORDER_SETTING) - ); - const usedSearchSource = persist ? searchSource : searchSource.getParent()!; + const parentSearchSource = persist ? searchSource : searchSource.getParent()!; - usedSearchSource + parentSearchSource .setField('index', indexPattern) .setField('query', data.query.queryString.getQuery() || null) .setField('filter', data.query.filterManager.getFilters()); if (!persist) { + const usedSort = getSortForSearchSource( + sort, + indexPattern, + uiSettings.get(SORT_DEFAULT_ORDER_SETTING) + ); searchSource .setField('trackTotalHits', true) - .setField('size', uiSettings.get(SAMPLE_SIZE_SETTING)) .setField('sort', usedSort) - .setField('highlightAll', true) - .setField('version', true) // Even when searching rollups, we want to use the default strategy so that we get back a // document-like response. .setPreferredSearchStrategyId('default'); diff --git a/src/plugins/discover/public/application/apps/main/utils/use_behavior_subject.ts b/src/plugins/discover/public/application/apps/main/utils/use_behavior_subject.ts new file mode 100644 index 0000000000000..5be6bd3266c7b --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/use_behavior_subject.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { useRef } from 'react'; +import { BehaviorSubject } from 'rxjs'; + +export function useBehaviorSubject(props: T): BehaviorSubject { + const ref = useRef | null>(null); + + if (ref.current === null) { + ref.current = new BehaviorSubject(props); + } + + return ref.current; +} diff --git a/src/plugins/discover/public/application/apps/main/utils/use_data_state.ts b/src/plugins/discover/public/application/apps/main/utils/use_data_state.ts new file mode 100644 index 0000000000000..2fd571a0dfcb9 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/use_data_state.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { useState, useEffect } from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { DataMsg } from '../services/use_saved_search'; + +export function useDataState(data$: BehaviorSubject) { + const [fetchState, setFetchState] = useState(data$.getValue()); + + useEffect(() => { + const subscription = data$.subscribe((next) => { + if (next.fetchStatus !== fetchState.fetchStatus) { + setFetchState({ ...fetchState, ...next }); + } + }); + return () => subscription.unsubscribe(); + }, [data$, fetchState, setFetchState]); + return fetchState; +} diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx index 9fdb564cb518d..1ff623dc0e317 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx @@ -55,6 +55,7 @@ describe('Test of helper / hook', () => { }, "script_fields": Array [], "stored_fields": Array [], + "version": true, }, } `); @@ -84,6 +85,7 @@ describe('Test of helper / hook', () => { "runtime_mappings": Object {}, "script_fields": Array [], "stored_fields": Array [], + "version": true, }, } `); @@ -114,6 +116,7 @@ describe('Test of helper / hook', () => { "runtime_mappings": Object {}, "script_fields": Array [], "stored_fields": Array [], + "version": true, }, } `); @@ -162,6 +165,7 @@ describe('Test of helper / hook', () => { }, "script_fields": Array [], "stored_fields": Array [], + "version": true, }, } `); diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts index 71a32b758aca7..ce039aeadc063 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts @@ -37,6 +37,7 @@ export function buildSearchBody( }, stored_fields: computedFields.storedFields, script_fields: computedFields.scriptFields, + version: true, }, }; if (!request.body) { diff --git a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx index c885cb92e7649..098c7f55fbd9f 100644 --- a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx @@ -22,7 +22,7 @@ import { Query, TimeRange, Filter, - IFieldType, + IndexPatternField, IndexPattern, ISearchSource, } from '../../../../data/common'; @@ -50,7 +50,7 @@ export interface SearchProps extends Partial { sharedItemTitle?: string; inspectorAdapters?: Adapters; - filter?: (field: IFieldType, value: string[], operator: string) => void; + filter?: (field: IndexPatternField, value: string[], operator: string) => void; hits?: ElasticSearchHit[]; totalHitCount?: number; onMoveColumn?: (column: string, index: number) => void; diff --git a/src/plugins/discover/public/application/embeddable/types.ts b/src/plugins/discover/public/application/embeddable/types.ts index c550e8c57f3ff..642c65c4b2a55 100644 --- a/src/plugins/discover/public/application/embeddable/types.ts +++ b/src/plugins/discover/public/application/embeddable/types.ts @@ -13,7 +13,7 @@ import { IEmbeddable, } from 'src/plugins/embeddable/public'; import { SortOrder } from '../angular/doc_table/components/table_header/helpers'; -import { Filter, IIndexPattern, TimeRange, Query } from '../../../../data/public'; +import { Filter, IndexPattern, TimeRange, Query } from '../../../../data/public'; import { SavedSearch } from '../..'; export interface SearchInput extends EmbeddableInput { @@ -27,7 +27,7 @@ export interface SearchInput extends EmbeddableInput { export interface SearchOutput extends EmbeddableOutput { editUrl: string; - indexPatterns?: IIndexPattern[]; + indexPatterns?: IndexPattern[]; editable: boolean; } diff --git a/src/plugins/discover/public/application/types.ts b/src/plugins/discover/public/application/types.ts index 4d7f47182e98a..798e0f350cc5f 100644 --- a/src/plugins/discover/public/application/types.ts +++ b/src/plugins/discover/public/application/types.ts @@ -9,6 +9,7 @@ export enum FetchStatus { UNINITIALIZED = 'uninitialized', LOADING = 'loading', + PARTIAL = 'partial', COMPLETE = 'complete', ERROR = 'error', } diff --git a/src/plugins/discover/public/kibana_services.ts b/src/plugins/discover/public/kibana_services.ts index c2ab4ae34c958..11bc61c9b261a 100644 --- a/src/plugins/discover/public/kibana_services.ts +++ b/src/plugins/discover/public/kibana_services.ts @@ -98,10 +98,9 @@ export { formatMsg, formatStack, subscribeWithScope } from '../../kibana_legacy/ // EXPORT types export { IndexPatternsContract, - IIndexPattern, IndexPattern, indexPatterns, - IFieldType, + IndexPatternField, ISearchSource, EsQuerySortValue, SortDirection, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index a7dd5461adf46..ace26dc9e6295 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -80,6 +80,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'text', _meta: { description: 'Non-default value of setting.' }, }, + 'timelion:legacyChartsLibrary': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'timelion:target_buckets': { type: 'long', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index a47acf25a2cc1..95faaee88fa61 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -45,6 +45,7 @@ export interface UsageStats { 'visualization:tileMap:maxPrecision': number; 'csv:separator': string; 'visualization:tileMap:WMSdefaults': string; + 'timelion:legacyChartsLibrary': boolean; 'timelion:target_buckets': number; 'timelion:max_buckets': number; 'timelion:es.timefield': string; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index f6c8d5fb69408..8f4940ffb05c9 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -34,7 +34,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { OverlayStart, HttpStart, IBasePath } from 'src/core/public'; import { IndexPatternsContract, - IIndexPattern, + IndexPattern, DataPublicPluginStart, } from '../../../../../data/public'; import { @@ -86,7 +86,7 @@ export interface FlyoutState { error?: string; file?: File; importCount: number; - indexPatterns?: IIndexPattern[]; + indexPatterns?: IndexPattern[]; importMode: ImportMode; loadingMessage?: string; isLegacyFile: boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index a5d4668a4296f..6947b6cc3ce38 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7258,6 +7258,12 @@ "description": "Non-default value of setting." } }, + "timelion:legacyChartsLibrary": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "timelion:target_buckets": { "type": "long", "_meta": { diff --git a/src/plugins/timelion/public/app.js b/src/plugins/timelion/public/app.js index 6db88ad65a1d1..4a4b2be679dd3 100644 --- a/src/plugins/timelion/public/app.js +++ b/src/plugins/timelion/public/app.js @@ -21,7 +21,7 @@ import { registerListenEventListener, watchMultiDecorator, } from '../../kibana_legacy/public'; -import { getTimezone } from '../../vis_type_timelion/public'; +import { _LEGACY_ as visTypeTimelion } from '../../vis_type_timelion/public'; import { initCellsDirective } from './directives/cells/cells'; import { initFullscreenDirective } from './directives/fullscreen/fullscreen'; import { initFixedElementDirective } from './directives/fixed_element'; @@ -144,7 +144,7 @@ export function initTimelionApp(app, deps) { $scope.updatedSheets = []; const savedVisualizations = deps.plugins.visualizations.savedVisualizationsLoader; - const timezone = getTimezone(deps.core.uiSettings); + const timezone = visTypeTimelion.getTimezone(deps.core.uiSettings); const defaultExpression = '.es(*)'; diff --git a/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js b/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js index 2abfd2b1e7c7a..0bc5897c49d6f 100644 --- a/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js +++ b/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js @@ -7,7 +7,7 @@ */ import _ from 'lodash'; -import { parseTimelionExpressionAsync } from '../../../vis_type_timelion/public'; +import { _LEGACY_ as visTypeTimelion } from '../../../vis_type_timelion/public'; export const SUGGESTION_TYPE = { ARGUMENTS: 'arguments', @@ -180,7 +180,7 @@ async function extractSuggestionsFromParsedResult( export async function suggest(expression, functionList, cursorPosition, argValueSuggestions) { try { - const result = await parseTimelionExpressionAsync(expression); + const result = await visTypeTimelion.parseTimelionExpressionAsync(expression); return await extractSuggestionsFromParsedResult( result, cursorPosition, diff --git a/src/plugins/timelion/public/index.scss b/src/plugins/timelion/public/index.scss index b93e99bf9bcc4..7a4259b2a17c8 100644 --- a/src/plugins/timelion/public/index.scss +++ b/src/plugins/timelion/public/index.scss @@ -15,4 +15,4 @@ // styles for timelion visualization are lazy loaded only while a vis is opened // this will duplicate styles only if both Timelion app and timelion visualization are loaded // could be left here as it is since the Timelion app is deprecated -@import '../../vis_type_timelion/public/components/timelion_vis.scss'; +@import '../../vis_type_timelion/public/legacy/timelion_vis.scss'; diff --git a/src/plugins/timelion/public/panels/timechart/schema.ts b/src/plugins/timelion/public/panels/timechart/schema.ts index 7178ba135c4c5..dc26adc6ea5f5 100644 --- a/src/plugins/timelion/public/panels/timechart/schema.ts +++ b/src/plugins/timelion/public/panels/timechart/schema.ts @@ -11,13 +11,7 @@ import $ from 'jquery'; import moment from 'moment-timezone'; // @ts-ignore import observeResize from '../../lib/observe_resize'; -import { - calculateInterval, - DEFAULT_TIME_FORMAT, - tickFormatters, - xaxisFormatterProvider, - generateTicksProvider, -} from '../../../../vis_type_timelion/public'; +import { _LEGACY_ as visTypeTimelion } from '../../../../vis_type_timelion/public'; import { TimelionVisualizationDependencies } from '../../application'; const DEBOUNCE_DELAY = 50; @@ -37,9 +31,9 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { help: 'Draw a timeseries chart', render($scope: any, $elem: any) { const template = '
'; - const formatters = tickFormatters() as any; - const getxAxisFormatter = xaxisFormatterProvider(uiSettings); - const generateTicks = generateTicksProvider(); + const formatters = visTypeTimelion.tickFormatters() as any; + const getxAxisFormatter = visTypeTimelion.xaxisFormatterProvider(uiSettings); + const generateTicks = visTypeTimelion.generateTicksProvider(); // TODO: I wonder if we should supply our own moment that sets this every time? // could just use angular's injection to provide a moment service? @@ -226,7 +220,7 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { if (legendCaption) { legendCaption.text( moment(pos.x).format( - _.get(dataset, '[0]._global.legend.timeFormat', DEFAULT_TIME_FORMAT) + _.get(dataset, '[0]._global.legend.timeFormat', visTypeTimelion.DEFAULT_TIME_FORMAT) ) ); } @@ -289,7 +283,7 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { // Get the X-axis tick format const time = timefilter.timefilter.getBounds() as any; - const interval = calculateInterval( + const interval = visTypeTimelion.calculateInterval( time.min.valueOf(), time.max.valueOf(), uiSettings.get('timelion:target_buckets') || 200, diff --git a/src/plugins/vis_type_timelion/common/constants.ts b/src/plugins/vis_type_timelion/common/constants.ts new file mode 100644 index 0000000000000..a97bdd855107c --- /dev/null +++ b/src/plugins/vis_type_timelion/common/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const UI_SETTINGS = { + LEGACY_CHARTS_LIBRARY: 'timelion:legacyChartsLibrary', + ES_TIMEFIELD: 'timelion:es.timefield', + DEFAULT_INDEX: 'timelion:es.default_index', + TARGET_BUCKETS: 'timelion:target_buckets', + MAX_BUCKETS: 'timelion:max_buckets', + MIN_INTERVAL: 'timelion:min_interval', + GRAPHITE_URL: 'timelion:graphite.url', + QUANDL_KEY: 'timelion:quandl.key', +}; diff --git a/src/plugins/vis_type_timelion/common/vis_data.ts b/src/plugins/vis_type_timelion/common/vis_data.ts new file mode 100644 index 0000000000000..e3041f43a8f19 --- /dev/null +++ b/src/plugins/vis_type_timelion/common/vis_data.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. + */ + +export interface VisSeries { + yaxis?: number; + label: string; + lines?: { + show?: boolean; + lineWidth?: number; + fill?: number; + steps?: number; + }; + points?: { + show?: boolean; + symbol?: 'cross' | 'x' | 'circle' | 'square' | 'diamond' | 'plus' | 'triangle'; + fillColor?: string; + fill?: number; + radius?: number; + lineWidth?: number; + }; + bars: { + lineWidth?: number; + fill?: number; + }; + color?: string; + data: Array>; + stack: boolean; +} diff --git a/src/plugins/vis_type_timelion/kibana.json b/src/plugins/vis_type_timelion/kibana.json index bbd52e3e1bec5..bf537f4ffbc76 100644 --- a/src/plugins/vis_type_timelion/kibana.json +++ b/src/plugins/vis_type_timelion/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["visualizations", "data", "expressions"], + "requiredPlugins": ["visualizations", "data", "expressions", "charts"], "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], "owner": { "name": "Kibana App", diff --git a/src/plugins/vis_type_timelion/public/components/series/area.tsx b/src/plugins/vis_type_timelion/public/components/series/area.tsx new file mode 100644 index 0000000000000..589a488d3acad --- /dev/null +++ b/src/plugins/vis_type_timelion/public/components/series/area.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { AreaSeries, ScaleType, CurveType, AreaSeriesStyle, PointShape } from '@elastic/charts'; +import type { VisSeries } from '../../../common/vis_data'; + +interface AreaSeriesComponentProps { + index: number; + visData: VisSeries; + groupId: string; +} + +const isShowLines = (lines: VisSeries['lines'], points: VisSeries['points']) => + lines?.show ? true : points?.show ? false : true; + +const getAreaSeriesStyle = ({ color, lines, points }: AreaSeriesComponentProps['visData']) => + ({ + line: { + opacity: isShowLines(lines, points) ? 1 : 0, + stroke: color, + strokeWidth: lines?.lineWidth !== undefined ? Number(lines.lineWidth) : 3, + visible: isShowLines(lines, points), + }, + area: { + fill: color, + opacity: lines?.fill ?? 0, + visible: lines?.show ?? points?.show ?? true, + }, + point: { + fill: points?.fillColor ?? color, + opacity: points?.lineWidth !== undefined ? (points.fill || 1) * 10 : 10, + radius: points?.radius ?? 3, + stroke: color, + strokeWidth: points?.lineWidth ?? 2, + visible: points?.show ?? false, + shape: points?.symbol === 'cross' ? PointShape.X : points?.symbol, + }, + curve: lines?.steps ? CurveType.CURVE_STEP : CurveType.LINEAR, + } as AreaSeriesStyle); + +export const AreaSeriesComponent = ({ index, groupId, visData }: AreaSeriesComponentProps) => ( + +); diff --git a/src/plugins/vis_type_timelion/public/components/series/bar.tsx b/src/plugins/vis_type_timelion/public/components/series/bar.tsx new file mode 100644 index 0000000000000..6a97c8fea9690 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/components/series/bar.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { BarSeries, ScaleType, BarSeriesStyle } from '@elastic/charts'; +import type { VisSeries } from '../../../common/vis_data'; + +interface BarSeriesComponentProps { + index: number; + visData: VisSeries; + groupId: string; +} + +const getBarSeriesStyle = ({ color, bars }: BarSeriesComponentProps['visData']) => { + let opacity = bars.fill ?? 1; + + if (opacity < 0) { + opacity = 0; + } else if (opacity > 1) { + opacity = 1; + } + + return { + rectBorder: { + stroke: color, + strokeWidth: Math.max(1, bars.lineWidth ? Math.ceil(bars.lineWidth / 2) : 1), + visible: true, + }, + rect: { + fill: color, + opacity, + widthPixel: 1, + }, + } as BarSeriesStyle; +}; + +export const BarSeriesComponent = ({ index, groupId, visData }: BarSeriesComponentProps) => ( + +); diff --git a/src/plugins/discover/public/application/helpers/index.ts b/src/plugins/vis_type_timelion/public/components/series/index.ts similarity index 79% rename from src/plugins/discover/public/application/helpers/index.ts rename to src/plugins/vis_type_timelion/public/components/series/index.ts index 40e771482fd14..3efe537d0b467 100644 --- a/src/plugins/discover/public/application/helpers/index.ts +++ b/src/plugins/vis_type_timelion/public/components/series/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ -export { formatNumWithCommas } from './format_number_with_commas'; +export { BarSeriesComponent } from './bar'; +export { AreaSeriesComponent } from './area'; diff --git a/src/plugins/vis_type_timelion/public/components/timelion_vis.scss b/src/plugins/vis_type_timelion/public/components/timelion_vis.scss index c4d591bc82cad..663563432d56b 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_vis.scss +++ b/src/plugins/vis_type_timelion/public/components/timelion_vis.scss @@ -1,60 +1,10 @@ -.timChart { +.timelionChart { height: 100%; width: 100%; display: flex; flex-direction: column; - - // Custom Jquery FLOT / schema selectors - // Cannot change at the moment - - .chart-top-title { - @include euiFontSizeXS; - flex: 0; - text-align: center; - font-weight: $euiFontWeightBold; - } - - .chart-canvas { - min-width: 100%; - flex: 1; - overflow: hidden; - } - - .legendLabel { - white-space: nowrap; - text-overflow: ellipsis; - overflow-x: hidden; - line-height: normal; - } - - .legendColorBox { - vertical-align: middle; - } - - .ngLegendValue { - color: $euiTextColor; - cursor: pointer; - - &:focus, - &:hover { - text-decoration: underline; - } - } - - .ngLegendValueNumber { - margin-left: $euiSizeXS; - margin-right: $euiSizeXS; - font-weight: $euiFontWeightBold; - } - - .flot-tick-label { - font-size: $euiFontSizeXS; - color: $euiColorDarkShade; - } } -.timChart__legendCaption { - color: $euiTextColor; - white-space: nowrap; - font-weight: $euiFontWeightBold; +.timelionChart__topTitle { + text-align: center; } diff --git a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx index 09e3b78c922cd..4690f4fe11e45 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx +++ b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx @@ -6,422 +6,228 @@ * Side Public License, v 1. */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import $ from 'jquery'; -import moment from 'moment-timezone'; -import { debounce, compact, get, each, cloneDeep, last, map } from 'lodash'; -import { useResizeObserver } from '@elastic/eui'; +import React, { useEffect, useCallback, useMemo, useRef } from 'react'; +import { compact, last, map } from 'lodash'; +import { + Chart, + Settings, + Position, + Axis, + TooltipType, + PointerEvent, + LegendPositionConfig, + LayoutDirection, +} from '@elastic/charts'; +import { EuiTitle } from '@elastic/eui'; -import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; import { useKibana } from '../../../kibana_react/public'; -import { DEFAULT_TIME_FORMAT } from '../../common/lib'; + +import { AreaSeriesComponent, BarSeriesComponent } from './series'; import { - buildSeriesData, - buildOptions, - SERIES_ID_ATTR, - colors, - Axis, - ACTIVE_CURSOR, - eventBus, + extractAllYAxis, + withStaticPadding, + createTickFormat, + validateLegendPositionValue, + MAIN_GROUP_ID, } from '../helpers/panel_utils'; -import { Series, Sheet } from '../helpers/timelion_request_handler'; -import { tickFormatters } from '../helpers/tick_formatters'; -import { generateTicksProvider } from '../helpers/tick_generator'; -import { TimelionVisDependencies } from '../plugin'; +import { colors } from '../helpers/chart_constants'; +import { activeCursor$ } from '../helpers/active_cursor'; -import './timelion_vis.scss'; +import type { Sheet } from '../helpers/timelion_request_handler'; +import type { IInterpreterRenderHandlers } from '../../../expressions'; +import type { TimelionVisDependencies } from '../plugin'; +import type { RangeFilterParams } from '../../../data/public'; +import type { Series } from '../helpers/timelion_request_handler'; -interface CrosshairPlot extends jquery.flot.plot { - setCrosshair: (pos: Position) => void; - clearCrosshair: () => void; -} +import './timelion_vis.scss'; interface TimelionVisComponentProps { - fireEvent: IInterpreterRenderHandlers['event']; interval: string; seriesList: Sheet; + onBrushEvent: (rangeFilterParams: RangeFilterParams) => void; renderComplete: IInterpreterRenderHandlers['done']; } -interface Position { - x: number; - x1: number; - y: number; - y1: number; - pageX: number; - pageY: number; -} - -interface Range { - to: number; - from: number; -} - -interface Ranges { - xaxis: Range; - yaxis: Range; -} - -const DEBOUNCE_DELAY = 50; -// ensure legend is the same height with or without a caption so legend items do not move around -const emptyCaption = '
'; - -function TimelionVisComponent({ +const DefaultYAxis = () => ( + +); + +const renderYAxis = (series: Series[]) => { + const yAxisOptions = extractAllYAxis(series); + + const yAxis = yAxisOptions.map((option, index) => ( + + )); + + return yAxis.length ? yAxis : ; +}; + +const TimelionVisComponent = ({ interval, seriesList, renderComplete, - fireEvent, -}: TimelionVisComponentProps) { + onBrushEvent, +}: TimelionVisComponentProps) => { const kibana = useKibana(); - const [chart, setChart] = useState(() => cloneDeep(seriesList.list)); - const [canvasElem, setCanvasElem] = useState(); - const [chartElem, setChartElem] = useState(null); - - const [originalColorMap, setOriginalColorMap] = useState(() => new Map()); + const chartRef = useRef(null); + const chart = seriesList.list; - const [highlightedSeries, setHighlightedSeries] = useState(null); - const [focusedSeries, setFocusedSeries] = useState(); - const [plot, setPlot] = useState(); - - // Used to toggle the series, and for displaying values on hover - const [legendValueNumbers, setLegendValueNumbers] = useState>(); - const [legendCaption, setLegendCaption] = useState>(); + useEffect(() => { + const subscription = activeCursor$.subscribe((cursor: PointerEvent) => { + chartRef.current?.dispatchExternalPointerEvent(cursor); + }); - const canvasRef = useCallback((node: HTMLDivElement | null) => { - if (node !== null) { - setCanvasElem(node); - } + return () => { + subscription.unsubscribe(); + }; }, []); - const elementRef = useCallback((node: HTMLDivElement | null) => { - if (node !== null) { - setChartElem(node); - } + const handleCursorUpdate = useCallback((cursor: PointerEvent) => { + activeCursor$.next(cursor); }, []); - useEffect( - () => () => { - if (chartElem) { - $(chartElem).off('plotselected').off('plothover').off('mouseleave'); - } - }, - [chartElem] - ); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const highlightSeries = useCallback( - debounce(({ currentTarget }: JQuery.TriggeredEvent) => { - const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); - if (highlightedSeries === id) { + const brushEndListener = useCallback( + ({ x }) => { + if (!x) { return; } - setHighlightedSeries(id); - setChart((chartState) => - chartState.map((series: Series, seriesIndex: number) => { - series.color = - seriesIndex === id - ? originalColorMap.get(series) // color it like it was - : 'rgba(128,128,128,0.1)'; // mark as grey - - return series; - }) - ); - }, DEBOUNCE_DELAY), - [originalColorMap, highlightedSeries] - ); - - const focusSeries = useCallback( - (event: JQuery.TriggeredEvent) => { - const id = Number(event.currentTarget.getAttribute(SERIES_ID_ATTR)); - setFocusedSeries(id); - highlightSeries(event); + onBrushEvent({ + gte: x[0], + lte: x[1], + }); }, - [highlightSeries] + [onBrushEvent] ); - const toggleSeries = useCallback(({ currentTarget }: JQuery.TriggeredEvent) => { - const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); - - setChart((chartState) => - chartState.map((series: Series, seriesIndex: number) => { - if (seriesIndex === id) { - series._hide = !series._hide; - } - return series; - }) - ); - }, []); - - const updateCaption = useCallback( - (plotData: any) => { - if (canvasElem && get(plotData, '[0]._global.legend.showTime', true)) { - const caption = $(''); - caption.html(emptyCaption); - setLegendCaption(caption); - - const canvasNode = $(canvasElem); - canvasNode.find('div.legend table').append(caption); - setLegendValueNumbers(canvasNode.find('.ngLegendValueNumber')); - - const legend = $(canvasElem).find('.ngLegendValue'); - if (legend) { - legend.click(toggleSeries); - legend.focus(focusSeries); - legend.mouseover(highlightSeries); - } - - // legend has been re-created. Apply focus on legend element when previously set - if (focusedSeries || focusedSeries === 0) { - canvasNode.find('div.legend table .legendLabel>span').get(focusedSeries).focus(); - } + const onRenderChange = useCallback( + (isRendered: boolean) => { + if (isRendered) { + renderComplete(); } }, - [focusedSeries, canvasElem, toggleSeries, focusSeries, highlightSeries] + [renderComplete] ); - const updatePlot = useCallback( - (chartValue: Series[], grid?: boolean) => { - if (canvasElem && canvasElem.clientWidth > 0 && canvasElem.clientHeight > 0) { - const options = buildOptions( - interval, - kibana.services.timefilter, - kibana.services.uiSettings, - chartElem?.clientWidth, - grid - ); - const updatedSeries = buildSeriesData(chartValue, options); - - if (options.yaxes) { - options.yaxes.forEach((yaxis: Axis) => { - if (yaxis && yaxis.units) { - const formatters = tickFormatters(); - yaxis.tickFormatter = formatters[yaxis.units.type as keyof typeof formatters]; - const byteModes = ['bytes', 'bytes/s']; - if (byteModes.includes(yaxis.units.type)) { - yaxis.tickGenerator = generateTicksProvider(); - } - } - }); - } - - const newPlot = $.plot($(canvasElem), updatedSeries, options); - setPlot(newPlot); - renderComplete(); + const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [ + seriesList.list, + ]); - updateCaption(newPlot.getData()); - } - }, - [canvasElem, chartElem?.clientWidth, renderComplete, kibana.services, interval, updateCaption] + const tickFormat = useMemo( + () => createTickFormat(interval, kibana.services.timefilter, kibana.services.uiSettings), + [interval, kibana.services.timefilter, kibana.services.uiSettings] ); - const dimensions = useResizeObserver(chartElem); + const legend = useMemo(() => { + const legendPosition: LegendPositionConfig = { + floating: true, + floatingColumns: 1, + vAlign: Position.Top, + hAlign: Position.Left, + direction: LayoutDirection.Vertical, + }; + let showLegend = true; - useEffect(() => { - updatePlot(chart, seriesList.render && seriesList.render.grid); - }, [chart, updatePlot, seriesList.render, dimensions]); + chart.forEach((series) => { + if (series._global?.legend) { + const { show = true, position, noColumns = legendPosition.floatingColumns } = + series._global?.legend ?? {}; - useEffect(() => { - const colorsSet: Array<[Series, string]> = []; - const newChart = seriesList.list.map((series: Series, seriesIndex: number) => { - const newSeries = { ...series }; - if (!newSeries.color) { - const colorIndex = seriesIndex % colors.length; - newSeries.color = colors[colorIndex]; - } - colorsSet.push([newSeries, newSeries.color]); - return newSeries; - }); - setChart(newChart); - setOriginalColorMap(new Map(colorsSet)); - }, [seriesList.list]); - - const unhighlightSeries = useCallback(() => { - if (highlightedSeries === null) { - return; - } - - setHighlightedSeries(null); - setFocusedSeries(null); - - setChart((chartState) => - chartState.map((series: Series) => { - series.color = originalColorMap.get(series); // reset the colors - return series; - }) - ); - }, [originalColorMap, highlightedSeries]); - - // Shamelessly borrowed from the flotCrosshairs example - const setLegendNumbers = useCallback( - (pos: Position) => { - unhighlightSeries(); - - const axes = plot!.getAxes(); - if (pos.x < axes.xaxis.min! || pos.x > axes.xaxis.max!) { - return; - } + if (validateLegendPositionValue(position)) { + const [vAlign, hAlign] = position.split(''); - const dataset = plot!.getData(); - if (legendCaption) { - legendCaption.text( - moment(pos.x).format(get(dataset, '[0]._global.legend.timeFormat', DEFAULT_TIME_FORMAT)) - ); - } - for (let i = 0; i < dataset.length; ++i) { - const series = dataset[i]; - const useNearestPoint = series.lines!.show && !series.lines!.steps; - const precision = get(series, '_meta.precision', 2); - - // We're setting this flag on top on the series object belonging to the flot library, so we're simply casting here. - if ((series as { _hide?: boolean })._hide) { - continue; + legendPosition.vAlign = vAlign === 'n' ? Position.Top : Position.Bottom; + legendPosition.hAlign = hAlign === 'e' ? Position.Right : Position.Left; } - const currentPoint = series.data.find((point: [number, number], index: number) => { - if (index + 1 === series.data.length) { - return true; - } - if (useNearestPoint) { - return pos.x - point[0] < series.data[index + 1][0] - pos.x; - } else { - return pos.x < series.data[index + 1][0]; - } - }); - - const y = currentPoint[1]; - - if (legendValueNumbers) { - if (y == null) { - legendValueNumbers.eq(i).empty(); - } else { - let label = y.toFixed(precision); - const formatter = ((series.yaxis as unknown) as Axis).tickFormatter; - if (formatter) { - label = formatter(Number(label), (series.yaxis as unknown) as Axis); - } - legendValueNumbers.eq(i).text(`(${label})`); - } + if (!show) { + showLegend = false; } - } - }, - [plot, legendValueNumbers, unhighlightSeries, legendCaption] - ); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const debouncedSetLegendNumbers = useCallback( - debounce(setLegendNumbers, DEBOUNCE_DELAY, { - maxWait: DEBOUNCE_DELAY, - leading: true, - trailing: false, - }), - [setLegendNumbers] - ); - - const clearLegendNumbers = useCallback(() => { - if (legendCaption) { - legendCaption.html(emptyCaption); - } - each(legendValueNumbers!, (num: Node) => { - $(num).empty(); - }); - }, [legendCaption, legendValueNumbers]); - - const plotHover = useCallback( - (pos: Position) => { - (plot as CrosshairPlot).setCrosshair(pos); - debouncedSetLegendNumbers(pos); - }, - [plot, debouncedSetLegendNumbers] - ); - - const plotHoverHandler = useCallback( - (event: JQuery.TriggeredEvent, pos: Position) => { - if (!plot) { - return; - } - plotHover(pos); - eventBus.trigger(ACTIVE_CURSOR, [event, pos]); - }, - [plot, plotHover] - ); - useEffect(() => { - const updateCursor = (_: any, event: JQuery.TriggeredEvent, pos: Position) => { - if (!plot) { - return; + if (noColumns !== undefined) { + legendPosition.floatingColumns = noColumns; + } } - plotHover(pos); - }; - - eventBus.on(ACTIVE_CURSOR, updateCursor); - - return () => { - eventBus.off(ACTIVE_CURSOR, updateCursor); - }; - }, [plot, plotHover]); - - const mouseLeaveHandler = useCallback(() => { - if (!plot) { - return; - } - (plot as CrosshairPlot).clearCrosshair(); - clearLegendNumbers(); - }, [plot, clearLegendNumbers]); - - const plotSelectedHandler = useCallback( - (event: JQuery.TriggeredEvent, ranges: Ranges) => { - fireEvent({ - name: 'applyFilter', - data: { - timeFieldName: '*', - filters: [ - { - range: { - '*': { - gte: ranges.xaxis.from, - lte: ranges.xaxis.to, - }, - }, - }, - ], - }, - }); - }, - [fireEvent] - ); - - useEffect(() => { - if (chartElem) { - $(chartElem).off('plotselected').on('plotselected', plotSelectedHandler); - } - }, [chartElem, plotSelectedHandler]); - - useEffect(() => { - if (chartElem) { - $(chartElem).off('mouseleave').on('mouseleave', mouseLeaveHandler); - } - }, [chartElem, mouseLeaveHandler]); + }); - useEffect(() => { - if (chartElem) { - $(chartElem).off('plothover').on('plothover', plotHoverHandler); - } - }, [chartElem, plotHoverHandler]); - - const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [ - seriesList.list, - ]); + return { legendPosition, showLegend }; + }, [chart]); return ( -
-
{title}
-
+
+ {title && ( + +

{title}

+
+ )} + + tickFormat(value), + type: TooltipType.VerticalCursor, + }} + externalPointerEvents={{ tooltip: { visible: false } }} + /> + + + + {renderYAxis(chart)} + + {chart.map((data, index) => { + const visData = { ...data }; + const SeriesComponent = data.bars ? BarSeriesComponent : AreaSeriesComponent; + + if (!visData.color) { + visData.color = colors[index % colors.length]; + } + return ( + + ); + })} +
); -} +}; // default export required for React.Lazy // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/discover/public/application/helpers/format_number_with_commas.ts b/src/plugins/vis_type_timelion/public/helpers/active_cursor.ts similarity index 59% rename from src/plugins/discover/public/application/helpers/format_number_with_commas.ts rename to src/plugins/vis_type_timelion/public/helpers/active_cursor.ts index 0dd85804ef92e..7f7f62fd6a9da 100644 --- a/src/plugins/discover/public/application/helpers/format_number_with_commas.ts +++ b/src/plugins/vis_type_timelion/public/helpers/active_cursor.ts @@ -6,11 +6,7 @@ * Side Public License, v 1. */ -const COMMA_SEPARATOR_RE = /(\d)(?=(\d{3})+(?!\d))/g; +import { Subject } from 'rxjs'; +import { PointerEvent } from '@elastic/charts'; -/** - * Converts a number to a string and adds commas - * as thousands separators - */ -export const formatNumWithCommas = (input: number) => - String(input).replace(COMMA_SEPARATOR_RE, '$1,'); +export const activeCursor$ = new Subject(); diff --git a/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts b/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts new file mode 100644 index 0000000000000..b530ec98bd8a1 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/helpers/chart_constants.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. + */ + +export const colors = [ + '#01A4A4', + '#C66', + '#D0D102', + '#616161', + '#00A1CB', + '#32742C', + '#F18D05', + '#113F8C', + '#61AE24', + '#D70060', +]; diff --git a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts index 8ef527a181e8c..1ee834b7d30ed 100644 --- a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts +++ b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts @@ -6,18 +6,18 @@ * Side Public License, v 1. */ -import { cloneDeep, defaults, mergeWith, compact } from 'lodash'; -import $ from 'jquery'; -import moment, { Moment } from 'moment-timezone'; - -import { TimefilterContract } from 'src/plugins/data/public'; -import { IUiSettingsClient } from 'kibana/public'; +import moment from 'moment-timezone'; +import { Position, AxisSpec } from '@elastic/charts'; +import type { TimefilterContract } from 'src/plugins/data/public'; +import type { IUiSettingsClient } from 'kibana/public'; import { calculateInterval } from '../../common/lib'; import { xaxisFormatterProvider } from './xaxis_formatter'; -import { Series } from './timelion_request_handler'; +import { tickFormatters } from './tick_formatters'; + +import type { Series } from './timelion_request_handler'; -export interface Axis { +export interface IAxis { delta?: number; max?: number; min?: number; @@ -30,87 +30,26 @@ export interface Axis { tickLength: number; timezone: string; tickDecimals?: number; - tickFormatter: ((val: number) => string) | ((val: number, axis: Axis) => string); - tickGenerator?(axis: Axis): number[]; - units?: { type: string }; -} - -interface TimeRangeBounds { - min: Moment | undefined; - max: Moment | undefined; + tickFormatter: (val: number) => string; + tickGenerator?(axis: IAxis): number[]; + units?: { type: string; prefix: string; suffix: string }; + domain?: { + min?: number; + max?: number; + }; + position?: Position; + axisLabel?: string; } -export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION'; -export const eventBus = $({}); - -const colors = [ - '#01A4A4', - '#C66', - '#D0D102', - '#616161', - '#00A1CB', - '#32742C', - '#F18D05', - '#113F8C', - '#61AE24', - '#D70060', -]; - -const SERIES_ID_ATTR = 'data-series-id'; - -function buildSeriesData(chart: Series[], options: jquery.flot.plotOptions) { - const seriesData = chart.map((series: Series, seriesIndex: number) => { - const newSeries: Series = cloneDeep( - defaults(series, { - shadowSize: 0, - lines: { - lineWidth: 3, - }, - }) - ); - - newSeries._id = seriesIndex; - - if (series.color) { - const span = document.createElement('span'); - span.style.color = series.color; - newSeries.color = span.style.color; - } - - if (series._hide) { - newSeries.data = []; - newSeries.stack = false; - newSeries.label = `(hidden) ${series.label}`; - } - - if (series._global) { - mergeWith(options, series._global, (objVal, srcVal) => { - // This is kind of gross, it means that you can't replace a global value with a null - // best you can do is an empty string. Deal with it. - if (objVal == null) { - return srcVal; - } - if (srcVal == null) { - return objVal; - } - }); - } - - return newSeries; - }); +export const validateLegendPositionValue = (position: string) => /^(n|s)(e|w)$/s.test(position); - return compact(seriesData); -} - -function buildOptions( +export const createTickFormat = ( intervalValue: string, timefilter: TimefilterContract, - uiSettings: IUiSettingsClient, - clientWidth = 0, - showGrid?: boolean -) { + uiSettings: IUiSettingsClient +) => { // Get the X-axis tick format - const time: TimeRangeBounds = timefilter.getBounds(); + const time = timefilter.getBounds(); const interval = calculateInterval( (time.min && time.min.valueOf()) || 0, (time.max && time.max.valueOf()) || 0, @@ -120,61 +59,75 @@ function buildOptions( ); const format = xaxisFormatterProvider(uiSettings)(interval); - const tickLetterWidth = 7; - const tickPadding = 45; - - const options = { - xaxis: { - mode: 'time', - tickLength: 5, - timezone: 'browser', - // Calculate how many ticks can fit on the axis - ticks: Math.floor(clientWidth / (format.length * tickLetterWidth + tickPadding)), - // Use moment to format ticks so we get timezone correction - tickFormatter: (val: number) => moment(val).format(format), - }, - selection: { - mode: 'x', - color: '#ccc', - }, - crosshair: { - mode: 'x', - color: '#C66', - lineWidth: 2, - }, - colors, - grid: { - show: showGrid, - borderWidth: 0, - borderColor: null, - margin: 10, - hoverable: true, - autoHighlight: false, - }, - legend: { - backgroundColor: 'rgb(255,255,255,0)', - position: 'nw', - labelBoxBorderColor: 'rgb(255,255,255,0)', - labelFormatter(label: string, series: { _id: number }) { - const wrapperSpan = document.createElement('span'); - const labelSpan = document.createElement('span'); - const numberSpan = document.createElement('span'); - - wrapperSpan.setAttribute('class', 'ngLegendValue'); - wrapperSpan.setAttribute(SERIES_ID_ATTR, `${series._id}`); - - labelSpan.appendChild(document.createTextNode(label)); - numberSpan.setAttribute('class', 'ngLegendValueNumber'); - - wrapperSpan.appendChild(labelSpan); - wrapperSpan.appendChild(numberSpan); - - return wrapperSpan.outerHTML; - }, - }, - } as jquery.flot.plotOptions & { yaxes?: Axis[] }; - - return options; -} + return (val: number) => moment(val).format(format); +}; + +/** While we support 2 versions of the timeline, we need this adapter. **/ +export const MAIN_GROUP_ID = 1; + +export const withStaticPadding = (domain: AxisSpec['domain']): AxisSpec['domain'] => + (({ + ...domain, + padding: 50, + paddingUnit: 'pixel', + } as unknown) as AxisSpec['domain']); + +const adaptYaxisParams = (yaxis: IAxis) => { + const y = { ...yaxis }; + + if (y.units) { + const formatters = tickFormatters(y); + y.tickFormatter = formatters[y.units.type as keyof typeof formatters]; + } else if (yaxis.tickDecimals) { + y.tickFormatter = (val: number) => val.toFixed(yaxis.tickDecimals); + } + + return { + title: y.axisLabel, + position: y.position, + tickFormat: y.tickFormatter, + domain: withStaticPadding({ + fit: y.min === undefined && y.max === undefined, + min: y.min, + max: y.max, + }), + }; +}; + +const extractYAxisForSeries = (series: Series) => { + const yaxis = (series._global?.yaxes ?? []).reduce( + (acc: IAxis, item: IAxis) => ({ + ...acc, + ...item, + }), + {} + ); + + if (Object.keys(yaxis).length) { + return adaptYaxisParams(yaxis); + } +}; + +export const extractAllYAxis = (series: Series[]) => { + return series.reduce((acc, data, index) => { + const yaxis = extractYAxisForSeries(data); + const groupId = `${data.yaxis ? data.yaxis : MAIN_GROUP_ID}`; + + if (acc.every((axis) => axis.groupId !== groupId)) { + acc.push({ + groupId, + domain: withStaticPadding({ + fit: false, + }), + id: (yaxis?.position || Position.Left) + index, + position: Position.Left, + ...yaxis, + }); + } else if (yaxis) { + const axisOptionIndex = acc.findIndex((axis) => axis.groupId === groupId); + acc[axisOptionIndex] = { ...acc[axisOptionIndex], ...yaxis }; + } -export { buildSeriesData, buildOptions, SERIES_ID_ATTR, colors }; + return acc; + }, [] as Array>); +}; diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts index 03b7c21706957..9980644c0f941 100644 --- a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts +++ b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts @@ -7,25 +7,26 @@ */ import { tickFormatters } from './tick_formatters'; +import type { IAxis } from './panel_utils'; -describe('Tick Formatters', function () { +describe('Tick Formatters', () => { let formatters: any; beforeEach(function () { - formatters = tickFormatters(); + formatters = tickFormatters({} as IAxis); }); - describe('Bits mode', function () { + describe('Bits mode', () => { let bitFormatter: any; beforeEach(function () { bitFormatter = formatters.bits; }); - it('is a function', function () { + it('is a function', () => { expect(bitFormatter).toEqual(expect.any(Function)); }); - it('formats with b/kb/mb/gb', function () { + it('formats with b/kb/mb/gb', () => { expect(bitFormatter(7)).toEqual('7b'); expect(bitFormatter(4 * 1000)).toEqual('4kb'); expect(bitFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb'); @@ -40,24 +41,24 @@ describe('Tick Formatters', function () { }); }); - describe('Bits/s mode', function () { + describe('Bits/s mode', () => { let bitsFormatter: any; beforeEach(function () { bitsFormatter = formatters['bits/s']; }); - it('is a function', function () { + it('is a function', () => { expect(bitsFormatter).toEqual(expect.any(Function)); }); - it('formats with b/kb/mb/gb', function () { + it('formats with b/kb/mb/gb', () => { expect(bitsFormatter(7)).toEqual('7b/s'); expect(bitsFormatter(4 * 1000)).toEqual('4kb/s'); expect(bitsFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb/s'); expect(bitsFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb/s'); }); - it('formats negative values with b/kb/mb/gb', function () { + it('formats negative values with b/kb/mb/gb', () => { expect(bitsFormatter(-7)).toEqual('-7b/s'); expect(bitsFormatter(-4 * 1000)).toEqual('-4kb/s'); expect(bitsFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb/s'); @@ -65,24 +66,24 @@ describe('Tick Formatters', function () { }); }); - describe('Bytes mode', function () { + describe('Bytes mode', () => { let byteFormatter: any; beforeEach(function () { byteFormatter = formatters.bytes; }); - it('is a function', function () { + it('is a function', () => { expect(byteFormatter).toEqual(expect.any(Function)); }); - it('formats with B/KB/MB/GB', function () { + it('formats with B/KB/MB/GB', () => { expect(byteFormatter(10)).toEqual('10B'); expect(byteFormatter(10 * 1024)).toEqual('10KB'); expect(byteFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB'); expect(byteFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB'); }); - it('formats negative values with B/KB/MB/GB', function () { + it('formats negative values with B/KB/MB/GB', () => { expect(byteFormatter(-10)).toEqual('-10B'); expect(byteFormatter(-10 * 1024)).toEqual('-10KB'); expect(byteFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB'); @@ -90,24 +91,24 @@ describe('Tick Formatters', function () { }); }); - describe('Bytes/s mode', function () { + describe('Bytes/s mode', () => { let bytesFormatter: any; beforeEach(function () { bytesFormatter = formatters['bytes/s']; }); - it('is a function', function () { + it('is a function', () => { expect(bytesFormatter).toEqual(expect.any(Function)); }); - it('formats with B/KB/MB/GB', function () { + it('formats with B/KB/MB/GB', () => { expect(bytesFormatter(10)).toEqual('10B/s'); expect(bytesFormatter(10 * 1024)).toEqual('10KB/s'); expect(bytesFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB/s'); expect(bytesFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB/s'); }); - it('formats negative values with B/KB/MB/GB', function () { + it('formats negative values with B/KB/MB/GB', () => { expect(bytesFormatter(-10)).toEqual('-10B/s'); expect(bytesFormatter(-10 * 1024)).toEqual('-10KB/s'); expect(bytesFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB/s'); @@ -115,108 +116,105 @@ describe('Tick Formatters', function () { }); }); - describe('Currency mode', function () { + describe('Currency mode', () => { let currencyFormatter: any; beforeEach(function () { currencyFormatter = formatters.currency; }); - it('is a function', function () { + it('is a function', () => { expect(currencyFormatter).toEqual(expect.any(Function)); }); - it('formats with $ by default', function () { + it('formats with $ by default', () => { const axis = { - options: { - units: {}, - }, + units: {}, }; - expect(currencyFormatter(10.2, axis)).toEqual('$10.20'); + formatters = tickFormatters(axis as IAxis); + currencyFormatter = formatters.currency; + expect(currencyFormatter(10.2)).toEqual('$10.20'); }); - it('accepts currency in ISO 4217', function () { + it('accepts currency in ISO 4217', () => { const axis = { - options: { - units: { - prefix: 'CNY', - }, + units: { + prefix: 'CNY', }, }; - - expect(currencyFormatter(10.2, axis)).toEqual('CN¥10.20'); + formatters = tickFormatters(axis as IAxis); + currencyFormatter = formatters.currency; + expect(currencyFormatter(10.2)).toEqual('CN¥10.20'); }); }); - describe('Percent mode', function () { + describe('Percent mode', () => { let percentFormatter: any; beforeEach(function () { percentFormatter = formatters.percent; }); - it('is a function', function () { + it('is a function', () => { expect(percentFormatter).toEqual(expect.any(Function)); }); - it('formats with %', function () { + it('formats with %', () => { const axis = { - options: { - units: {}, - }, + units: {}, }; - expect(percentFormatter(0.1234, axis)).toEqual('12%'); + formatters = tickFormatters(axis as IAxis); + percentFormatter = formatters.percent; + expect(percentFormatter(0.1234)).toEqual('12%'); }); - it('formats with % with decimal precision', function () { + it('formats with % with decimal precision', () => { const tickDecimals = 3; const tickDecimalShift = 2; const axis = { tickDecimals: tickDecimals + tickDecimalShift, - options: { - units: { - tickDecimalsShift: tickDecimalShift, - }, + units: { + tickDecimalsShift: tickDecimalShift, }, - }; - expect(percentFormatter(0.12345, axis)).toEqual('12.345%'); + } as unknown; + formatters = tickFormatters(axis as IAxis); + percentFormatter = formatters.percent; + expect(percentFormatter(0.12345)).toEqual('12.345%'); }); }); - describe('Custom mode', function () { + describe('Custom mode', () => { let customFormatter: any; beforeEach(function () { customFormatter = formatters.custom; }); - it('is a function', function () { + it('is a function', () => { expect(customFormatter).toEqual(expect.any(Function)); }); - it('accepts prefix and suffix', function () { + it('accepts prefix and suffix', () => { const axis = { - options: { - units: { - prefix: 'prefix', - suffix: 'suffix', - }, + units: { + prefix: 'prefix', + suffix: 'suffix', }, tickDecimals: 1, }; - - expect(customFormatter(10.2, axis)).toEqual('prefix10.2suffix'); + formatters = tickFormatters(axis as IAxis); + customFormatter = formatters.custom; + expect(customFormatter(10.2)).toEqual('prefix10.2suffix'); }); - it('correctly renders small values', function () { + it('correctly renders small values', () => { const axis = { - options: { - units: { - prefix: 'prefix', - suffix: 'suffix', - }, + units: { + prefix: 'prefix', + suffix: 'suffix', }, tickDecimals: 3, }; - - expect(customFormatter(0.00499999999999999, axis)).toEqual('prefix0.005suffix'); + formatters = tickFormatters(axis as IAxis); + customFormatter = formatters.custom; + expect(customFormatter(0.00499999999999999)).toEqual('prefix0.005suffix'); }); }); }); diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts index 56fa17e74f275..eb37e76e1f95d 100644 --- a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts +++ b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts @@ -8,9 +8,9 @@ import { get } from 'lodash'; -import { Axis } from './panel_utils'; +import { IAxis } from './panel_utils'; -function baseTickFormatter(value: number, axis: Axis) { +function baseTickFormatter(value: number, axis: IAxis) { const factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1; const formatted = '' + Math.round(value * factor) / factor; @@ -45,21 +45,20 @@ function unitFormatter(divisor: number, units: string[]) { }; } -export function tickFormatters() { +export function tickFormatters(axis: IAxis) { return { bits: unitFormatter(1000, ['b', 'kb', 'mb', 'gb', 'tb', 'pb']), 'bits/s': unitFormatter(1000, ['b/s', 'kb/s', 'mb/s', 'gb/s', 'tb/s', 'pb/s']), bytes: unitFormatter(1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB']), 'bytes/s': unitFormatter(1024, ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s']), - currency(val: number, axis: Axis) { + currency(val: number) { return val.toLocaleString('en', { style: 'currency', - currency: (axis && axis.options && axis.options.units.prefix) || 'USD', + currency: (axis && axis.units && axis.units.prefix) || 'USD', }); }, - percent(val: number, axis: Axis) { - let precision = - get(axis, 'tickDecimals', 0) - get(axis, 'options.units.tickDecimalsShift', 0); + percent(val: number) { + let precision = get(axis, 'tickDecimals', 0) - get(axis, 'units.tickDecimalsShift', 0); // toFixed only accepts values between 0 and 20 if (precision < 0) { precision = 0; @@ -69,10 +68,10 @@ export function tickFormatters() { return (val * 100).toFixed(precision) + '%'; }, - custom(val: number, axis: Axis) { + custom(val: number) { const formattedVal = baseTickFormatter(val, axis); - const prefix = axis && axis.options && axis.options.units.prefix; - const suffix = axis && axis.options && axis.options.units.suffix; + const prefix = axis && axis.units && axis.units.prefix; + const suffix = axis && axis.units && axis.units.suffix; return prefix + formattedVal + suffix; }, }; diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts b/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts index af559d5aaac2b..6ffdda0bafdb6 100644 --- a/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts +++ b/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import { Axis } from './panel_utils'; +import { IAxis } from './panel_utils'; export function generateTicksProvider() { function floorInBase(n: number, base: number) { return base * Math.floor(n / base); } - function generateTicks(axis: Axis) { + function generateTicks(axis: IAxis) { const returnTicks = []; let tickSize = 2; let delta = axis.delta || 0; @@ -46,5 +46,5 @@ export function generateTicksProvider() { return returnTicks; } - return (axis: Axis) => generateTicks(axis); + return (axis: IAxis) => generateTicks(axis); } diff --git a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts index 7e8f28bd32b2f..fe5e9183976fa 100644 --- a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts +++ b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts @@ -12,6 +12,7 @@ import { TimelionVisDependencies } from '../plugin'; import { getTimezone } from './get_timezone'; import { TimelionVisParams } from '../timelion_vis_fn'; import { getDataSearch } from '../helpers/plugin_services'; +import { VisSeries } from '../../common/vis_data'; interface Stats { cacheCount: number; @@ -21,17 +22,13 @@ interface Stats { sheetTime: number; } -export interface Series { - _global?: boolean; +export interface Series extends VisSeries { + _global?: Record; _hide?: boolean; _id?: number; _title?: string; - color?: string; - data: Array>; fit: string; - label: string; split: string; - stack?: boolean; type: string; } diff --git a/src/plugins/vis_type_timelion/public/index.ts b/src/plugins/vis_type_timelion/public/index.ts index fa257907a176d..1ab572b497212 100644 --- a/src/plugins/vis_type_timelion/public/index.ts +++ b/src/plugins/vis_type_timelion/public/index.ts @@ -9,16 +9,26 @@ import { PluginInitializerContext } from 'kibana/public'; import { TimelionVisPlugin as Plugin } from './plugin'; +import { tickFormatters } from './legacy/tick_formatters'; +import { getTimezone } from './helpers/get_timezone'; +import { xaxisFormatterProvider } from './helpers/xaxis_formatter'; +import { generateTicksProvider } from './helpers/tick_generator'; +import { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib'; +import { parseTimelionExpressionAsync } from '../common/parser_async'; + export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } -export { getTimezone } from './helpers/get_timezone'; -export { tickFormatters } from './helpers/tick_formatters'; -export { xaxisFormatterProvider } from './helpers/xaxis_formatter'; -export { generateTicksProvider } from './helpers/tick_generator'; - -export { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib'; -export { parseTimelionExpressionAsync } from '../common/parser_async'; +// This export should be removed on removing Timeline APP +export const _LEGACY_ = { + DEFAULT_TIME_FORMAT, + calculateInterval, + parseTimelionExpressionAsync, + tickFormatters, + getTimezone, + xaxisFormatterProvider, + generateTicksProvider, +}; export { VisTypeTimelionPluginStart, VisTypeTimelionPluginSetup } from './plugin'; diff --git a/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts b/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts new file mode 100644 index 0000000000000..5dd8431a5a2ab --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts @@ -0,0 +1,168 @@ +/* + * Copyright 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 { cloneDeep, defaults, mergeWith, compact } from 'lodash'; +import $ from 'jquery'; +import moment, { Moment } from 'moment-timezone'; + +import { TimefilterContract } from 'src/plugins/data/public'; +import { IUiSettingsClient } from 'kibana/public'; + +import { calculateInterval } from '../../common/lib'; +import { xaxisFormatterProvider } from '../helpers/xaxis_formatter'; +import { Series } from '../helpers/timelion_request_handler'; +import { colors } from '../helpers/chart_constants'; + +export interface LegacyAxis { + delta?: number; + max?: number; + min?: number; + mode: string; + options?: { + units: { prefix: string; suffix: string }; + }; + tickSize?: number; + ticks: number; + tickLength: number; + timezone: string; + tickDecimals?: number; + tickFormatter: ((val: number) => string) | ((val: number, axis: LegacyAxis) => string); + tickGenerator?(axis: LegacyAxis): number[]; + units?: { type: string }; +} + +interface TimeRangeBounds { + min: Moment | undefined; + max: Moment | undefined; +} + +export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION'; +export const eventBus = $({}); + +const SERIES_ID_ATTR = 'data-series-id'; + +function buildSeriesData(chart: Series[], options: jquery.flot.plotOptions) { + const seriesData = chart.map((series: Series, seriesIndex: number) => { + const newSeries: Series = cloneDeep( + defaults(series, { + shadowSize: 0, + lines: { + lineWidth: 3, + }, + }) + ); + + newSeries._id = seriesIndex; + + if (series.color) { + const span = document.createElement('span'); + span.style.color = series.color; + newSeries.color = span.style.color; + } + + if (series._hide) { + newSeries.data = []; + newSeries.stack = false; + newSeries.label = `(hidden) ${series.label}`; + } + + if (series._global) { + mergeWith(options, series._global, (objVal, srcVal) => { + // This is kind of gross, it means that you can't replace a global value with a null + // best you can do is an empty string. Deal with it. + if (objVal == null) { + return srcVal; + } + if (srcVal == null) { + return objVal; + } + }); + } + + return newSeries; + }); + + return compact(seriesData); +} + +function buildOptions( + intervalValue: string, + timefilter: TimefilterContract, + uiSettings: IUiSettingsClient, + clientWidth = 0, + showGrid?: boolean +) { + // Get the X-axis tick format + const time: TimeRangeBounds = timefilter.getBounds(); + const interval = calculateInterval( + (time.min && time.min.valueOf()) || 0, + (time.max && time.max.valueOf()) || 0, + uiSettings.get('timelion:target_buckets') || 200, + intervalValue, + uiSettings.get('timelion:min_interval') || '1ms' + ); + const format = xaxisFormatterProvider(uiSettings)(interval); + + const tickLetterWidth = 7; + const tickPadding = 45; + + const options = { + xaxis: { + mode: 'time', + tickLength: 5, + timezone: 'browser', + // Calculate how many ticks can fit on the axis + ticks: Math.floor(clientWidth / (format.length * tickLetterWidth + tickPadding)), + // Use moment to format ticks so we get timezone correction + tickFormatter: (val: number) => moment(val).format(format), + }, + selection: { + mode: 'x', + color: '#ccc', + }, + crosshair: { + mode: 'x', + color: '#C66', + lineWidth: 2, + }, + colors, + grid: { + show: showGrid, + borderWidth: 0, + borderColor: null, + margin: 10, + hoverable: true, + autoHighlight: false, + }, + legend: { + backgroundColor: 'rgb(255,255,255,0)', + position: 'nw', + labelBoxBorderColor: 'rgb(255,255,255,0)', + labelFormatter(label: string, series: { _id: number }) { + const wrapperSpan = document.createElement('span'); + const labelSpan = document.createElement('span'); + const numberSpan = document.createElement('span'); + + wrapperSpan.setAttribute('class', 'ngLegendValue'); + wrapperSpan.setAttribute(SERIES_ID_ATTR, `${series._id}`); + + labelSpan.appendChild(document.createTextNode(label)); + numberSpan.setAttribute('class', 'ngLegendValueNumber'); + + wrapperSpan.appendChild(labelSpan); + wrapperSpan.appendChild(numberSpan); + + return wrapperSpan.outerHTML; + }, + }, + } as jquery.flot.plotOptions & { yaxes?: LegacyAxis[] }; + + return options; +} + +export { buildSeriesData, buildOptions, SERIES_ID_ATTR }; diff --git a/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts new file mode 100644 index 0000000000000..03b7c21706957 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright 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 { tickFormatters } from './tick_formatters'; + +describe('Tick Formatters', function () { + let formatters: any; + + beforeEach(function () { + formatters = tickFormatters(); + }); + + describe('Bits mode', function () { + let bitFormatter: any; + beforeEach(function () { + bitFormatter = formatters.bits; + }); + + it('is a function', function () { + expect(bitFormatter).toEqual(expect.any(Function)); + }); + + it('formats with b/kb/mb/gb', function () { + expect(bitFormatter(7)).toEqual('7b'); + expect(bitFormatter(4 * 1000)).toEqual('4kb'); + expect(bitFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb'); + expect(bitFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb'); + }); + + it('formats negative values with b/kb/mb/gb', () => { + expect(bitFormatter(-7)).toEqual('-7b'); + expect(bitFormatter(-4 * 1000)).toEqual('-4kb'); + expect(bitFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb'); + expect(bitFormatter(-3 * 1000 * 1000 * 1000)).toEqual('-3gb'); + }); + }); + + describe('Bits/s mode', function () { + let bitsFormatter: any; + beforeEach(function () { + bitsFormatter = formatters['bits/s']; + }); + + it('is a function', function () { + expect(bitsFormatter).toEqual(expect.any(Function)); + }); + + it('formats with b/kb/mb/gb', function () { + expect(bitsFormatter(7)).toEqual('7b/s'); + expect(bitsFormatter(4 * 1000)).toEqual('4kb/s'); + expect(bitsFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb/s'); + expect(bitsFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb/s'); + }); + + it('formats negative values with b/kb/mb/gb', function () { + expect(bitsFormatter(-7)).toEqual('-7b/s'); + expect(bitsFormatter(-4 * 1000)).toEqual('-4kb/s'); + expect(bitsFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb/s'); + expect(bitsFormatter(-3 * 1000 * 1000 * 1000)).toEqual('-3gb/s'); + }); + }); + + describe('Bytes mode', function () { + let byteFormatter: any; + beforeEach(function () { + byteFormatter = formatters.bytes; + }); + + it('is a function', function () { + expect(byteFormatter).toEqual(expect.any(Function)); + }); + + it('formats with B/KB/MB/GB', function () { + expect(byteFormatter(10)).toEqual('10B'); + expect(byteFormatter(10 * 1024)).toEqual('10KB'); + expect(byteFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB'); + expect(byteFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB'); + }); + + it('formats negative values with B/KB/MB/GB', function () { + expect(byteFormatter(-10)).toEqual('-10B'); + expect(byteFormatter(-10 * 1024)).toEqual('-10KB'); + expect(byteFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB'); + expect(byteFormatter(-3 * 1024 * 1024 * 1024)).toEqual('-3GB'); + }); + }); + + describe('Bytes/s mode', function () { + let bytesFormatter: any; + beforeEach(function () { + bytesFormatter = formatters['bytes/s']; + }); + + it('is a function', function () { + expect(bytesFormatter).toEqual(expect.any(Function)); + }); + + it('formats with B/KB/MB/GB', function () { + expect(bytesFormatter(10)).toEqual('10B/s'); + expect(bytesFormatter(10 * 1024)).toEqual('10KB/s'); + expect(bytesFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB/s'); + expect(bytesFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB/s'); + }); + + it('formats negative values with B/KB/MB/GB', function () { + expect(bytesFormatter(-10)).toEqual('-10B/s'); + expect(bytesFormatter(-10 * 1024)).toEqual('-10KB/s'); + expect(bytesFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB/s'); + expect(bytesFormatter(-3 * 1024 * 1024 * 1024)).toEqual('-3GB/s'); + }); + }); + + describe('Currency mode', function () { + let currencyFormatter: any; + beforeEach(function () { + currencyFormatter = formatters.currency; + }); + + it('is a function', function () { + expect(currencyFormatter).toEqual(expect.any(Function)); + }); + + it('formats with $ by default', function () { + const axis = { + options: { + units: {}, + }, + }; + expect(currencyFormatter(10.2, axis)).toEqual('$10.20'); + }); + + it('accepts currency in ISO 4217', function () { + const axis = { + options: { + units: { + prefix: 'CNY', + }, + }, + }; + + expect(currencyFormatter(10.2, axis)).toEqual('CN¥10.20'); + }); + }); + + describe('Percent mode', function () { + let percentFormatter: any; + beforeEach(function () { + percentFormatter = formatters.percent; + }); + + it('is a function', function () { + expect(percentFormatter).toEqual(expect.any(Function)); + }); + + it('formats with %', function () { + const axis = { + options: { + units: {}, + }, + }; + expect(percentFormatter(0.1234, axis)).toEqual('12%'); + }); + + it('formats with % with decimal precision', function () { + const tickDecimals = 3; + const tickDecimalShift = 2; + const axis = { + tickDecimals: tickDecimals + tickDecimalShift, + options: { + units: { + tickDecimalsShift: tickDecimalShift, + }, + }, + }; + expect(percentFormatter(0.12345, axis)).toEqual('12.345%'); + }); + }); + + describe('Custom mode', function () { + let customFormatter: any; + beforeEach(function () { + customFormatter = formatters.custom; + }); + + it('is a function', function () { + expect(customFormatter).toEqual(expect.any(Function)); + }); + + it('accepts prefix and suffix', function () { + const axis = { + options: { + units: { + prefix: 'prefix', + suffix: 'suffix', + }, + }, + tickDecimals: 1, + }; + + expect(customFormatter(10.2, axis)).toEqual('prefix10.2suffix'); + }); + + it('correctly renders small values', function () { + const axis = { + options: { + units: { + prefix: 'prefix', + suffix: 'suffix', + }, + }, + tickDecimals: 3, + }; + + expect(customFormatter(0.00499999999999999, axis)).toEqual('prefix0.005suffix'); + }); + }); +}); diff --git a/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts new file mode 100644 index 0000000000000..950226968287b --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { get } from 'lodash'; + +import type { LegacyAxis } from './panel_utils'; + +function baseTickFormatter(value: number, axis: LegacyAxis) { + const factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1; + const formatted = '' + Math.round(value * factor) / factor; + + // If tickDecimals was specified, ensure that we have exactly that + // much precision; otherwise default to the value's own precision. + + if (axis.tickDecimals != null) { + const decimal = formatted.indexOf('.'); + const precision = decimal === -1 ? 0 : formatted.length - decimal - 1; + if (precision < axis.tickDecimals) { + return ( + (precision ? formatted : formatted + '.') + + ('' + factor).substr(1, axis.tickDecimals - precision) + ); + } + } + + return formatted; +} + +function unitFormatter(divisor: number, units: string[]) { + return (val: number) => { + let index = 0; + const isNegative = val < 0; + val = Math.abs(val); + while (val >= divisor && index < units.length) { + val /= divisor; + index++; + } + const value = (Math.round(val * 100) / 100) * (isNegative ? -1 : 1); + return `${value}${units[index]}`; + }; +} + +export function tickFormatters() { + return { + bits: unitFormatter(1000, ['b', 'kb', 'mb', 'gb', 'tb', 'pb']), + 'bits/s': unitFormatter(1000, ['b/s', 'kb/s', 'mb/s', 'gb/s', 'tb/s', 'pb/s']), + bytes: unitFormatter(1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB']), + 'bytes/s': unitFormatter(1024, ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s']), + currency(val: number, axis: LegacyAxis) { + return val.toLocaleString('en', { + style: 'currency', + currency: (axis && axis.options && axis.options.units.prefix) || 'USD', + }); + }, + percent(val: number, axis: LegacyAxis) { + let precision = + get(axis, 'tickDecimals', 0) - get(axis, 'options.units.tickDecimalsShift', 0); + // toFixed only accepts values between 0 and 20 + if (precision < 0) { + precision = 0; + } else if (precision > 20) { + precision = 20; + } + + return (val * 100).toFixed(precision) + '%'; + }, + custom(val: number, axis: LegacyAxis) { + const formattedVal = baseTickFormatter(val, axis); + const prefix = axis && axis.options && axis.options.units.prefix; + const suffix = axis && axis.options && axis.options.units.suffix; + return prefix + formattedVal + suffix; + }, + }; +} diff --git a/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss b/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss new file mode 100644 index 0000000000000..c4d591bc82cad --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss @@ -0,0 +1,60 @@ +.timChart { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + + // Custom Jquery FLOT / schema selectors + // Cannot change at the moment + + .chart-top-title { + @include euiFontSizeXS; + flex: 0; + text-align: center; + font-weight: $euiFontWeightBold; + } + + .chart-canvas { + min-width: 100%; + flex: 1; + overflow: hidden; + } + + .legendLabel { + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; + line-height: normal; + } + + .legendColorBox { + vertical-align: middle; + } + + .ngLegendValue { + color: $euiTextColor; + cursor: pointer; + + &:focus, + &:hover { + text-decoration: underline; + } + } + + .ngLegendValueNumber { + margin-left: $euiSizeXS; + margin-right: $euiSizeXS; + font-weight: $euiFontWeightBold; + } + + .flot-tick-label { + font-size: $euiFontSizeXS; + color: $euiColorDarkShade; + } +} + +.timChart__legendCaption { + color: $euiTextColor; + white-space: nowrap; + font-weight: $euiFontWeightBold; +} diff --git a/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx b/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx new file mode 100644 index 0000000000000..ddac86fa73bee --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx @@ -0,0 +1,418 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import $ from 'jquery'; +import moment from 'moment-timezone'; +import { debounce, compact, get, each, cloneDeep, last, map } from 'lodash'; +import { useResizeObserver } from '@elastic/eui'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { useKibana } from '../../../kibana_react/public'; +import { DEFAULT_TIME_FORMAT } from '../../common/lib'; + +import { + buildSeriesData, + buildOptions, + SERIES_ID_ATTR, + LegacyAxis, + ACTIVE_CURSOR, + eventBus, +} from './panel_utils'; + +import { Series, Sheet } from '../helpers/timelion_request_handler'; +import { colors } from '../helpers/chart_constants'; +import { tickFormatters } from './tick_formatters'; +import { generateTicksProvider } from '../helpers/tick_generator'; + +import type { TimelionVisDependencies } from '../plugin'; +import type { RangeFilterParams } from '../../../data/common'; + +import './timelion_vis.scss'; + +interface CrosshairPlot extends jquery.flot.plot { + setCrosshair: (pos: Position) => void; + clearCrosshair: () => void; +} + +interface TimelionVisComponentProps { + onBrushEvent: (rangeFilterParams: RangeFilterParams) => void; + interval: string; + seriesList: Sheet; + renderComplete: IInterpreterRenderHandlers['done']; +} + +interface Position { + x: number; + x1: number; + y: number; + y1: number; + pageX: number; + pageY: number; +} + +interface Range { + to: number; + from: number; +} + +interface Ranges { + xaxis: Range; + yaxis: Range; +} + +const DEBOUNCE_DELAY = 50; +// ensure legend is the same height with or without a caption so legend items do not move around +const emptyCaption = '
'; + +function TimelionVisComponent({ + interval, + seriesList, + renderComplete, + onBrushEvent, +}: TimelionVisComponentProps) { + const kibana = useKibana(); + const [chart, setChart] = useState(() => cloneDeep(seriesList.list)); + const [canvasElem, setCanvasElem] = useState(); + const [chartElem, setChartElem] = useState(null); + + const [originalColorMap, setOriginalColorMap] = useState(() => new Map()); + + const [highlightedSeries, setHighlightedSeries] = useState(null); + const [focusedSeries, setFocusedSeries] = useState(); + const [plot, setPlot] = useState(); + + // Used to toggle the series, and for displaying values on hover + const [legendValueNumbers, setLegendValueNumbers] = useState>(); + const [legendCaption, setLegendCaption] = useState>(); + + const canvasRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setCanvasElem(node); + } + }, []); + + const elementRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setChartElem(node); + } + }, []); + + useEffect( + () => () => { + if (chartElem) { + $(chartElem).off('plotselected').off('plothover').off('mouseleave'); + } + }, + [chartElem] + ); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + const highlightSeries = useCallback( + debounce(({ currentTarget }: JQuery.TriggeredEvent) => { + const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); + if (highlightedSeries === id) { + return; + } + + setHighlightedSeries(id); + setChart((chartState) => + chartState.map((series: Series, seriesIndex: number) => { + series.color = + seriesIndex === id + ? originalColorMap.get(series) // color it like it was + : 'rgba(128,128,128,0.1)'; // mark as grey + + return series; + }) + ); + }, DEBOUNCE_DELAY), + [originalColorMap, highlightedSeries] + ); + + const focusSeries = useCallback( + (event: JQuery.TriggeredEvent) => { + const id = Number(event.currentTarget.getAttribute(SERIES_ID_ATTR)); + setFocusedSeries(id); + highlightSeries(event); + }, + [highlightSeries] + ); + + const toggleSeries = useCallback(({ currentTarget }: JQuery.TriggeredEvent) => { + const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); + + setChart((chartState) => + chartState.map((series: Series, seriesIndex: number) => { + if (seriesIndex === id) { + series._hide = !series._hide; + } + return series; + }) + ); + }, []); + + const updateCaption = useCallback( + (plotData: any) => { + if (canvasElem && get(plotData, '[0]._global.legend.showTime', true)) { + const caption = $(''); + caption.html(emptyCaption); + setLegendCaption(caption); + + const canvasNode = $(canvasElem); + canvasNode.find('div.legend table').append(caption); + setLegendValueNumbers(canvasNode.find('.ngLegendValueNumber')); + + const legend = $(canvasElem).find('.ngLegendValue'); + if (legend) { + legend.click(toggleSeries); + legend.focus(focusSeries); + legend.mouseover(highlightSeries); + } + + // legend has been re-created. Apply focus on legend element when previously set + if (focusedSeries || focusedSeries === 0) { + canvasNode.find('div.legend table .legendLabel>span').get(focusedSeries).focus(); + } + } + }, + [focusedSeries, canvasElem, toggleSeries, focusSeries, highlightSeries] + ); + + const updatePlot = useCallback( + (chartValue: Series[], grid?: boolean) => { + if (canvasElem && canvasElem.clientWidth > 0 && canvasElem.clientHeight > 0) { + const options = buildOptions( + interval, + kibana.services.timefilter, + kibana.services.uiSettings, + chartElem?.clientWidth, + grid + ); + const updatedSeries = buildSeriesData(chartValue, options); + + if (options.yaxes) { + options.yaxes.forEach((yaxis: LegacyAxis) => { + if (yaxis && yaxis.units) { + const formatters = tickFormatters(); + yaxis.tickFormatter = formatters[yaxis.units.type as keyof typeof formatters]; + const byteModes = ['bytes', 'bytes/s']; + if (byteModes.includes(yaxis.units.type)) { + yaxis.tickGenerator = generateTicksProvider(); + } + } + }); + } + + const newPlot = $.plot($(canvasElem), updatedSeries, options); + setPlot(newPlot); + renderComplete(); + + updateCaption(newPlot.getData()); + } + }, + [canvasElem, chartElem?.clientWidth, renderComplete, kibana.services, interval, updateCaption] + ); + + const dimensions = useResizeObserver(chartElem); + + useEffect(() => { + updatePlot(chart, seriesList.render && seriesList.render.grid); + }, [chart, updatePlot, seriesList.render, dimensions]); + + useEffect(() => { + const colorsSet: Array<[Series, string]> = []; + const newChart = seriesList.list.map((series: Series, seriesIndex: number) => { + const newSeries = { ...series }; + if (!newSeries.color) { + const colorIndex = seriesIndex % colors.length; + newSeries.color = colors[colorIndex]; + } + colorsSet.push([newSeries, newSeries.color]); + return newSeries; + }); + setChart(newChart); + setOriginalColorMap(new Map(colorsSet)); + }, [seriesList.list]); + + const unhighlightSeries = useCallback(() => { + if (highlightedSeries === null) { + return; + } + + setHighlightedSeries(null); + setFocusedSeries(null); + + setChart((chartState) => + chartState.map((series: Series) => { + series.color = originalColorMap.get(series); // reset the colors + return series; + }) + ); + }, [originalColorMap, highlightedSeries]); + + // Shamelessly borrowed from the flotCrosshairs example + const setLegendNumbers = useCallback( + (pos: Position) => { + unhighlightSeries(); + + const axes = plot!.getAxes(); + if (pos.x < axes.xaxis.min! || pos.x > axes.xaxis.max!) { + return; + } + + const dataset = plot!.getData(); + if (legendCaption) { + legendCaption.text( + moment(pos.x).format(get(dataset, '[0]._global.legend.timeFormat', DEFAULT_TIME_FORMAT)) + ); + } + for (let i = 0; i < dataset.length; ++i) { + const series = dataset[i]; + const useNearestPoint = series.lines!.show && !series.lines!.steps; + const precision = get(series, '_meta.precision', 2); + + // We're setting this flag on top on the series object belonging to the flot library, so we're simply casting here. + if ((series as { _hide?: boolean })._hide) { + continue; + } + + const currentPoint = series.data.find((point: [number, number], index: number) => { + if (index + 1 === series.data.length) { + return true; + } + if (useNearestPoint) { + return pos.x - point[0] < series.data[index + 1][0] - pos.x; + } else { + return pos.x < series.data[index + 1][0]; + } + }); + + const y = currentPoint[1]; + + if (legendValueNumbers) { + if (y == null) { + legendValueNumbers.eq(i).empty(); + } else { + let label = y.toFixed(precision); + const formatter = ((series.yaxis as unknown) as LegacyAxis).tickFormatter; + if (formatter) { + label = formatter(Number(label), (series.yaxis as unknown) as LegacyAxis); + } + legendValueNumbers.eq(i).text(`(${label})`); + } + } + } + }, + [plot, legendValueNumbers, unhighlightSeries, legendCaption] + ); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + const debouncedSetLegendNumbers = useCallback( + debounce(setLegendNumbers, DEBOUNCE_DELAY, { + maxWait: DEBOUNCE_DELAY, + leading: true, + trailing: false, + }), + [setLegendNumbers] + ); + + const clearLegendNumbers = useCallback(() => { + if (legendCaption) { + legendCaption.html(emptyCaption); + } + each(legendValueNumbers!, (num: Node) => { + $(num).empty(); + }); + }, [legendCaption, legendValueNumbers]); + + const plotHover = useCallback( + (pos: Position) => { + (plot as CrosshairPlot).setCrosshair(pos); + debouncedSetLegendNumbers(pos); + }, + [plot, debouncedSetLegendNumbers] + ); + + const plotHoverHandler = useCallback( + (event: JQuery.TriggeredEvent, pos: Position) => { + if (!plot) { + return; + } + plotHover(pos); + eventBus.trigger(ACTIVE_CURSOR, [event, pos]); + }, + [plot, plotHover] + ); + + useEffect(() => { + const updateCursor = (_: any, event: JQuery.TriggeredEvent, pos: Position) => { + if (!plot) { + return; + } + plotHover(pos); + }; + + eventBus.on(ACTIVE_CURSOR, updateCursor); + + return () => { + eventBus.off(ACTIVE_CURSOR, updateCursor); + }; + }, [plot, plotHover]); + + const mouseLeaveHandler = useCallback(() => { + if (!plot) { + return; + } + (plot as CrosshairPlot).clearCrosshair(); + clearLegendNumbers(); + }, [plot, clearLegendNumbers]); + + const plotSelectedHandler = useCallback( + (event: JQuery.TriggeredEvent, ranges: Ranges) => { + onBrushEvent({ + gte: ranges.xaxis.from, + lte: ranges.xaxis.to, + }); + }, + [onBrushEvent] + ); + + useEffect(() => { + if (chartElem) { + $(chartElem).off('plotselected').on('plotselected', plotSelectedHandler); + } + }, [chartElem, plotSelectedHandler]); + + useEffect(() => { + if (chartElem) { + $(chartElem).off('mouseleave').on('mouseleave', mouseLeaveHandler); + } + }, [chartElem, mouseLeaveHandler]); + + useEffect(() => { + if (chartElem) { + $(chartElem).off('plothover').on('plothover', plotHoverHandler); + } + }, [chartElem, plotHoverHandler]); + + const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [ + seriesList.list, + ]); + + return ( +
+
{title}
+
+
+ ); +} + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { TimelionVisComponent as default }; diff --git a/src/plugins/vis_type_timelion/public/plugin.ts b/src/plugins/vis_type_timelion/public/plugin.ts index 3e9c4cf77687e..93712ae4507fe 100644 --- a/src/plugins/vis_type_timelion/public/plugin.ts +++ b/src/plugins/vis_type_timelion/public/plugin.ts @@ -22,6 +22,7 @@ import { } from 'src/plugins/data/public'; import { VisualizationsSetup } from '../../visualizations/public'; +import { ChartsPluginSetup } from '../../charts/public'; import { getTimelionVisualizationConfig } from './timelion_vis_fn'; import { getTimelionVisDefinition } from './timelion_vis_type'; @@ -36,6 +37,7 @@ export interface TimelionVisDependencies extends Partial { uiSettings: IUiSettingsClient; http: HttpSetup; timefilter: TimefilterContract; + chartTheme: ChartsPluginSetup['theme']; } /** @internal */ @@ -43,6 +45,7 @@ export interface TimelionVisSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + charts: ChartsPluginSetup; } /** @internal */ @@ -72,13 +75,14 @@ export class TimelionVisPlugin constructor(public initializerContext: PluginInitializerContext) {} public setup( - core: CoreSetup, - { expressions, visualizations, data }: TimelionVisSetupDependencies + { uiSettings, http }: CoreSetup, + { expressions, visualizations, data, charts }: TimelionVisSetupDependencies ) { const dependencies: TimelionVisDependencies = { - uiSettings: core.uiSettings, - http: core.http, + http, + uiSettings, timefilter: data.query.timefilter.timefilter, + chartTheme: charts.theme, }; expressions.registerFunction(() => getTimelionVisualizationConfig(dependencies)); diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx index 6ef5d29ea8a91..b14055a4d6b63 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx +++ b/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx @@ -14,8 +14,11 @@ import { KibanaContextProvider } from '../../kibana_react/public'; import { VisualizationContainer } from '../../visualizations/public'; import { TimelionVisDependencies } from './plugin'; import { TimelionRenderValue } from './timelion_vis_fn'; -// @ts-ignore +import { UI_SETTINGS } from '../common/constants'; +import { RangeFilterParams } from '../../data/public'; + const TimelionVisComponent = lazy(() => import('./components/timelion_vis_component')); +const TimelionVisLegacyComponent = lazy(() => import('./legacy/timelion_vis_component')); export const getTimelionVisRenderer: ( deps: TimelionVisDependencies @@ -31,14 +34,34 @@ export const getTimelionVisRenderer: ( const [seriesList] = visData.sheet; const showNoResult = !seriesList || !seriesList.list.length; + const VisComponent = deps.uiSettings.get(UI_SETTINGS.LEGACY_CHARTS_LIBRARY, false) + ? TimelionVisLegacyComponent + : TimelionVisComponent; + + const onBrushEvent = (rangeFilterParams: RangeFilterParams) => { + handlers.event({ + name: 'applyFilter', + data: { + timeFieldName: '*', + filters: [ + { + range: { + '*': rangeFilterParams, + }, + }, + ], + }, + }); + }; + render( - , diff --git a/src/plugins/vis_type_timelion/server/plugin.ts b/src/plugins/vis_type_timelion/server/plugin.ts index c1800a09ba35c..fc23569b351e6 100644 --- a/src/plugins/vis_type_timelion/server/plugin.ts +++ b/src/plugins/vis_type_timelion/server/plugin.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import { TypeOf, schema } from '@kbn/config-schema'; +import { TypeOf } from '@kbn/config-schema'; import { RecursiveReadonly } from '@kbn/utility-types'; import { deepFreeze } from '@kbn/std'; @@ -19,10 +19,7 @@ import { functionsRoute } from './routes/functions'; import { validateEsRoute } from './routes/validate_es'; import { runRoute } from './routes/run'; import { ConfigManager } from './lib/config_manager'; - -const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { - defaultMessage: 'experimental', -}); +import { getUiSettings } from './ui_settings'; /** * Describes public Timelion plugin contract returned at the `setup` stage. @@ -78,97 +75,7 @@ export class TimelionPlugin runRoute(router, deps); validateEsRoute(router); - core.uiSettings.register({ - 'timelion:es.timefield': { - name: i18n.translate('timelion.uiSettings.timeFieldLabel', { - defaultMessage: 'Time field', - }), - value: '@timestamp', - description: i18n.translate('timelion.uiSettings.timeFieldDescription', { - defaultMessage: 'Default field containing a timestamp when using {esParam}', - values: { esParam: '.es()' }, - }), - category: ['timelion'], - schema: schema.string(), - }, - 'timelion:es.default_index': { - name: i18n.translate('timelion.uiSettings.defaultIndexLabel', { - defaultMessage: 'Default index', - }), - value: '_all', - description: i18n.translate('timelion.uiSettings.defaultIndexDescription', { - defaultMessage: 'Default elasticsearch index to search with {esParam}', - values: { esParam: '.es()' }, - }), - category: ['timelion'], - schema: schema.string(), - }, - 'timelion:target_buckets': { - name: i18n.translate('timelion.uiSettings.targetBucketsLabel', { - defaultMessage: 'Target buckets', - }), - value: 200, - description: i18n.translate('timelion.uiSettings.targetBucketsDescription', { - defaultMessage: 'The number of buckets to shoot for when using auto intervals', - }), - category: ['timelion'], - schema: schema.number(), - }, - 'timelion:max_buckets': { - name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', { - defaultMessage: 'Maximum buckets', - }), - value: 2000, - description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', { - defaultMessage: 'The maximum number of buckets a single datasource can return', - }), - category: ['timelion'], - schema: schema.number(), - }, - 'timelion:min_interval': { - name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', { - defaultMessage: 'Minimum interval', - }), - value: '1ms', - description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', { - defaultMessage: 'The smallest interval that will be calculated when using "auto"', - description: - '"auto" is a technical value in that context, that should not be translated.', - }), - category: ['timelion'], - schema: schema.string(), - }, - 'timelion:graphite.url': { - name: i18n.translate('timelion.uiSettings.graphiteURLLabel', { - defaultMessage: 'Graphite URL', - description: - 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', - }), - value: config.graphiteUrls && config.graphiteUrls.length ? config.graphiteUrls[0] : null, - description: i18n.translate('timelion.uiSettings.graphiteURLDescription', { - defaultMessage: - '{experimentalLabel} The URL of your graphite host', - values: { experimentalLabel: `[${experimentalLabel}]` }, - }), - type: 'select', - options: config.graphiteUrls || [], - category: ['timelion'], - schema: schema.nullable(schema.string()), - }, - 'timelion:quandl.key': { - name: i18n.translate('timelion.uiSettings.quandlKeyLabel', { - defaultMessage: 'Quandl key', - }), - value: 'someKeyHere', - description: i18n.translate('timelion.uiSettings.quandlKeyDescription', { - defaultMessage: '{experimentalLabel} Your API key from www.quandl.com', - values: { experimentalLabel: `[${experimentalLabel}]` }, - }), - sensitive: true, - category: ['timelion'], - schema: schema.string(), - }, - }); + core.uiSettings.register(getUiSettings(config)); return deepFreeze({ uiEnabled: config.ui.enabled }); } diff --git a/src/plugins/vis_type_timelion/server/series_functions/label.js b/src/plugins/vis_type_timelion/server/series_functions/label.js index d31320d3ad6e9..30a5c626251d1 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/label.js +++ b/src/plugins/vis_type_timelion/server/series_functions/label.js @@ -44,7 +44,7 @@ export default new Chainable('label', { // that it doesn't prevent Kibana from starting up and we only have an issue using Timelion labels const RE2 = require('re2'); eachSeries.label = eachSeries.label.replace(new RE2(config.regex), config.label); - } else { + } else if (config.label) { eachSeries.label = config.label; } diff --git a/src/plugins/vis_type_timelion/server/ui_settings.ts b/src/plugins/vis_type_timelion/server/ui_settings.ts new file mode 100644 index 0000000000000..1d8dc997a3f6a --- /dev/null +++ b/src/plugins/vis_type_timelion/server/ui_settings.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import type { UiSettingsParams } from 'kibana/server'; + +import { UI_SETTINGS } from '../common/constants'; +import { configSchema } from '../config'; + +const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { + defaultMessage: 'experimental', +}); + +export function getUiSettings( + config: TypeOf +): Record> { + return { + [UI_SETTINGS.LEGACY_CHARTS_LIBRARY]: { + name: i18n.translate('timelion.uiSettings.legacyChartsLibraryLabel', { + defaultMessage: 'Timelion legacy charts library', + }), + description: i18n.translate('timelion.uiSettings.legacyChartsLibraryDescription', { + defaultMessage: 'Enables the legacy charts library for timelion charts in Visualize', + }), + deprecation: { + message: i18n.translate('timelion.uiSettings.legacyChartsLibraryDeprication', { + defaultMessage: 'This setting is deprecated and will not be supported as of 8.0.', + }), + docLinksKey: 'timelionSettings', + }, + value: false, + category: ['timelion'], + schema: schema.boolean(), + }, + [UI_SETTINGS.ES_TIMEFIELD]: { + name: i18n.translate('timelion.uiSettings.timeFieldLabel', { + defaultMessage: 'Time field', + }), + value: '@timestamp', + description: i18n.translate('timelion.uiSettings.timeFieldDescription', { + defaultMessage: 'Default field containing a timestamp when using {esParam}', + values: { esParam: '.es()' }, + }), + category: ['timelion'], + schema: schema.string(), + }, + [UI_SETTINGS.DEFAULT_INDEX]: { + name: i18n.translate('timelion.uiSettings.defaultIndexLabel', { + defaultMessage: 'Default index', + }), + value: '_all', + description: i18n.translate('timelion.uiSettings.defaultIndexDescription', { + defaultMessage: 'Default elasticsearch index to search with {esParam}', + values: { esParam: '.es()' }, + }), + category: ['timelion'], + schema: schema.string(), + }, + [UI_SETTINGS.TARGET_BUCKETS]: { + name: i18n.translate('timelion.uiSettings.targetBucketsLabel', { + defaultMessage: 'Target buckets', + }), + value: 200, + description: i18n.translate('timelion.uiSettings.targetBucketsDescription', { + defaultMessage: 'The number of buckets to shoot for when using auto intervals', + }), + category: ['timelion'], + schema: schema.number(), + }, + [UI_SETTINGS.MAX_BUCKETS]: { + name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', { + defaultMessage: 'Maximum buckets', + }), + value: 2000, + description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', { + defaultMessage: 'The maximum number of buckets a single datasource can return', + }), + category: ['timelion'], + schema: schema.number(), + }, + [UI_SETTINGS.MIN_INTERVAL]: { + name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', { + defaultMessage: 'Minimum interval', + }), + value: '1ms', + description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', { + defaultMessage: 'The smallest interval that will be calculated when using "auto"', + description: '"auto" is a technical value in that context, that should not be translated.', + }), + category: ['timelion'], + schema: schema.string(), + }, + [UI_SETTINGS.GRAPHITE_URL]: { + name: i18n.translate('timelion.uiSettings.graphiteURLLabel', { + defaultMessage: 'Graphite URL', + description: + 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', + }), + value: config.graphiteUrls && config.graphiteUrls.length ? config.graphiteUrls[0] : null, + description: i18n.translate('timelion.uiSettings.graphiteURLDescription', { + defaultMessage: + '{experimentalLabel} The URL of your graphite host', + values: { experimentalLabel: `[${experimentalLabel}]` }, + }), + type: 'select', + options: config.graphiteUrls || [], + category: ['timelion'], + schema: schema.nullable(schema.string()), + }, + [UI_SETTINGS.QUANDL_KEY]: { + name: i18n.translate('timelion.uiSettings.quandlKeyLabel', { + defaultMessage: 'Quandl key', + }), + value: 'someKeyHere', + description: i18n.translate('timelion.uiSettings.quandlKeyDescription', { + defaultMessage: '{experimentalLabel} Your API key from www.quandl.com', + values: { experimentalLabel: `[${experimentalLabel}]` }, + }), + sensitive: true, + category: ['timelion'], + schema: schema.string(), + }, + }; +} diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts b/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts index 436a5b85a6887..886745ba19563 100644 --- a/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts +++ b/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts @@ -8,7 +8,6 @@ import d3 from 'd3'; import $ from 'jquery'; -import { IScope } from 'angular'; export interface Emitter { on: (...args: any[]) => void; @@ -20,13 +19,6 @@ export interface Emitter { export class Binder { private disposal: Array<() => void> = []; - constructor($scope: IScope) { - // support auto-binding to $scope objects - if ($scope) { - $scope.$on('$destroy', () => this.destroy()); - } - } - public on(emitter: Emitter, ...args: any[]) { const on = emitter.on || emitter.addListener; const off = emitter.off || emitter.removeListener; diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 616c07dd8cf9c..8dc695abc8d45 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -108,7 +108,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should modify the time range when the histogram is brushed', async function () { // this is the number of renderings of the histogram needed when new data is fetched // this needs to be improved - const renderingCountInc = 1; + const renderingCountInc = 2; const prevRenderingCount = await elasticChart.getVisualizationRenderingCount(); await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitUntilSearchingHasFinished(); @@ -268,8 +268,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': true }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); - - expect(await PageObjects.discover.getNrOfFetches()).to.be(1); + await retry.waitFor('number of fetches to be 1', async () => { + const nrOfFetches = await PageObjects.discover.getNrOfFetches(); + return nrOfFetches === 1; + }); }); }); diff --git a/test/functional/apps/discover/_inspector.ts b/test/functional/apps/discover/_inspector.ts index 17f358ec74871..9ff425be2739b 100644 --- a/test/functional/apps/discover/_inspector.ts +++ b/test/functional/apps/discover/_inspector.ts @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const inspector = getService('inspector'); + const testSubjects = getService('testSubjects'); const STATS_ROW_NAME_INDEX = 0; const STATS_ROW_VALUE_INDEX = 1; @@ -50,15 +51,28 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should display request stats with no results', async () => { await inspector.open(); - const requestStats = await inspector.getTableData(); - - expect(getHitCount(requestStats)).to.be('0'); + await testSubjects.click('inspectorRequestChooser'); + let foundZero = false; + for (const subj of ['Documents', 'Total hits', 'Charts']) { + await testSubjects.click(`inspectorRequestChooser${subj}`); + if (testSubjects.exists('inspectorRequestDetailStatistics', { timeout: 500 })) { + await testSubjects.click(`inspectorRequestDetailStatistics`); + const requestStatsTotalHits = getHitCount(await inspector.getTableData()); + if (requestStatsTotalHits === '0') { + foundZero = true; + break; + } + } + } + expect(foundZero).to.be(true); }); it('should display request stats with results', async () => { await PageObjects.timePicker.setDefaultAbsoluteRange(); - await inspector.open(); + await testSubjects.click('inspectorRequestChooser'); + await testSubjects.click(`inspectorRequestChooserDocuments`); + await testSubjects.click(`inspectorRequestDetailStatistics`); const requestStats = await inspector.getTableData(); expect(getHitCount(requestStats)).to.be('500'); diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index decf1618c7879..37c6a45557f2f 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -138,7 +138,7 @@ export class SavedQueryManagementComponentService extends FtrService { async savedQueryExist(title: string) { await this.openSavedQueryManagementComponent(); - const exists = this.testSubjects.exists(`~load-saved-query-${title}-button`); + const exists = await this.testSubjects.exists(`~load-saved-query-${title}-button`); await this.closeSavedQueryManagementComponent(); return exists; } diff --git a/x-pack/plugins/alerting/server/lib/license_state.mock.ts b/x-pack/plugins/alerting/server/lib/license_state.mock.ts index 3932ce9329824..1521a1cf25da9 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.mock.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.mock.ts @@ -18,6 +18,7 @@ export const createLicenseStateMock = () => { checkLicense: jest.fn().mockResolvedValue({ state: 'valid', }), + getIsSecurityEnabled: jest.fn(), setNotifyUsage: jest.fn(), }; return licenseState; diff --git a/x-pack/plugins/alerting/server/lib/license_state.test.ts b/x-pack/plugins/alerting/server/lib/license_state.test.ts index 6cfe368245842..e20acafbab314 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.test.ts @@ -272,6 +272,42 @@ describe('ensureLicenseForAlertType()', () => { }); }); +describe('getIsSecurityEnabled()', () => { + let license: Subject; + let licenseState: ILicenseState; + beforeEach(() => { + license = new Subject(); + licenseState = new LicenseState(license); + }); + + test('should return null when license is not defined', () => { + expect(licenseState.getIsSecurityEnabled()).toBeNull(); + }); + + test('should return null when license is unavailable', () => { + license.next(createUnavailableLicense()); + expect(licenseState.getIsSecurityEnabled()).toBeNull(); + }); + + test('should return true if security is enabled', () => { + const basicLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'basic' }, + features: { security: { isEnabled: true, isAvailable: true } }, + }); + license.next(basicLicense); + expect(licenseState.getIsSecurityEnabled()).toEqual(true); + }); + + test('should return false if security is not enabled', () => { + const basicLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'basic' }, + features: { security: { isEnabled: false, isAvailable: true } }, + }); + license.next(basicLicense); + expect(licenseState.getIsSecurityEnabled()).toEqual(false); + }); +}); + function createUnavailableLicense() { const unavailableLicense = licensingMock.createLicenseMock(); unavailableLicense.isAvailable = false; diff --git a/x-pack/plugins/alerting/server/lib/license_state.ts b/x-pack/plugins/alerting/server/lib/license_state.ts index 837fecde11659..9f6fd1b292af8 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.ts @@ -55,6 +55,15 @@ export class LicenseState { return this.licenseInformation; } + public getIsSecurityEnabled(): boolean | null { + if (!this.license || !this.license?.isAvailable) { + return null; + } + + const { isEnabled } = this.license.getFeature('security'); + return isEnabled; + } + public setNotifyUsage(notifyUsage: LicensingPluginStart['featureUsage']['notifyUsage']) { this._notifyUsage = notifyUsage; } diff --git a/x-pack/plugins/alerting/server/routes/health.test.ts b/x-pack/plugins/alerting/server/routes/health.test.ts index 7c00b04ae7ef3..b8e023e4f4d1b 100644 --- a/x-pack/plugins/alerting/server/routes/health.test.ts +++ b/x-pack/plugins/alerting/server/routes/health.test.ts @@ -21,7 +21,6 @@ jest.mock('../lib/license_api_access.ts', () => ({ })); const alerting = alertsMock.createStart(); - const currentDate = new Date().toISOString(); beforeEach(() => { jest.resetAllMocks(); @@ -62,7 +61,15 @@ describe('healthRoute', () => { healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; - const [context, req, res] = mockHandlerArguments({ rulesClient }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments( + { + rulesClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(true), + }, + {}, + ['ok'] + ); await handler(context, req, res); @@ -78,7 +85,11 @@ describe('healthRoute', () => { const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + rulesClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(true), + }, {}, ['ok'] ); @@ -105,52 +116,21 @@ describe('healthRoute', () => { }); }); - it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => { + test('when ES security status cannot be determined from license state, isSufficientlySecure should return false', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); - healthRoute(router, licenseState, encryptedSavedObjects); - const [, handler] = router.get.mock.calls[0]; + licenseState.getIsSecurityEnabled.mockReturnValueOnce(null); - const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, - {}, - ['ok'] - ); - - expect(await handler(context, req, res)).toStrictEqual({ - body: { - alerting_framework_heath: { - decryption_health: { - status: HealthStatus.OK, - timestamp: currentDate, - }, - execution_health: { - status: HealthStatus.OK, - timestamp: currentDate, - }, - read_health: { - status: HealthStatus.OK, - timestamp: currentDate, - }, - }, - has_permanent_encryption_key: true, - is_sufficiently_secure: true, - }, - }); - }); - - it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => { - const router = httpServiceMock.createRouter(); - - const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + rulesClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(true), + }, {}, ['ok'] ); @@ -172,16 +152,17 @@ describe('healthRoute', () => { }, }, has_permanent_encryption_key: true, - is_sufficiently_secure: true, + is_sufficiently_secure: false, }, }); }); - it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => { + test('when ES security is disabled, isSufficientlySecure should return true', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + licenseState.getIsSecurityEnabled.mockReturnValueOnce(false); + healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -212,16 +193,17 @@ describe('healthRoute', () => { }, }, has_permanent_encryption_key: true, - is_sufficiently_secure: false, + is_sufficiently_secure: true, }, }); }); - it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => { + test('when ES security is enabled but user cannot generate api keys, isSufficientlySecure should return false', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + licenseState.getIsSecurityEnabled.mockReturnValueOnce(true); + healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -257,16 +239,21 @@ describe('healthRoute', () => { }); }); - it('evaluates security and tls enabled to mean that the user can generate keys', async () => { + test('when ES security is enabled and user can generate api keys, isSufficientlySecure should return true', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + licenseState.getIsSecurityEnabled.mockReturnValueOnce(true); + healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + rulesClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(true), + }, {}, ['ok'] ); diff --git a/x-pack/plugins/alerting/server/routes/health.ts b/x-pack/plugins/alerting/server/routes/health.ts index 96016ccc45472..fa09213dada3a 100644 --- a/x-pack/plugins/alerting/server/routes/health.ts +++ b/x-pack/plugins/alerting/server/routes/health.ts @@ -44,11 +44,22 @@ export const healthRoute = ( router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { try { + const isEsSecurityEnabled: boolean | null = licenseState.getIsSecurityEnabled(); const areApiKeysEnabled = await context.alerting.areApiKeysEnabled(); const alertingFrameworkHeath = await context.alerting.getFrameworkHealth(); + let isSufficientlySecure; + if (isEsSecurityEnabled === null) { + isSufficientlySecure = false; + } else { + // if isEsSecurityEnabled = true, then areApiKeysEnabled must be true to enable alerting + // if isEsSecurityEnabled = false, then it does not matter what areApiKeysEnabled is + isSufficientlySecure = + !isEsSecurityEnabled || (isEsSecurityEnabled && areApiKeysEnabled); + } + const frameworkHealth: AlertingFrameworkHealth = { - isSufficientlySecure: areApiKeysEnabled, + isSufficientlySecure, hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt, alertingFrameworkHeath, }; diff --git a/x-pack/plugins/alerting/server/routes/legacy/health.test.ts b/x-pack/plugins/alerting/server/routes/legacy/health.test.ts index 57bfdc29024f6..ed8e6763d66b7 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/health.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/health.test.ts @@ -62,7 +62,11 @@ describe('healthRoute', () => { const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + rulesClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(true), + }, {}, ['ok'] ); @@ -89,52 +93,21 @@ describe('healthRoute', () => { }); }); - it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => { + test('when ES security status cannot be determined from license state, isSufficientlySecure should return false', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); - healthRoute(router, licenseState, encryptedSavedObjects); - const [, handler] = router.get.mock.calls[0]; - - const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, - {}, - ['ok'] - ); - - expect(await handler(context, req, res)).toStrictEqual({ - body: { - alertingFrameworkHeath: { - decryptionHealth: { - status: HealthStatus.OK, - timestamp: currentDate, - }, - executionHealth: { - status: HealthStatus.OK, - timestamp: currentDate, - }, - readHealth: { - status: HealthStatus.OK, - timestamp: currentDate, - }, - }, - hasPermanentEncryptionKey: true, - isSufficientlySecure: true, - }, - }); - }); - - it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => { - const router = httpServiceMock.createRouter(); + licenseState.getIsSecurityEnabled.mockReturnValueOnce(null); - const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + rulesClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(true), + }, {}, ['ok'] ); @@ -156,16 +129,17 @@ describe('healthRoute', () => { }, }, hasPermanentEncryptionKey: true, - isSufficientlySecure: true, + isSufficientlySecure: false, }, }); }); - it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => { + test('when ES security is disabled, isSufficientlySecure should return true', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + licenseState.getIsSecurityEnabled.mockReturnValueOnce(false); + healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -196,16 +170,17 @@ describe('healthRoute', () => { }, }, hasPermanentEncryptionKey: true, - isSufficientlySecure: false, + isSufficientlySecure: true, }, }); }); - it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => { + test('when ES security is enabled but user cannot generate api keys, isSufficientlySecure should return false', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + licenseState.getIsSecurityEnabled.mockReturnValueOnce(true); + healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -241,16 +216,21 @@ describe('healthRoute', () => { }); }); - it('evaluates security and tls enabled to mean that the user can generate keys', async () => { + test('when ES security is enabled and user can generate api keys, isSufficientlySecure should return true', async () => { const router = httpServiceMock.createRouter(); - const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + licenseState.getIsSecurityEnabled.mockReturnValueOnce(true); + healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; const [context, req, res] = mockHandlerArguments( - { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth }, + { + rulesClient, + getFrameworkHealth: alerting.getFrameworkHealth, + areApiKeysEnabled: () => Promise.resolve(true), + }, {}, ['ok'] ); diff --git a/x-pack/plugins/alerting/server/routes/legacy/health.ts b/x-pack/plugins/alerting/server/routes/legacy/health.ts index 206a74c2ea636..03a574ca62c33 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/health.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/health.ts @@ -27,11 +27,21 @@ export function healthRoute( return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); } try { + const isEsSecurityEnabled: boolean | null = licenseState.getIsSecurityEnabled(); const alertingFrameworkHeath = await context.alerting.getFrameworkHealth(); const areApiKeysEnabled = await context.alerting.areApiKeysEnabled(); + let isSufficientlySecure; + if (isEsSecurityEnabled === null) { + isSufficientlySecure = false; + } else { + // if isEsSecurityEnabled = true, then areApiKeysEnabled must be true to enable alerting + // if isEsSecurityEnabled = false, then it does not matter what areApiKeysEnabled is + isSufficientlySecure = !isEsSecurityEnabled || (isEsSecurityEnabled && areApiKeysEnabled); + } + const frameworkHealth: AlertingFrameworkHealth = { - isSufficientlySecure: areApiKeysEnabled, + isSufficientlySecure, hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt, alertingFrameworkHeath, }; diff --git a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx index 72c5bac1f9f17..8358837cac563 100644 --- a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx @@ -11,7 +11,7 @@ import React, { useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { esKuery, - IIndexPattern, + IndexPattern, QuerySuggestion, } from '../../../../../../../src/plugins/data/public'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; @@ -29,7 +29,7 @@ interface State { isLoadingSuggestions: boolean; } -function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { +function convertKueryToEsQuery(kuery: string, indexPattern: IndexPattern) { const ast = esKuery.fromKueryExpression(kuery); return esKuery.toElasticsearchQuery(ast, indexPattern); } @@ -125,7 +125,10 @@ export function KueryBar(props: { prepend?: React.ReactNode | string }) { } try { - const res = convertKueryToEsQuery(inputValue, indexPattern); + const res = convertKueryToEsQuery( + inputValue, + indexPattern as IndexPattern + ); if (!res) { return; } diff --git a/x-pack/plugins/lens/common/suffix_formatter/index.ts b/x-pack/plugins/lens/common/suffix_formatter/index.ts index 12a4e02a81ef2..97fa8c067331e 100644 --- a/x-pack/plugins/lens/common/suffix_formatter/index.ts +++ b/x-pack/plugins/lens/common/suffix_formatter/index.ts @@ -31,6 +31,7 @@ export const unitSuffixesLong: Record = { export function getSuffixFormatter(formatFactory: FormatFactory): FieldFormatInstanceType { return class SuffixFormatter extends FieldFormat { static id = 'suffix'; + static hidden = true; // Don't want this format to appear in index pattern editor static title = i18n.translate('xpack.lens.fieldFormats.suffix.title', { defaultMessage: 'Suffix', }); diff --git a/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts b/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts index c4379bdd1fb34..d08908ecde417 100644 --- a/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts +++ b/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts @@ -42,4 +42,11 @@ describe('suffix formatter', () => { expect(result).toEqual(''); }); + + it('should be a hidden formatter', () => { + const convertMock = jest.fn((x) => ''); + const formatFactory = jest.fn(() => ({ convert: convertMock })); + const SuffixFormatter = getSuffixFormatter((formatFactory as unknown) as FormatFactory); + expect(SuffixFormatter.hidden).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 1c49527d9eca8..6bd75c585f954 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -30,7 +30,7 @@ import { esFilters, FilterManager, IFieldType, - IIndexPattern, + IndexPattern, Query, } from '../../../../../src/plugins/data/public'; import { TopNavMenuData } from '../../../../../src/plugins/navigation/public'; @@ -182,7 +182,7 @@ describe('Lens App', () => { it('updates global filters with store state', async () => { const services = makeDefaultServices(sessionIdSubject); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const pinnedFilter = esFilters.buildExistsFilter(pinnedField, indexPattern); services.data.query.filterManager.getFilters = jest.fn().mockImplementation(() => { @@ -634,7 +634,7 @@ describe('Lens App', () => { }); it('saves app filters and does not save pinned filters', async () => { - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const unpinned = esFilters.buildExistsFilter(field, indexPattern); @@ -816,7 +816,7 @@ describe('Lens App', () => { it('updates the filters when the user changes them', async () => { const { instance, services, lensStore } = await mountWith({}); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; expect(lensStore.getState()).toEqual({ lens: expect.objectContaining({ @@ -871,7 +871,7 @@ describe('Lens App', () => { searchSessionId: `sessionId-3`, }), }); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; act(() => services.data.query.filterManager.setFilters([ @@ -1006,7 +1006,7 @@ describe('Lens App', () => { query: { query: 'new', language: 'lucene' }, }) ); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const unpinned = esFilters.buildExistsFilter(field, indexPattern); @@ -1063,7 +1063,7 @@ describe('Lens App', () => { query: { query: 'new', language: 'lucene' }, }) ); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const unpinned = esFilters.buildExistsFilter(field, indexPattern); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index c7147e75af59a..d4a9870056b34 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -199,7 +199,7 @@ export function LayerPanels( })} content={i18n.translate('xpack.lens.xyChart.addLayerTooltip', { defaultMessage: - 'Use multiple layers to combine chart types or visualize different index patterns.', + 'Use multiple layers to combine visualization types or visualize different index patterns.', })} position="bottom" > diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index 6445038e40d7c..44fb47001631e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -16,7 +16,7 @@ import { } from '../../mocks'; import { act } from 'react-dom/test-utils'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; -import { esFilters, IFieldType, IIndexPattern } from '../../../../../../src/plugins/data/public'; +import { esFilters, IFieldType, IndexPattern } from '../../../../../../src/plugins/data/public'; import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; import { getSuggestions, Suggestion } from './suggestion_helpers'; import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; @@ -291,7 +291,7 @@ describe('suggestion_panel', () => { (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression'); mockDatasource.toExpression.mockReturnValue('datasource_expression'); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; mountWithProvider( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index f948ec6a59687..314989ecc9758 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -370,7 +370,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { 'xpack.lens.chartSwitch.dataLossDescription', { defaultMessage: - 'Selecting this chart type will result in a partial loss of currently applied configuration selections.', + 'Selecting this visualization type will result in a partial loss of currently applied configuration selections.', } )} iconProps={{ @@ -439,8 +439,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { - {i18n.translate('xpack.lens.configPanel.chartType', { - defaultMessage: 'Chart type', + {i18n.translate('xpack.lens.configPanel.visualizationType', { + defaultMessage: 'Visualization type', })} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 4feb13fcfffd9..784455cc9f6d1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -28,7 +28,7 @@ import { ReactWrapper } from 'enzyme'; import { DragDrop, ChildDragDropProvider } from '../../../drag_drop'; import { fromExpression } from '@kbn/interpreter/common'; import { coreMock } from 'src/core/public/mocks'; -import { esFilters, IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public'; +import { esFilters, IFieldType, IndexPattern } from '../../../../../../../src/plugins/data/public'; import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks'; import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers'; @@ -443,7 +443,7 @@ describe('workspace_panel', () => { expect(expressionRendererMock).toHaveBeenCalledTimes(1); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern; const field = ({ name: 'myfield' } as unknown) as IFieldType; await act(async () => { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 77b2b06389240..e26466be6f81b 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -12,7 +12,6 @@ import type { ExecutionContextServiceStart } from 'src/core/public'; import { ExecutionContextSearch, Filter, - IIndexPattern, Query, TimefilterContract, TimeRange, @@ -83,7 +82,7 @@ export type LensByReferenceInput = SavedObjectEmbeddableInput & LensBaseEmbeddab export type LensEmbeddableInput = LensByValueInput | LensByReferenceInput; export interface LensEmbeddableOutput extends EmbeddableOutput { - indexPatterns?: IIndexPattern[]; + indexPatterns?: IndexPattern[]; } export interface LensEmbeddableDeps { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx index 0a41e7e65212a..e643ea12528ee 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx @@ -70,7 +70,7 @@ export function ChangeIndexPattern({
{i18n.translate('xpack.lens.indexPattern.changeIndexPatternTitle', { - defaultMessage: 'Change index pattern', + defaultMessage: 'Index pattern', })} = { // Wrapper around esQuery.buildEsQuery, handling errors (e.g. because a query can't be parsed) by // returning a query dsl object not matching anything function buildSafeEsQuery( - indexPattern: IIndexPattern, + indexPattern: IndexPattern, query: Query, filters: Filter[], queryConfig: EsQueryConfig @@ -164,7 +164,7 @@ export function IndexPatternDataPanel({ })); const dslQuery = buildSafeEsQuery( - indexPatterns[currentIndexPatternId] as IIndexPattern, + indexPatterns[currentIndexPatternId], query, filters, esQuery.getEsQueryConfig(core.uiSettings) @@ -269,7 +269,7 @@ const defaultFieldGroups: { }; const fieldFiltersLabel = i18n.translate('xpack.lens.indexPatterns.fieldFiltersLabel', { - defaultMessage: 'Field filters', + defaultMessage: 'Filter by type', }); const htmlId = htmlIdGenerator('datapanel'); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 013bb46500d0d..5ceb452038426 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -44,7 +44,6 @@ import { ES_FIELD_TYPES, Filter, esQuery, - IIndexPattern, } from '../../../../../src/plugins/data/public'; import { FieldButton } from '../../../../../src/plugins/kibana_react/public'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; @@ -169,7 +168,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { .post(`/api/lens/index_stats/${indexPattern.id}/field`, { body: JSON.stringify({ dslQuery: esQuery.buildEsQuery( - indexPattern as IIndexPattern, + indexPattern, query, filters, esQuery.getEsQueryConfig(core.uiSettings) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index a458a1edcfa16..4e2f69c927a18 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -19,11 +19,7 @@ import { import { uniq } from 'lodash'; import { CoreStart } from 'kibana/public'; import { FieldStatsResponse } from '../../../../../common'; -import { - AggFunctionsMapping, - esQuery, - IIndexPattern, -} from '../../../../../../../../src/plugins/data/public'; +import { AggFunctionsMapping, esQuery } from '../../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public'; import { updateColumnParam, isReferenced } from '../../layer_helpers'; import { DataType, FramePublicAPI } from '../../../../types'; @@ -99,7 +95,7 @@ function getDisallowedTermsMessage( body: JSON.stringify({ fieldName, dslQuery: esQuery.buildEsQuery( - indexPattern as IIndexPattern, + indexPattern, frame.query, frame.filters, esQuery.getEsQueryConfig(core.uiSettings) diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index ac0aa6cd4b1f1..d25726951ea8f 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -250,7 +250,7 @@ export function PieComponent( 1 ? esFilters.buildPhrasesFilter(indexField, value, indexPattern) - : esFilters.buildPhraseFilter(indexField, value, indexPattern); + : esFilters.buildPhraseFilter(indexField, value as string, indexPattern); filter.meta.type = value instanceof Array && value.length > 1 ? 'phrases' : 'phrase'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 5347ee875181b..83006f09a14be 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -823,7 +823,8 @@ describe('Exception helpers', () => { }, ]); }); - + }); + describe('ransomware protection exception items', () => { test('it should return pre-populated ransomware items for event code `ransomware`', () => { const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { _id: '123', @@ -938,7 +939,9 @@ describe('Exception helpers', () => { }, ]); }); + }); + describe('memory protection exception items', () => { test('it should return pre-populated memory signature items for event code `memory_signature`', () => { const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { _id: '123', @@ -990,6 +993,44 @@ describe('Exception helpers', () => { ]); }); + test('it should return pre-populated memory signature items for event code `memory_signature` and skip Empty', () => { + const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { + _id: '123', + process: { + name: '', // name is empty + // executable: '', left intentionally commented + hash: { + sha256: 'some hash', + }, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + Memory_protection: { + feature: 'signature', + }, + event: { + code: 'memory_signature', + }, + }); + + // should not contain name or executable + expect(defaultItems[0].entries).toEqual([ + { + field: 'Memory_protection.feature', + operator: 'included', + type: 'match', + value: 'signature', + id: '123', + }, + { + field: 'process.hash.sha256', + operator: 'included', + type: 'match', + value: 'some hash', + id: '123', + }, + ]); + }); + test('it should return pre-populated memory shellcode items for event code `malicious_thread`', () => { const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { _id: '123', @@ -1085,7 +1126,115 @@ describe('Exception helpers', () => { value: '4000', id: '123', }, - { field: 'region_size', operator: 'included', type: 'match', value: '4000', id: '123' }, + { + field: 'region_size', + operator: 'included', + type: 'match', + value: '4000', + id: '123', + }, + { + field: 'region_protection', + operator: 'included', + type: 'match', + value: 'RWX', + id: '123', + }, + { + field: 'memory_pe.imphash', + operator: 'included', + type: 'match', + value: 'a hash', + id: '123', + }, + ], + id: '123', + }, + ]); + }); + + test('it should return pre-populated memory shellcode items for event code `malicious_thread` and skip empty', () => { + const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { + _id: '123', + process: { + name: '', // name is empty + // executable: '', left intentionally commented + Ext: { + token: { + integrity_level_name: 'high', + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + Memory_protection: { + feature: 'shellcode_thread', + self_injection: true, + }, + event: { + code: 'malicious_thread', + }, + Target: { + process: { + thread: { + Ext: { + start_address_allocation_offset: 0, + start_address_bytes_disasm_hash: 'a disam hash', + start_address_details: { + // allocation_type: '', left intentionally commented + allocation_size: 4000, + region_size: 4000, + region_protection: 'RWX', + memory_pe: { + imphash: 'a hash', + }, + }, + }, + }, + }, + }, + }); + + // no name, no exceutable, no allocation_type + expect(defaultItems[0].entries).toEqual([ + { + field: 'Memory_protection.feature', + operator: 'included', + type: 'match', + value: 'shellcode_thread', + id: '123', + }, + { + field: 'Memory_protection.self_injection', + operator: 'included', + type: 'match', + value: 'true', + id: '123', + }, + { + field: 'process.Ext.token.integrity_level_name', + operator: 'included', + type: 'match', + value: 'high', + id: '123', + }, + { + field: 'Target.process.thread.Ext.start_address_details', + type: 'nested', + entries: [ + { + field: 'allocation_size', + operator: 'included', + type: 'match', + value: '4000', + id: '123', + }, + { + field: 'region_size', + operator: 'included', + type: 'match', + value: '4000', + id: '123', + }, { field: 'region_protection', operator: 'included', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 613d295545461..62250a0933ffb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -343,6 +343,29 @@ export const getCodeSignatureValue = ( } }; +// helper type to filter empty-valued exception entries +interface ExceptionEntry { + value?: string; + entries?: ExceptionEntry[]; +} + +/** + * Takes an array of Entries and filter out the ones with empty values. + * It will also filter out empty values for nested entries. + */ +function filterEmptyExceptionEntries(entries: T[]): T[] { + const finalEntries: T[] = []; + for (const entry of entries) { + if (entry.entries !== undefined) { + entry.entries = entry.entries.filter((el) => el.value !== undefined && el.value.length > 0); + finalEntries.push(entry); + } else if (entry.value !== undefined && entry.value.length > 0) { + finalEntries.push(entry); + } + } + return finalEntries; +} + /** * Returns the default values from the alert data to autofill new endpoint exceptions */ @@ -510,34 +533,35 @@ export const getPrepopulatedMemorySignatureException = ({ alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { const { process } = alertEcsData; + const entries = filterEmptyExceptionEntries([ + { + field: 'Memory_protection.feature', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.Memory_protection?.feature ?? '', + }, + { + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: process?.executable ?? '', + }, + { + field: 'process.name.caseless', + operator: 'included' as const, + type: 'match' as const, + value: process?.name ?? '', + }, + { + field: 'process.hash.sha256', + operator: 'included' as const, + type: 'match' as const, + value: process?.hash?.sha256 ?? '', + }, + ]); return { ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), - entries: addIdToEntries([ - { - field: 'Memory_protection.feature', - operator: 'included', - type: 'match', - value: alertEcsData.Memory_protection?.feature ?? '', - }, - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: process?.executable ?? '', - }, - { - field: 'process.name.caseless', - operator: 'included', - type: 'match', - value: process?.name ?? '', - }, - { - field: 'process.hash.sha256', - operator: 'included', - type: 'match', - value: process?.hash?.sha256 ?? '', - }, - ]), + entries: addIdToEntries(entries), }; }; export const getPrepopulatedMemoryShellcodeException = ({ @@ -554,81 +578,83 @@ export const getPrepopulatedMemoryShellcodeException = ({ alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { const { process, Target } = alertEcsData; + const entries = filterEmptyExceptionEntries([ + { + field: 'Memory_protection.feature', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.Memory_protection?.feature ?? '', + }, + { + field: 'Memory_protection.self_injection', + operator: 'included' as const, + type: 'match' as const, + value: String(alertEcsData.Memory_protection?.self_injection) ?? '', + }, + { + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: process?.executable ?? '', + }, + { + field: 'process.name.caseless', + operator: 'included' as const, + type: 'match' as const, + value: process?.name ?? '', + }, + { + field: 'process.Ext.token.integrity_level_name', + operator: 'included' as const, + type: 'match' as const, + value: process?.Ext?.token?.integrity_level_name ?? '', + }, + { + field: 'Target.process.thread.Ext.start_address_details', + type: 'nested' as const, + entries: [ + { + field: 'allocation_type', + operator: 'included' as const, + type: 'match' as const, + value: Target?.process?.thread?.Ext?.start_address_details?.allocation_type ?? '', + }, + { + field: 'allocation_size', + operator: 'included' as const, + type: 'match' as const, + value: String(Target?.process?.thread?.Ext?.start_address_details?.allocation_size) ?? '', + }, + { + field: 'region_size', + operator: 'included' as const, + type: 'match' as const, + value: String(Target?.process?.thread?.Ext?.start_address_details?.region_size) ?? '', + }, + { + field: 'region_protection', + operator: 'included' as const, + type: 'match' as const, + value: + String(Target?.process?.thread?.Ext?.start_address_details?.region_protection) ?? '', + }, + { + field: 'memory_pe.imphash', + operator: 'included' as const, + type: 'match' as const, + value: + String(Target?.process?.thread?.Ext?.start_address_details?.memory_pe?.imphash) ?? '', + }, + ], + }, + ]); + return { ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), - entries: addIdToEntries([ - { - field: 'Memory_protection.feature', - operator: 'included', - type: 'match', - value: alertEcsData.Memory_protection?.feature ?? '', - }, - { - field: 'Memory_protection.self_injection', - operator: 'included', - type: 'match', - value: String(alertEcsData.Memory_protection?.self_injection) ?? '', - }, - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: process?.executable ?? '', - }, - { - field: 'process.name.caseless', - operator: 'included', - type: 'match', - value: process?.name ?? '', - }, - { - field: 'process.Ext.token.integrity_level_name', - operator: 'included', - type: 'match', - value: process?.Ext?.token?.integrity_level_name ?? '', - }, - { - field: 'Target.process.thread.Ext.start_address_details', - type: 'nested', - entries: [ - { - field: 'allocation_type', - operator: 'included', - type: 'match', - value: Target?.process?.thread?.Ext?.start_address_details?.allocation_type ?? '', - }, - { - field: 'allocation_size', - operator: 'included', - type: 'match', - value: - String(Target?.process?.thread?.Ext?.start_address_details?.allocation_size) ?? '', - }, - { - field: 'region_size', - operator: 'included', - type: 'match', - value: String(Target?.process?.thread?.Ext?.start_address_details?.region_size) ?? '', - }, - { - field: 'region_protection', - operator: 'included', - type: 'match', - value: - String(Target?.process?.thread?.Ext?.start_address_details?.region_protection) ?? '', - }, - { - field: 'memory_pe.imphash', - operator: 'included', - type: 'match', - value: - String(Target?.process?.thread?.Ext?.start_address_details?.memory_pe?.imphash) ?? '', - }, - ], - }, - ]), + entries: addIdToEntries(entries), }; }; + /** * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping */ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 07511506402a6..6c33c5f8e3ba6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1715,7 +1715,6 @@ "discover.howToChangeTheTimeTooltip": "時刻を変更するには、グローバル時刻フィルターを使用します。", "discover.howToSeeOtherMatchingDocumentsDescription": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。", "discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。", - "discover.inspectorRequestDataTitle": "データ", "discover.inspectorRequestDescriptionDocument": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。", "discover.json.codeEditorAriaLabel": "Elasticsearch ドキュメントの JSON ビューのみを読み込む", "discover.json.copyToClipboardLabel": "クリップボードにコピー", @@ -13314,12 +13313,10 @@ "xpack.lens.breadcrumbsByValue": "ビジュアライゼーションを編集", "xpack.lens.breadcrumbsCreate": "作成", "xpack.lens.breadcrumbsTitle": "Visualizeライブラリ", - "xpack.lens.chartSwitch.dataLossDescription": "このグラフタイプを選択すると、現在適用されている構成選択の一部が失われます。", "xpack.lens.chartSwitch.dataLossLabel": "警告", "xpack.lens.chartSwitch.experimentalLabel": "実験的", "xpack.lens.chartSwitch.noResults": "{term}の結果が見つかりませんでした。", "xpack.lens.chartTitle.unsaved": "保存されていないビジュアライゼーション", - "xpack.lens.configPanel.chartType": "チャートタイプ", "xpack.lens.configPanel.color.tooltip.auto": "カスタム色を指定しない場合、Lensは自動的に色を選択します。", "xpack.lens.configPanel.color.tooltip.custom": "[自動]モードに戻すには、カスタム色をオフにしてください。", "xpack.lens.configPanel.color.tooltip.disabled": "レイヤーに「内訳条件」が含まれている場合は、個別の系列をカスタム色にできません。", @@ -13543,7 +13540,6 @@ "xpack.lens.indexPattern.cardinality": "ユニークカウント", "xpack.lens.indexPattern.cardinality.signature": "フィールド:文字列", "xpack.lens.indexPattern.cardinalityOf": "{name} のユニークカウント", - "xpack.lens.indexPattern.changeIndexPatternTitle": "インデックスパターンを変更", "xpack.lens.indexPattern.chooseField": "フィールドを選択", "xpack.lens.indexPattern.chooseFieldLabel": "この関数を使用するには、フィールドを選択してください。", "xpack.lens.indexPattern.chooseSubFunction": "サブ関数を選択", @@ -13777,7 +13773,6 @@ "xpack.lens.indexPatterns.actionsPopoverLabel": "インデックスパターン設定", "xpack.lens.indexPatterns.addFieldButton": "フィールドをインデックスパターンに追加", "xpack.lens.indexPatterns.clearFiltersLabel": "名前とタイプフィルターを消去", - "xpack.lens.indexPatterns.fieldFiltersLabel": "フィールドフィルター", "xpack.lens.indexPatterns.filterByNameLabel": "検索フィールド名", "xpack.lens.indexPatterns.manageFieldButton": "インデックスパターンを管理", "xpack.lens.indexPatterns.noAvailableDataLabel": "データを含むフィールドはありません。", @@ -13807,7 +13802,6 @@ "xpack.lens.pie.groupLabel": "比率", "xpack.lens.pie.groupsizeLabel": "サイズ単位", "xpack.lens.pie.pielabel": "円", - "xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType}グラフは負の値では表示できません。別のグラフタイプを試してください。", "xpack.lens.pie.sliceGroupLabel": "スライス", "xpack.lens.pie.suggestionLabel": "{chartName}", "xpack.lens.pie.treemapGroupLabel": "グループ分けの条件", @@ -13905,7 +13899,6 @@ "xpack.lens.visualizeGeoFieldMessage": "Lensは{fieldType}フィールドを可視化できません", "xpack.lens.xyChart.addLayer": "レイヤーを追加", "xpack.lens.xyChart.addLayerButton": "レイヤーを追加", - "xpack.lens.xyChart.addLayerTooltip": "複数のレイヤーを使用すると、グラフタイプを組み合わせたり、別のインデックスパターンを可視化したりすることができます。", "xpack.lens.xyChart.axisExtent.custom": "カスタム", "xpack.lens.xyChart.axisExtent.dataBounds": "データ境界", "xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "折れ線グラフのみをデータ境界に合わせることができます", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4a35d27fbd8b0..af269112574f9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1721,11 +1721,9 @@ "discover.helpMenu.appName": "Discover", "discover.hideChart": "隐藏图表", "discover.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图", - "discover.hitsPluralTitle": "{hits, plural, other {命中}}", "discover.howToChangeTheTimeTooltip": "要更改时间,请使用全局时间筛选。", "discover.howToSeeOtherMatchingDocumentsDescription": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。", "discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。", - "discover.inspectorRequestDataTitle": "数据", "discover.inspectorRequestDescriptionDocument": "此请求将查询 Elasticsearch 以获取搜索的数据。", "discover.json.codeEditorAriaLabel": "Elasticsearch 文档的只读 JSON 视图", "discover.json.copyToClipboardLabel": "复制到剪贴板", @@ -13661,13 +13659,11 @@ "xpack.lens.breadcrumbsByValue": "编辑可视化", "xpack.lens.breadcrumbsCreate": "创建", "xpack.lens.breadcrumbsTitle": "Visualize 库", - "xpack.lens.chartSwitch.dataLossDescription": "选择此图表类型将使当前应用的配置选择部分丢失。", "xpack.lens.chartSwitch.dataLossLabel": "警告", "xpack.lens.chartSwitch.experimentalLabel": "实验性", "xpack.lens.chartSwitch.noResults": "找不到 {term} 的结果。", "xpack.lens.chartTitle.unsaved": "未保存的可视化", "xpack.lens.chartWarnings.number": "{warningsCount} 个{warningsCount, plural, other {警告}}", - "xpack.lens.configPanel.chartType": "图表类型", "xpack.lens.configPanel.color.tooltip.auto": "Lens 自动为您选取颜色,除非您指定定制颜色。", "xpack.lens.configPanel.color.tooltip.custom": "清除定制颜色以返回到“自动”模式。", "xpack.lens.configPanel.color.tooltip.disabled": "当图层包括“细分依据”,各个系列无法定制颜色。", @@ -13898,7 +13894,6 @@ "xpack.lens.indexPattern.cardinality": "唯一计数", "xpack.lens.indexPattern.cardinality.signature": "field: string", "xpack.lens.indexPattern.cardinalityOf": "{name} 的唯一计数", - "xpack.lens.indexPattern.changeIndexPatternTitle": "更改索引模式", "xpack.lens.indexPattern.chooseField": "选择字段", "xpack.lens.indexPattern.chooseFieldLabel": "要使用此函数,请选择字段。", "xpack.lens.indexPattern.chooseSubFunction": "选择子函数", @@ -14135,7 +14130,6 @@ "xpack.lens.indexPatterns.actionsPopoverLabel": "索引模式设置", "xpack.lens.indexPatterns.addFieldButton": "将字段添加到索引模式", "xpack.lens.indexPatterns.clearFiltersLabel": "清除名称和类型筛选", - "xpack.lens.indexPatterns.fieldFiltersLabel": "字段筛选", "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields} 个可用{availableFields, plural, other {字段}}。{emptyFields} 个空{emptyFields, plural, other {字段}}。{metaFields} 个元{metaFields, plural,other {字段}}。", "xpack.lens.indexPatterns.filterByNameLabel": "搜索字段名称", "xpack.lens.indexPatterns.manageFieldButton": "管理索引模式字段", @@ -14166,7 +14160,6 @@ "xpack.lens.pie.groupLabel": "比例", "xpack.lens.pie.groupsizeLabel": "大小调整依据", "xpack.lens.pie.pielabel": "饼图", - "xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType} 图表无法使用负值进行呈现。请尝试不同的图表类型。", "xpack.lens.pie.sliceGroupLabel": "切片依据", "xpack.lens.pie.suggestionLabel": "为 {chartName}", "xpack.lens.pie.treemapGroupLabel": "分组依据", @@ -14264,7 +14257,6 @@ "xpack.lens.visualizeGeoFieldMessage": "Lens 无法可视化 {fieldType} 字段", "xpack.lens.xyChart.addLayer": "添加图层", "xpack.lens.xyChart.addLayerButton": "添加图层", - "xpack.lens.xyChart.addLayerTooltip": "使用多个图层以组合图表类型或可视化不同的索引模式。", "xpack.lens.xyChart.axisExtent.custom": "定制", "xpack.lens.xyChart.axisExtent.dataBounds": "数据边界", "xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "仅折线图可适应数据边界", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx index 762526dfd7fa7..f990e12ed76e5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx @@ -153,7 +153,7 @@ const TlsError = ({ docLinks, className }: PromptErrorProps) => (

} diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts index de92cfeb29e08..84f405d6ee494 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts @@ -80,22 +80,6 @@ describe('synthetics runtime types', () => { maxSteps: 1, ref: { screenshotRef: refResult, - blocks: [ - { - id: 'hash1', - synthetics: { - blob: 'image data', - blob_mime: 'image/jpeg', - }, - }, - { - id: 'hash2', - synthetics: { - blob: 'image data', - blob_mime: 'image/jpeg', - }, - }, - ], }, }; }); diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts index cd6be645c7a62..e7948f4ad532c 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts @@ -82,10 +82,9 @@ export const FullScreenshotType = t.type({ synthetics: t.intersection([ t.partial({ blob: t.string, + blob_mime: t.string, }), t.type({ - blob: t.string, - blob_mime: t.string, step: t.type({ name: t.string, }), @@ -158,6 +157,10 @@ export const ScreenshotBlockDocType = t.type({ export type ScreenshotBlockDoc = t.TypeOf; +export function isScreenshotBlockDoc(data: unknown): data is ScreenshotBlockDoc { + return isRight(ScreenshotBlockDocType.decode(data)); +} + /** * Contains the fields requried by the Synthetics UI when utilizing screenshot refs. */ @@ -166,7 +169,6 @@ export const ScreenshotRefImageDataType = t.type({ maxSteps: t.number, ref: t.type({ screenshotRef: RefResultType, - blocks: t.array(ScreenshotBlockDocType), }), }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx index 8e2dc1b4c24e0..df4c73908b627 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx @@ -57,7 +57,7 @@ export const PingTimestamp = ({ label, checkGroup, initialStepNo = 1 }: Props) = const { data, status } = useFetcher(() => { if (intersection && intersection.intersectionRatio === 1 && !stepImages[stepNumber - 1]) return getJourneyScreenshot(imgPath); - }, [intersection?.intersectionRatio, stepNumber]); + }, [intersection?.intersectionRatio, stepNumber, imgPath]); const [screenshotRef, setScreenshotRef] = useState(undefined); useEffect(() => { diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx index 6b78c4046da95..73c43da98bfc4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx @@ -64,7 +64,7 @@ const RecomposedScreenshotImage: React.FC< } > = ({ captionContent, imageCaption, imageData, imgRef, setImageData }) => { // initially an undefined URL value is passed to the image display, and a loading spinner is rendered. - // `useCompositeImage` will call `setUrl` when the image is composited, and the updated `url` will display. + // `useCompositeImage` will call `setImageData` when the image is composited, and the updated `imageData` will display. useCompositeImage(imgRef, setImageData, imageData); return ( diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx index c24ecd9183865..9d0555d97cbd4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx @@ -109,7 +109,7 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) {(!journey || journey.loading) && ( - + )} diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx index 316154929320d..54f73fb39a52a 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx @@ -60,8 +60,8 @@ export const StepScreenshots = ({ step }: Props) => { { { // expect only one accordion to be expanded expect(Object.keys(result.current.expandedRows)).toEqual(['0']); }); + + describe('getExpandedStepCallback', () => { + it('matches step index to key', () => { + const callback = getExpandedStepCallback(2); + expect(callback(defaultSteps[0])).toBe(false); + expect(callback(defaultSteps[1])).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx index 4b50a94f602b7..e58e1cca8660b 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx @@ -18,6 +18,10 @@ interface HookProps { type ExpandRowType = Record; +export function getExpandedStepCallback(key: number) { + return (step: JourneyStep) => step.synthetics?.step?.index === key; +} + export const useExpandedRow = ({ loading, steps, allSteps }: HookProps) => { const [expandedRows, setExpandedRows] = useState({}); // eui table uses index from 0, synthetics uses 1 @@ -37,21 +41,18 @@ export const useExpandedRow = ({ loading, steps, allSteps }: HookProps) => { useEffect(() => { const expandedRowsN: ExpandRowType = {}; - for (const expandedRowKeyStr in expandedRows) { - if (expandedRows.hasOwnProperty(expandedRowKeyStr)) { - const expandedRowKey = Number(expandedRowKeyStr); - const step = steps.find((stepF) => stepF.synthetics?.step?.index !== expandedRowKey); + for (const expandedRowKey of Object.keys(expandedRows).map((key) => Number(key))) { + const step = steps.find(getExpandedStepCallback(expandedRowKey + 1)); - if (step) { - expandedRowsN[expandedRowKey] = ( - - ); - } + if (step) { + expandedRowsN[expandedRowKey] = ( + + ); } } diff --git a/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx index c3016864c72a7..add34c3f71f0d 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx @@ -51,7 +51,7 @@ export const ExecutedStep: FC = ({ return ( {loading ? ( - + ) : ( <> diff --git a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx index 8d35df51c2421..5b86ed525bc31 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx @@ -30,7 +30,7 @@ describe('StepScreenshotDisplayProps', () => { const { getByAltText } = render( { const { getByAltText } = render( @@ -57,7 +57,7 @@ describe('StepScreenshotDisplayProps', () => { const { getByTestId } = render( { const { getByAltText } = render( = ({ checkGroup, - isScreenshotBlob: isScreenshotBlob, + isFullScreenshot: isScreenshotBlob, isScreenshotRef, stepIndex, stepName, @@ -134,7 +134,7 @@ export const StepScreenshotDisplay: FC = ({ if (isScreenshotRef) { return getJourneyScreenshot(imgSrc); } - }, [basePath, checkGroup, stepIndex, isScreenshotRef]); + }, [basePath, checkGroup, imgSrc, stepIndex, isScreenshotRef]); const refDimensions = useMemo(() => { if (isAScreenshotRef(screenshotRef)) { diff --git a/x-pack/plugins/uptime/public/hooks/use_composite_image.test.tsx b/x-pack/plugins/uptime/public/hooks/use_composite_image.test.tsx new file mode 100644 index 0000000000000..79e0cde1eaab8 --- /dev/null +++ b/x-pack/plugins/uptime/public/hooks/use_composite_image.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright 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 redux from 'react-redux'; +import { renderHook } from '@testing-library/react-hooks'; +import { ScreenshotRefImageData } from '../../common/runtime_types'; +import { ScreenshotBlockCache } from '../state/reducers/synthetics'; +import { shouldCompose, useCompositeImage } from './use_composite_image'; +import * as compose from '../lib/helper/compose_screenshot_images'; + +const MIME = 'image/jpeg'; + +describe('use composite image', () => { + let imageData: string | undefined; + let imgRef: ScreenshotRefImageData; + let curRef: ScreenshotRefImageData; + let blocks: ScreenshotBlockCache; + + beforeEach(() => { + imgRef = { + stepName: 'step-1', + maxSteps: 3, + ref: { + screenshotRef: { + '@timestamp': '123', + monitor: { + check_group: 'check-group', + }, + screenshot_ref: { + width: 100, + height: 200, + blocks: [ + { + hash: 'hash1', + top: 0, + left: 0, + width: 10, + height: 10, + }, + { + hash: 'hash2', + top: 0, + left: 10, + width: 10, + height: 10, + }, + ], + }, + synthetics: { + package_version: 'v1', + step: { index: 0, name: 'first' }, + type: 'step/screenshot_ref', + }, + }, + }, + }; + curRef = { + stepName: 'step-1', + maxSteps: 3, + ref: { + screenshotRef: { + '@timestamp': '234', + monitor: { + check_group: 'check-group-2', + }, + screenshot_ref: { + width: 100, + height: 200, + blocks: [ + { + hash: 'hash1', + top: 0, + left: 0, + width: 10, + height: 10, + }, + { + hash: 'hash2', + top: 0, + left: 10, + width: 10, + height: 10, + }, + ], + }, + synthetics: { + package_version: 'v1', + step: { index: 1, name: 'second' }, + type: 'step/screenshot_ref', + }, + }, + }, + }; + blocks = { + hash1: { + id: 'id1', + synthetics: { + blob: 'blob', + blob_mime: MIME, + }, + }, + hash2: { + id: 'id2', + synthetics: { + blob: 'blob', + blob_mime: MIME, + }, + }, + }; + }); + + describe('shouldCompose', () => { + it('returns true if all blocks are loaded and ref is new', () => { + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(true); + }); + + it('returns false if a required block is pending', () => { + blocks.hash2 = { status: 'pending' }; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false); + }); + + it('returns false if a required block is missing', () => { + delete blocks.hash2; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false); + }); + + it('returns false if imageData is defined and the refs have matching step index/check_group', () => { + imageData = 'blob'; + curRef.ref.screenshotRef.synthetics.step.index = 0; + curRef.ref.screenshotRef.monitor.check_group = 'check-group'; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false); + }); + + it('returns true if imageData is defined and the refs have different step names', () => { + imageData = 'blob'; + curRef.ref.screenshotRef.synthetics.step.index = 0; + expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(true); + }); + }); + + describe('useCompositeImage', () => { + let useDispatchMock: jest.Mock; + let canvasMock: unknown; + let removeChildSpy: jest.Mock; + let selectorSpy: jest.SpyInstance; + let composeSpy: jest.SpyInstance; + + beforeEach(() => { + useDispatchMock = jest.fn(); + removeChildSpy = jest.fn(); + canvasMock = { + parentElement: { + removeChild: removeChildSpy, + }, + toDataURL: jest.fn().mockReturnValue('compose success'), + }; + // @ts-expect-error mocking canvas element for testing + jest.spyOn(document, 'createElement').mockReturnValue(canvasMock); + jest.spyOn(redux, 'useDispatch').mockReturnValue(useDispatchMock); + selectorSpy = jest.spyOn(redux, 'useSelector').mockReturnValue({ blocks }); + composeSpy = jest + .spyOn(compose, 'composeScreenshotRef') + .mockReturnValue(new Promise((r) => r([]))); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not compose if all blocks are not loaded', () => { + blocks = {}; + renderHook(() => useCompositeImage(imgRef, jest.fn(), imageData)); + + expect(useDispatchMock).toHaveBeenCalledWith({ + payload: ['hash1', 'hash2'], + type: 'FETCH_BLOCKS', + }); + }); + + it('composes when all required blocks are loaded', async () => { + const onComposeImageSuccess = jest.fn(); + const { waitFor } = renderHook(() => useCompositeImage(imgRef, onComposeImageSuccess)); + + expect(selectorSpy).toHaveBeenCalled(); + expect(composeSpy).toHaveBeenCalledTimes(1); + expect(composeSpy.mock.calls[0][0]).toEqual(imgRef); + expect(composeSpy.mock.calls[0][1]).toBe(canvasMock); + expect(composeSpy.mock.calls[0][2]).toBe(blocks); + + await waitFor(() => { + expect(onComposeImageSuccess).toHaveBeenCalledTimes(1); + expect(onComposeImageSuccess).toHaveBeenCalledWith('compose success'); + }); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/hooks/use_composite_image.ts b/x-pack/plugins/uptime/public/hooks/use_composite_image.ts index 6db3d05b8c968..3af1e798d43e1 100644 --- a/x-pack/plugins/uptime/public/hooks/use_composite_image.ts +++ b/x-pack/plugins/uptime/public/hooks/use_composite_image.ts @@ -5,19 +5,70 @@ * 2.0. */ +import { useDispatch, useSelector } from 'react-redux'; import React from 'react'; import { composeScreenshotRef } from '../lib/helper/compose_screenshot_images'; import { ScreenshotRefImageData } from '../../common/runtime_types/ping/synthetics'; +import { + fetchBlocksAction, + isPendingBlock, + ScreenshotBlockCache, + StoreScreenshotBlock, +} from '../state/reducers/synthetics'; +import { syntheticsSelector } from '../state/selectors'; + +function allBlocksLoaded(blocks: { [key: string]: StoreScreenshotBlock }, hashes: string[]) { + for (const hash of hashes) { + if (!blocks[hash] || isPendingBlock(blocks[hash])) { + return false; + } + } + return true; +} /** * Checks if two refs are the same. If the ref is unchanged, there's no need * to run the expensive draw procedure. + * + * The key fields here are `step.index` and `check_group`, as there's a 1:1 between + * journey and check group, and each step has a unique index within a journey. */ -function isNewRef(a: ScreenshotRefImageData, b: ScreenshotRefImageData): boolean { - if (typeof a === 'undefined' || typeof b === 'undefined') return false; - const stepA = a.ref.screenshotRef.synthetics.step; - const stepB = b.ref.screenshotRef.synthetics.step; - return stepA.index !== stepB.index || stepA.name !== stepB.name; +const isNewRef = ( + { + ref: { + screenshotRef: { + synthetics: { + step: { index: indexA }, + }, + monitor: { check_group: checkGroupA }, + }, + }, + }: ScreenshotRefImageData, + { + ref: { + screenshotRef: { + synthetics: { + step: { index: indexB }, + }, + monitor: { check_group: checkGroupB }, + }, + }, + }: ScreenshotRefImageData +): boolean => indexA !== indexB || checkGroupA !== checkGroupB; + +export function shouldCompose( + imageData: string | undefined, + imgRef: ScreenshotRefImageData, + curRef: ScreenshotRefImageData, + blocks: ScreenshotBlockCache +): boolean { + return ( + allBlocksLoaded( + blocks, + imgRef.ref.screenshotRef.screenshot_ref.blocks.map(({ hash }) => hash) + ) && + (typeof imageData === 'undefined' || isNewRef(imgRef, curRef)) + ); } /** @@ -31,25 +82,34 @@ export const useCompositeImage = ( onComposeImageSuccess: React.Dispatch, imageData?: string ): void => { + const dispatch = useDispatch(); + const { blocks }: { blocks: ScreenshotBlockCache } = useSelector(syntheticsSelector); + + React.useEffect(() => { + dispatch( + fetchBlocksAction(imgRef.ref.screenshotRef.screenshot_ref.blocks.map(({ hash }) => hash)) + ); + }, [dispatch, imgRef.ref.screenshotRef.screenshot_ref.blocks]); + const [curRef, setCurRef] = React.useState(imgRef); React.useEffect(() => { const canvas = document.createElement('canvas'); async function compose() { - await composeScreenshotRef(imgRef, canvas); - const imgData = canvas.toDataURL('image/png', 1.0); + await composeScreenshotRef(imgRef, canvas, blocks); + const imgData = canvas.toDataURL('image/jpg', 1.0); onComposeImageSuccess(imgData); } // if the URL is truthy it means it's already been composed, so there // is no need to call the function - if (typeof imageData === 'undefined' || isNewRef(imgRef, curRef)) { + if (shouldCompose(imageData, imgRef, curRef, blocks)) { compose(); setCurRef(imgRef); } return () => { canvas.parentElement?.removeChild(canvas); }; - }, [imgRef, onComposeImageSuccess, curRef, imageData]); + }, [blocks, curRef, imageData, imgRef, onComposeImageSuccess]); }; diff --git a/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts b/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts index d3a005d982168..a95aa77371b23 100644 --- a/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts +++ b/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts @@ -40,23 +40,5 @@ export const mockRef: ScreenshotRefImageData = { }, monitor: { check_group: 'a567cc7a-c891-11eb-bdf9-3e22fb19bf97' }, }, - blocks: [ - { - id: 'd518801fc523cf02727cd520f556c4113b3098c7', - synthetics: { - blob: - '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABaAKADASIAAhEBAxEB/8QAHAABAAIDAQEBAAAAAAAAAAAAAAMFBAYHAggB/8QANRAAAQMDAwEHAwMCBwEAAAAAAQACAwQFEQYSITEHE0FRUpLRFBVhIjKBcaEIFiMkMzSxQv/EABkBAQADAQEAAAAAAAAAAAAAAAADBQYEAv/EACYRAQABBAEEAgEFAAAAAAAAAAABAgMEESEFEjFBBjJxE0JRYYH/2gAMAwEAAhEDEQA/APqlERAREQEREBERAREQEREBERAREQEREBERAREQeT4J5qn1XdDZbFU1rWB74wA1p6FxIAz+OVp+ldV3O6UdxfVd0BEWCNzG4ILieOvgAVFk3qcezVeq8Uxt12cK7dtzdp8ROnRTKxmA57QT+cL2CCucOc55LnuLnHkknJKvNNV7xUtpZHF0bwdmT+0jlZPA+WUZWRFmujtiqdRO98+t/lLe6fVbomqJ3ptqIi2KvEWHc7hR2uhlrbjVQUlJCN0k9RII2MGcZLjwOSsprg5oc0gtIyCPFB6REQERQPqIWVDIHSxtnkBc2MuAc4DqQOpwgnREQEREBERAREQcf7QdZXKK+z0FvlENPTkMcCxru8djnOQeOcYUuj7hLdLZWCop6SlxKwsmYxsDJpMEbT0Bdg54Vjr3SlD9ZLfqieSOmG01EDB+qQ8AbT4Z4z/JXP7ncZK90bdoipYRsgp2fsjb+PM+ZPJVhl2MXNwpxu37RqZ9wydzqGX03LquV1zMb3FO+Jj1uPUOiTQvik2yxuY/w3DCptS6vpNHPj3MFXdjhzaUOwImnxkPgSOjeviVV6Wv9dBWU9C+eV9LK7ug3hzoi7gOZnOCCc+S0+5dnepX3CUQNhuj3ykOlhqWOcXE9XBxDh+SVkek/D8TCyv1ci53RHNMTxz/AH/Ol3f+TXc3H1i0TFXifevw+hdI32n1LYqa50zSxsoLXMccljgcFufHnx8Rha7fdeVrNUVWn9KacqL/AHGgZHLXFtVHTRUweMsaXv6vI52gdPHri27ONOP0xpWnoKh7X1Jc6WYt6B7vAf0GB/C5ZZxNQ9rGvaCr1rPpupqaqCrgiLKYtqonRABzTOxxO3G0hp4wtBXFMVzFPh2WJrm3TNz7a5Zvadq+HVnYRrV30VTbrjQYpK6hqMF8EokYcZHDmkEEOHBC3683nUlufRw2LSv3mldTse6o+4xU+1/ILNrhk8AHP5/C5h2g2C22zsj7SbrR6kfqCquggNZUGSEhskbmNAxE0NacEZGPAK8u94rLn2jzabrtVT6atFHaqeriFM6KGWse4nc4SSNd+luACGrylbNYu0enqbRqWpv1tqrNXacy65Ub3tlcxvd941zHN4eHN6dP/CcO3641hcaamraLs5qzQVO18T5btTxy907kPdGTwcHO3OfBc/7N5NLSdoPaxZ6/UUN1tldTULTU11wZI6pibTvEx70EAhhftJH7cDyXrU9c7s40/DcND9okt5ZDLDDTWCsqIa36lrpGs7qJw/1G4BJGM4DcILnUOpNUUfb2Y7TpWruQjsUjI6YXKGFs0f1LP9wNzsDB/RtOHc+S3aW70b9caTprtZDBqGst087HmVrzRYEZli3Dh3JAyODtVDe7lR2j/ERbai51MNJT1WmpqeKWeRrGOkFSx5bkkc4HRZF8qIartu0LPTSxzQy2uveySNwc17T3RBBHBB80Eg7RrrdrtdabRmkKu90lsqH0c9a+thpY3Ts/cyPfkuxnrwP7Z6BbppqihppqymdSVEkTXyU7nNeYXEAlhc0kEg8ZBwccLk3YfqGzWSxXux3i40FuuluvNY2ogqZmxOIdKXNeA4jLSCMEccLrtNPFVU8U9NKyaGVofHJG4Oa9pGQQRwQR4oMhERAREQEREFTqa2/drJV0WQHSt/ST0DgcjP4yAuDXCiqbfVPp6yF8MzDgtcP7jzH5X0aenkoZqaGYDv4mSY8HNB/9U1q9NvhUdS6VTnTFcTqqOP8AHF9A2apuF7pqlsJ+lp373SPGG7hyAD4nOFu2ndL1lJeGVddIwd2S4BhyXk8fwOVu0bGsADWhoHAwMBe8LlyrVOTcprr/AG+EuB06jDt9m9zve36qe96bsV/MRvtmttyMWRGayljm2Z8twOFcIpFkpqXTVipbZNbKWy22C2zHMlJHSRtif0/cwDB6DqPBL1pmw30Q/fLJbLj3IIj+spI5u7B6hu4HH8K5RBUf5cseAPs1uwIjB/1Wf8Z6s6ftPl0WLbNGaXtVa2ttmnLLRVjc7Z6egijkGfJzWgrYUQVN70/Zr/FEy+Wm33KOIl0baymZMGE9SA4HBU1NabdS/Smlt9JCaSPuafu4Wt7iPgbGYH6W8DgccKwRBRXjSWnL1VCqvGn7RcKkANEtXRRyvAHhuc0lWtNBFS08UFNEyGGJoZHHG0NaxoGAABwAB4LIRAREQEREBERAREQEREBERAREQEREBERAREQEREBERBHul9DPcfhN0voZ7j8KREEe6X0M9x+E3S+hnuPwpEQR7pfQz3H4TdL6Ge4/CkRBHul9DPcfhN0voZ7j8KREEe6X0M9x+E3S+hnuPwpEQR7pfQz3H4TdL6Ge4/CkRBHul9DPcfhN0voZ7j8KREEe6X0M9x+E3S+hnuPwpEQR7pfQz3H4TdL6Ge4/CkRBHuk9DPcfhVV1vcdtexssL3ucM4jOcf1zhW/ktF1OT92k5/8AgL3RT3Ty4c/IqsW+6jy//9k=', - blob_mime: 'image/jpeg', - }, - }, - { - id: 'fa90345d5d7b05b1601e9ee645e663bc358869e0', - synthetics: { - blob: - '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABaAKADASIAAhEBAxEB/8QAHAABAAIDAQEBAAAAAAAAAAAAAAYIAwUHBAIB/8QANBAAAQQCAQICCAUDBQAAAAAAAQACAwQFEQYSIQcxExQWQVFSVKEiMmGS0RVTcQhDgZHh/8QAGQEBAAIDAAAAAAAAAAAAAAAAAAIEAwUG/8QAHxEBAAEEAwADAAAAAAAAAAAAAAEEExVRAgORBRIh/9oADAMBAAIRAxEAPwC1KIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIg+JHBkbnnyaCVyXj3jlhsqMRYs4PP43GZax6rUyViuz1Z8vUWhhe150dtI8vcfcCV1iyCa8oAJJYQAP8KvPg54WZPJ8D4qOY5TKwY/HWn3IuPzVG1xHK2aQtMjiOtwO+rR9zu3ZB3qDL46xYNeDIVJJ+p7TGyZpdtmuoaB3sbG/htZaORpZBshoW61psbul5gla/pPwOj2KrvjvD7J2uD+Kliph5q3KLuYvMozzxGKWWqXMcWxF2vwvBkGx2dvz7LyYvi+Vyd/MTcI4vk+K1TxWTG2GW63qvrVw76Q0bHU73el/nuHcLvPMPByvAYKCT1yfMOstimrSMfFEYI+t4eQ7YOj5AH9dLfR5fGyQVp48hUdDaf6OCQTtLZX7I6WHenHYI0Pgq18A47IefeGcuN4PmcEyhQt1crbs0XRMfY9Vc3rcfftx7Pdrq6gB+XQxcaxvI4cB4acXs8Sz0M/HuStluXDVJr9BmkeHscPNmn93a6Rrz7hBZ0ZKi6+aDbtY3gOo1xK30gHx6d70tJxbnGA5PPlYsPejlfjLD69jbgO7fN7e/dnf83kuI8B48/HZmjjuQcCy9/lsOdfcm5AA6GH0ZcSJvWR+duv9o9j/lfVHhLK2L8WMDZ4rlIn3LslmnNjKbWelqGRjmRQyHTTot2Yt+QIHdBYmhfqZGEy0LVezED0l8Ege3fw2CvWuJ/6fqeTo5TkLLGDFTGlkDYsg/EnFS2ntBBa6vst/DvXU0Dvvz327YgIiINXyPM0ePYS5lstO2ChUjMksh9w/wAe8k6AHvJChHG/FrGZfOY7GW8LnsN/VQX46xk6gjit6G9NcHHRI7gH3a+IC93jfxm5y/wxzeGxfe9Mxj4mF3SJHMka/oJ/Xp0N9t6Wm4/zPOcizGCx0PAsnj44AHZG3lq3oIqpa0dq52esk9hrXbX66DoozGMMDLAyNMwPk9C2T07el0nl0A711fp5rKclRF4UTcrC6R1CuZW+kI+PTvaq6cTySpxanxB3FM66zQ5ay9JdZVLqz4DKSHscO7vPZ0NADZI8ls8txzJY3xYns4XjN+/JazrbbxkcUHRsBO3Tw32OBYwe6N3l27HyQd04/wA1wfIM5l8RjLrZL2LeGWGbA3sebe/4gPIkeRW2gy+MsVp7MGRpy16+/TSMma5sevPqIOh5e9V6yvDMnFl/F3HYXj89bJZWBk2Mvw1AyGSH8JmhZMAA1z9kFuxsg78lro+NXLo5Ba4pw3L8fxrOITY+3WnpGF124QekMjHeRw+fWz/yNhZMZzFCOZ/9UoBkLGySu9YZpjXflc477A7GifNZ7eSo1Kgt27laCq7XTNLK1rDvy04nXdV1wHhnUfybhbbnFZPU5eJj+o+kqPDHXOkdptjXpQSezu4IHwGo9W4ryUcO8Np8zispNjqFe5Xs1H4g3pK0rpn+jc+q/RILOkA67AA+8ILcIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiCOe1Nb6eb7J7U1vp5vsoj5u7r9PkrFuHMZSo3HiW+1Nb6eb7J7U1vp5vsoiiW4MpUbjxLvamt9PN9k9qa30832URRLcGUqNx4l3tTW+nm+ye1Nb6eb7KIoluDKVG48S72prfTzfZPamt9PN9lEUS3BlKjceJd7U1vp5vsntTW/sTfZRFEtwZSo3HiW+1Nf+xN/0Fuq05sQtliawscNj8X/AIucKccYJOJi3+qhz4REfi98fW9nfz+vNteqX5GfuP8ACdUvyM/cf4WRFiblj6pfkZ+4/wAJ1S/Iz9x/hZEQY+qX5GfuP8J1S/Iz9x/hZEQf/9k=', - blob_mime: 'image/jpeg', - }, - }, - ], }, }; diff --git a/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts b/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts index 8ad3379615549..c0b4c893d93d8 100644 --- a/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts +++ b/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts @@ -111,4 +111,9 @@ export const mockState: AppState = { }, journeys: {}, networkEvents: {}, + synthetics: { + blocks: {}, + cacheSize: 0, + hitCount: [], + }, }; diff --git a/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.test.ts b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.test.ts new file mode 100644 index 0000000000000..0bf809d4e7a40 --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ScreenshotRefImageData } from '../../../common/runtime_types/ping/synthetics'; +import { composeScreenshotRef } from './compose_screenshot_images'; + +describe('composeScreenshotRef', () => { + let getContextMock: jest.Mock; + let drawImageMock: jest.Mock; + let ref: ScreenshotRefImageData; + let contextMock: unknown; + + beforeEach(() => { + drawImageMock = jest.fn(); + contextMock = { + drawImage: drawImageMock, + }; + getContextMock = jest.fn().mockReturnValue(contextMock); + ref = { + stepName: 'step', + maxSteps: 3, + ref: { + screenshotRef: { + '@timestamp': '123', + monitor: { check_group: 'check-group' }, + screenshot_ref: { + blocks: [ + { + hash: '123', + top: 0, + left: 0, + width: 10, + height: 10, + }, + ], + height: 100, + width: 100, + }, + synthetics: { + package_version: 'v1', + step: { + name: 'step-name', + index: 0, + }, + type: 'step/screenshot_ref', + }, + }, + }, + }; + }); + + it('throws error when blob does not exist', async () => { + try { + // @ts-expect-error incomplete invocation for test + await composeScreenshotRef(ref, { getContext: getContextMock }, {}); + } catch (e: any) { + expect(e).toMatchInlineSnapshot( + `[Error: Error processing image. Expected image data with hash 123 is missing]` + ); + expect(getContextMock).toHaveBeenCalled(); + expect(getContextMock.mock.calls[0][0]).toBe('2d'); + expect(getContextMock.mock.calls[0][1]).toEqual({ alpha: false }); + } + }); + + it('throws error when block is pending', async () => { + try { + await composeScreenshotRef( + ref, + // @ts-expect-error incomplete invocation for test + { getContext: getContextMock }, + { '123': { status: 'pending' } } + ); + } catch (e: any) { + expect(e).toMatchInlineSnapshot( + `[Error: Error processing image. Expected image data with hash 123 is missing]` + ); + expect(getContextMock).toHaveBeenCalled(); + expect(getContextMock.mock.calls[0][0]).toBe('2d'); + expect(getContextMock.mock.calls[0][1]).toEqual({ alpha: false }); + } + }); +}); diff --git a/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts index 7481a517d3c9e..60cd248c1487a 100644 --- a/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts +++ b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { ScreenshotRefImageData } from '../../../common/runtime_types'; +import { + isScreenshotBlockDoc, + ScreenshotRefImageData, +} from '../../../common/runtime_types/ping/synthetics'; +import { ScreenshotBlockCache } from '../../state/reducers/synthetics'; /** * Draws image fragments on a canvas. @@ -15,30 +19,30 @@ import { ScreenshotRefImageData } from '../../../common/runtime_types'; */ export async function composeScreenshotRef( data: ScreenshotRefImageData, - canvas: HTMLCanvasElement + canvas: HTMLCanvasElement, + blocks: ScreenshotBlockCache ) { const { - ref: { screenshotRef, blocks }, + ref: { screenshotRef }, } = data; + const ctx = canvas.getContext('2d', { alpha: false }); + canvas.width = screenshotRef.screenshot_ref.width; canvas.height = screenshotRef.screenshot_ref.height; - const ctx = canvas.getContext('2d', { alpha: false }); - /** * We need to treat each operation as an async task, otherwise we will race between drawing image * chunks and extracting the final data URL from the canvas; without this, the image could be blank or incomplete. */ const drawOperations: Array> = []; - for (const block of screenshotRef.screenshot_ref.blocks) { + for (const { hash, top, left, width, height } of screenshotRef.screenshot_ref.blocks) { drawOperations.push( new Promise((resolve, reject) => { const img = new Image(); - const { top, left, width, height, hash } = block; - const blob = blocks.find((b) => b.id === hash); - if (!blob) { + const blob = blocks[hash]; + if (!blob || !isScreenshotBlockDoc(blob)) { reject(Error(`Error processing image. Expected image data with hash ${hash} is missing`)); } else { img.onload = () => { diff --git a/x-pack/plugins/uptime/public/state/api/journey.ts b/x-pack/plugins/uptime/public/state/api/journey.ts index 4e71a07c70b68..8ed3fadf5c346 100644 --- a/x-pack/plugins/uptime/public/state/api/journey.ts +++ b/x-pack/plugins/uptime/public/state/api/journey.ts @@ -12,11 +12,16 @@ import { FailedStepsApiResponseType, JourneyStep, JourneyStepType, + ScreenshotBlockDoc, ScreenshotImageBlob, ScreenshotRefImageData, SyntheticsJourneyApiResponse, SyntheticsJourneyApiResponseType, -} from '../../../common/runtime_types'; +} from '../../../common/runtime_types/ping/synthetics'; + +export async function fetchScreenshotBlockSet(params: string[]): Promise { + return apiService.post('/api/uptime/journey/screenshot/block', { hashes: params }); +} export async function fetchJourneySteps( params: FetchJourneyStepsParams diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts index a5e9ffecadaf8..df02180b1c28d 100644 --- a/x-pack/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/plugins/uptime/public/state/effects/index.ts @@ -20,6 +20,11 @@ import { fetchCertificatesEffect } from '../certificates/certificates'; import { fetchAlertsEffect } from '../alerts/alerts'; import { fetchJourneyStepsEffect } from './journey'; import { fetchNetworkEventsEffect } from './network_events'; +import { + fetchScreenshotBlocks, + generateBlockStatsOnPut, + pruneBlockCache, +} from './synthetic_journey_blocks'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); @@ -38,4 +43,7 @@ export function* rootEffect() { yield fork(fetchAlertsEffect); yield fork(fetchJourneyStepsEffect); yield fork(fetchNetworkEventsEffect); + yield fork(fetchScreenshotBlocks); + yield fork(generateBlockStatsOnPut); + yield fork(pruneBlockCache); } diff --git a/x-pack/plugins/uptime/public/state/effects/synthetic_journey_blocks.ts b/x-pack/plugins/uptime/public/state/effects/synthetic_journey_blocks.ts new file mode 100644 index 0000000000000..829048747ddf7 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/effects/synthetic_journey_blocks.ts @@ -0,0 +1,81 @@ +/* + * Copyright 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 { Action } from 'redux-actions'; +import { call, fork, put, select, takeEvery, throttle } from 'redux-saga/effects'; +import { ScreenshotBlockDoc } from '../../../common/runtime_types/ping/synthetics'; +import { fetchScreenshotBlockSet } from '../api/journey'; +import { + fetchBlocksAction, + setBlockLoadingAction, + isPendingBlock, + pruneCacheAction, + putBlocksAction, + putCacheSize, + ScreenshotBlockCache, + updateHitCountsAction, +} from '../reducers/synthetics'; +import { syntheticsSelector } from '../selectors'; + +function* fetchBlocks(hashes: string[]) { + yield put(setBlockLoadingAction(hashes)); + const blocks: ScreenshotBlockDoc[] = yield call(fetchScreenshotBlockSet, hashes); + yield put(putBlocksAction({ blocks })); +} + +export function* fetchScreenshotBlocks() { + /** + * We maintain a list of each hash and how many times it is requested so we can avoid + * subsequent re-requests if the block is dropped due to cache pruning. + */ + yield takeEvery(String(fetchBlocksAction), function* (action: Action) { + if (action.payload.length > 0) { + yield put(updateHitCountsAction(action.payload)); + } + }); + + /** + * We do a short delay to allow multiple item renders to queue up before dispatching + * a fetch to the backend. + */ + yield throttle(20, String(fetchBlocksAction), function* () { + const { blocks }: { blocks: ScreenshotBlockCache } = yield select(syntheticsSelector); + const toFetch = Object.keys(blocks).filter((hash) => { + const block = blocks[hash]; + return isPendingBlock(block) && block.status !== 'loading'; + }); + + if (toFetch.length > 0) { + yield fork(fetchBlocks, toFetch); + } + }); +} + +export function* generateBlockStatsOnPut() { + yield takeEvery( + String(putBlocksAction), + function* (action: Action<{ blocks: ScreenshotBlockDoc[] }>) { + const batchSize = action.payload.blocks.reduce((total, cur) => { + return cur.synthetics.blob.length + total; + }, 0); + yield put(putCacheSize(batchSize)); + } + ); +} + +// 4 MB cap for cache size +const MAX_CACHE_SIZE = 4000000; + +export function* pruneBlockCache() { + yield takeEvery(String(putCacheSize), function* (_action: Action) { + const { cacheSize }: { cacheSize: number } = yield select(syntheticsSelector); + + if (cacheSize > MAX_CACHE_SIZE) { + yield put(pruneCacheAction(cacheSize - MAX_CACHE_SIZE)); + } + }); +} diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts index 05fb7c732466d..53cb6d6bffb0c 100644 --- a/x-pack/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/plugins/uptime/public/state/reducers/index.ts @@ -23,6 +23,7 @@ import { selectedFiltersReducer } from './selected_filters'; import { alertsReducer } from '../alerts/alerts'; import { journeyReducer } from './journey'; import { networkEventsReducer } from './network_events'; +import { syntheticsReducer } from './synthetics'; export const rootReducer = combineReducers({ monitor: monitorReducer, @@ -42,4 +43,5 @@ export const rootReducer = combineReducers({ alerts: alertsReducer, journeys: journeyReducer, networkEvents: networkEventsReducer, + synthetics: syntheticsReducer, }); diff --git a/x-pack/plugins/uptime/public/state/reducers/synthetics.test.ts b/x-pack/plugins/uptime/public/state/reducers/synthetics.test.ts new file mode 100644 index 0000000000000..06d738d01b42f --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/synthetics.test.ts @@ -0,0 +1,429 @@ +/* + * Copyright 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 { + fetchBlocksAction, + isPendingBlock, + pruneCacheAction, + setBlockLoadingAction, + putBlocksAction, + putCacheSize, + syntheticsReducer, + SyntheticsReducerState, + updateHitCountsAction, +} from './synthetics'; + +const MIME = 'image/jpeg'; + +describe('syntheticsReducer', () => { + jest.spyOn(Date, 'now').mockImplementation(() => 10); + + describe('isPendingBlock', () => { + it('returns true for pending block', () => { + expect(isPendingBlock({ status: 'pending' })).toBe(true); + }); + + it('returns true for loading block', () => { + expect(isPendingBlock({ status: 'loading' })).toBe(true); + }); + + it('returns false for non-pending block', () => { + expect(isPendingBlock({ synthetics: { blob: 'blobdata', blob_mime: MIME } })).toBe(false); + expect(isPendingBlock({})).toBe(false); + }); + }); + + describe('prune cache', () => { + let state: SyntheticsReducerState; + + beforeEach(() => { + const blobs = ['large', 'large2', 'large3', 'large4']; + state = { + blocks: { + '123': { + synthetics: { + blob: blobs[0], + blob_mime: MIME, + }, + id: '123', + }, + '234': { + synthetics: { + blob: blobs[1], + blob_mime: MIME, + }, + id: '234', + }, + '345': { + synthetics: { + blob: blobs[2], + blob_mime: MIME, + }, + id: '345', + }, + '456': { + synthetics: { + blob: blobs[3], + blob_mime: MIME, + }, + id: '456', + }, + }, + cacheSize: 23, + hitCount: [ + { hash: '123', hitTime: 89 }, + { hash: '234', hitTime: 23 }, + { hash: '345', hitTime: 4 }, + { hash: '456', hitTime: 1 }, + ], + }; + }); + + it('removes lowest common hits', () => { + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, pruneCacheAction(10))).toMatchInlineSnapshot(` + Object { + "blocks": Object { + "123": Object { + "id": "123", + "synthetics": Object { + "blob": "large", + "blob_mime": "image/jpeg", + }, + }, + "234": Object { + "id": "234", + "synthetics": Object { + "blob": "large2", + "blob_mime": "image/jpeg", + }, + }, + }, + "cacheSize": 11, + "hitCount": Array [ + Object { + "hash": "123", + "hitTime": 89, + }, + Object { + "hash": "234", + "hitTime": 23, + }, + ], + } + `); + }); + + it('skips pending blocks', () => { + state.blocks = { ...state.blocks, '000': { status: 'pending' } }; + state.hitCount.push({ hash: '000', hitTime: 1 }); + // @ts-expect-error redux-actions doesn't handle types well + const newState = syntheticsReducer(state, pruneCacheAction(10)); + expect(newState.blocks['000']).toEqual({ status: 'pending' }); + }); + + it('ignores a hash from `hitCount` that does not exist', () => { + state.hitCount.push({ hash: 'not exist', hitTime: 1 }); + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, pruneCacheAction(2))).toMatchInlineSnapshot(` + Object { + "blocks": Object { + "123": Object { + "id": "123", + "synthetics": Object { + "blob": "large", + "blob_mime": "image/jpeg", + }, + }, + "234": Object { + "id": "234", + "synthetics": Object { + "blob": "large2", + "blob_mime": "image/jpeg", + }, + }, + "345": Object { + "id": "345", + "synthetics": Object { + "blob": "large3", + "blob_mime": "image/jpeg", + }, + }, + }, + "cacheSize": 17, + "hitCount": Array [ + Object { + "hash": "123", + "hitTime": 89, + }, + Object { + "hash": "234", + "hitTime": 23, + }, + Object { + "hash": "345", + "hitTime": 4, + }, + ], + } + `); + }); + + it('will prune a block with an empty blob', () => { + state.blocks = { + ...state.blocks, + '000': { id: '000', synthetics: { blob: '', blob_mime: MIME } }, + }; + state.hitCount.push({ hash: '000', hitTime: 1 }); + // @ts-expect-error redux-actions doesn't handle types well + const newState = syntheticsReducer(state, pruneCacheAction(10)); + expect(Object.keys(newState.blocks)).not.toContain('000'); + }); + }); + + describe('fetch blocks', () => { + it('sets targeted blocks as pending', () => { + const state: SyntheticsReducerState = { blocks: {}, cacheSize: 0, hitCount: [] }; + const action = fetchBlocksAction(['123', '234']); + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, action)).toMatchInlineSnapshot(` + Object { + "blocks": Object { + "123": Object { + "status": "pending", + }, + "234": Object { + "status": "pending", + }, + }, + "cacheSize": 0, + "hitCount": Array [], + } + `); + }); + + it('will not overwrite a cached block', () => { + const state: SyntheticsReducerState = { + blocks: { '123': { id: '123', synthetics: { blob: 'large', blob_mime: MIME } } }, + cacheSize: 'large'.length, + hitCount: [{ hash: '123', hitTime: 1 }], + }; + const action = fetchBlocksAction(['123']); + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, action)).toMatchInlineSnapshot(` + Object { + "blocks": Object { + "123": Object { + "id": "123", + "synthetics": Object { + "blob": "large", + "blob_mime": "image/jpeg", + }, + }, + }, + "cacheSize": 5, + "hitCount": Array [ + Object { + "hash": "123", + "hitTime": 1, + }, + ], + } + `); + }); + }); + describe('update hit counts', () => { + let state: SyntheticsReducerState; + + beforeEach(() => { + const blobs = ['large', 'large2', 'large3']; + state = { + blocks: { + '123': { + synthetics: { + blob: blobs[0], + blob_mime: MIME, + }, + id: '123', + }, + '234': { + synthetics: { + blob: blobs[1], + blob_mime: MIME, + }, + id: '234', + }, + '345': { + synthetics: { + blob: blobs[2], + blob_mime: MIME, + }, + id: '345', + }, + }, + cacheSize: 17, + hitCount: [ + { hash: '123', hitTime: 1 }, + { hash: '234', hitTime: 1 }, + ], + }; + }); + + it('increments hit count for selected hashes', () => { + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, updateHitCountsAction(['123', '234'])).hitCount).toEqual([ + { + hash: '123', + hitTime: 10, + }, + { hash: '234', hitTime: 10 }, + ]); + }); + + it('adds new hit count for missing item', () => { + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, updateHitCountsAction(['345'])).hitCount).toEqual([ + { hash: '345', hitTime: 10 }, + { hash: '123', hitTime: 1 }, + { hash: '234', hitTime: 1 }, + ]); + }); + }); + describe('put cache size', () => { + let state: SyntheticsReducerState; + + beforeEach(() => { + state = { + blocks: {}, + cacheSize: 0, + hitCount: [], + }; + }); + + it('updates the cache size', () => { + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, putCacheSize(100))).toEqual({ + blocks: {}, + cacheSize: 100, + hitCount: [], + }); + }); + }); + + describe('in-flight blocks', () => { + let state: SyntheticsReducerState; + + beforeEach(() => { + state = { + blocks: { + '123': { status: 'pending' }, + }, + cacheSize: 1, + hitCount: [{ hash: '123', hitTime: 1 }], + }; + }); + + it('sets pending blocks to loading', () => { + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, setBlockLoadingAction(['123']))).toEqual({ + blocks: { '123': { status: 'loading' } }, + cacheSize: 1, + hitCount: [{ hash: '123', hitTime: 1 }], + }); + }); + }); + + describe('put blocks', () => { + let state: SyntheticsReducerState; + + beforeEach(() => { + state = { + blocks: { + '123': { + status: 'pending', + }, + }, + cacheSize: 0, + hitCount: [{ hash: '123', hitTime: 1 }], + }; + }); + + it('resolves pending blocks', () => { + const action = putBlocksAction({ + blocks: [ + { + id: '123', + synthetics: { + blob: 'reallybig', + blob_mime: MIME, + }, + }, + ], + }); + // @ts-expect-error redux-actions doesn't handle types well + const result = syntheticsReducer(state, action); + expect(result).toMatchInlineSnapshot(` + Object { + "blocks": Object { + "123": Object { + "id": "123", + "synthetics": Object { + "blob": "reallybig", + "blob_mime": "image/jpeg", + }, + }, + }, + "cacheSize": 0, + "hitCount": Array [ + Object { + "hash": "123", + "hitTime": 1, + }, + ], + } + `); + }); + + it('keeps unresolved blocks', () => { + const action = putBlocksAction({ + blocks: [ + { + id: '234', + synthetics: { + blob: 'also big', + blob_mime: MIME, + }, + }, + ], + }); + // @ts-expect-error redux-actions doesn't handle types well + expect(syntheticsReducer(state, action)).toMatchInlineSnapshot(` + Object { + "blocks": Object { + "123": Object { + "status": "pending", + }, + "234": Object { + "id": "234", + "synthetics": Object { + "blob": "also big", + "blob_mime": "image/jpeg", + }, + }, + }, + "cacheSize": 0, + "hitCount": Array [ + Object { + "hash": "123", + "hitTime": 1, + }, + ], + } + `); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/state/reducers/synthetics.ts b/x-pack/plugins/uptime/public/state/reducers/synthetics.ts new file mode 100644 index 0000000000000..1e97c3972444b --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/synthetics.ts @@ -0,0 +1,180 @@ +/* + * Copyright 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 { createAction, handleActions, Action } from 'redux-actions'; +import { + isScreenshotBlockDoc, + ScreenshotBlockDoc, +} from '../../../common/runtime_types/ping/synthetics'; + +export interface PendingBlock { + status: 'pending' | 'loading'; +} + +export function isPendingBlock(data: unknown): data is PendingBlock { + return ['pending', 'loading'].some((s) => s === (data as PendingBlock)?.status); +} +export type StoreScreenshotBlock = ScreenshotBlockDoc | PendingBlock; +export interface ScreenshotBlockCache { + [hash: string]: StoreScreenshotBlock; +} + +export interface CacheHitCount { + hash: string; + hitTime: number; +} + +export interface SyntheticsReducerState { + blocks: ScreenshotBlockCache; + cacheSize: number; + hitCount: CacheHitCount[]; +} + +export interface PutBlocksPayload { + blocks: ScreenshotBlockDoc[]; +} + +// this action denotes a set of blocks is required +export const fetchBlocksAction = createAction('FETCH_BLOCKS'); +// this action denotes a request for a set of blocks is in flight +export const setBlockLoadingAction = createAction('IN_FLIGHT_BLOCKS_ACTION'); +// block data has been received, and should be added to the store +export const putBlocksAction = createAction('PUT_SCREENSHOT_BLOCKS'); +// updates the total size of the image blob data cached in the store +export const putCacheSize = createAction('PUT_CACHE_SIZE'); +// keeps track of the most-requested blocks +export const updateHitCountsAction = createAction('UPDATE_HIT_COUNTS'); +// reduce the cache size to the value in the action payload +export const pruneCacheAction = createAction('PRUNE_SCREENSHOT_BLOCK_CACHE'); + +const initialState: SyntheticsReducerState = { + blocks: {}, + cacheSize: 0, + hitCount: [], +}; + +// using `any` here because `handleActions` is not set up well to handle the multi-type +// nature of all the actions it supports. redux-actions is looking for new maintainers https://github.com/redux-utilities/redux-actions#looking-for-maintainers +// and seems that we should move to something else like Redux Toolkit. +export const syntheticsReducer = handleActions< + SyntheticsReducerState, + string[] & PutBlocksPayload & number +>( + { + /** + * When removing blocks from the cache, we receive an action with a number. + * The number equates to the desired ceiling size of the cache. We then discard + * blocks, ordered by the least-requested. We continue dropping blocks until + * the newly-pruned size will be less than the ceiling supplied by the action. + */ + [String(pruneCacheAction)]: (state, action: Action) => handlePruneAction(state, action), + + /** + * Keep track of the least- and most-requested blocks, so when it is time to + * prune we keep the most commonly-used ones. + */ + [String(updateHitCountsAction)]: (state, action: Action) => + handleUpdateHitCountsAction(state, action), + + [String(putCacheSize)]: (state, action: Action) => ({ + ...state, + cacheSize: state.cacheSize + action.payload, + }), + + [String(fetchBlocksAction)]: (state, action: Action) => ({ + // increment hit counts + ...state, + blocks: { + ...state.blocks, + ...action.payload + // there's no need to overwrite existing blocks because the key + // is either storing a pending req or a cached result + .filter((b) => !state.blocks[b]) + // convert the list of new hashes in the payload to an object that + // will combine with with the existing blocks cache + .reduce( + (acc, cur) => ({ + ...acc, + [cur]: { status: 'pending' }, + }), + {} + ), + }, + }), + + /** + * All hashes contained in the action payload have been requested, so we can + * indicate that they're loading. Subsequent requests will skip them. + */ + [String(setBlockLoadingAction)]: (state, action: Action) => ({ + ...state, + blocks: { + ...state.blocks, + ...action.payload.reduce( + (acc, cur) => ({ + ...acc, + [cur]: { status: 'loading' }, + }), + {} + ), + }, + }), + + [String(putBlocksAction)]: (state, action: Action) => ({ + ...state, + blocks: { + ...state.blocks, + ...action.payload.blocks.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), + }, + }), + }, + initialState +); + +function handlePruneAction(state: SyntheticsReducerState, action: Action) { + const { blocks, hitCount } = state; + const hashesToPrune: string[] = []; + let sizeToRemove = 0; + let removeIndex = hitCount.length - 1; + while (sizeToRemove < action.payload && removeIndex >= 0) { + const { hash } = hitCount[removeIndex]; + removeIndex--; + if (!blocks[hash]) continue; + const block = blocks[hash]; + if (isScreenshotBlockDoc(block)) { + sizeToRemove += block.synthetics.blob.length; + hashesToPrune.push(hash); + } + } + for (const hash of hashesToPrune) { + delete blocks[hash]; + } + return { + cacheSize: state.cacheSize - sizeToRemove, + blocks: { ...blocks }, + hitCount: hitCount.slice(0, removeIndex + 1), + }; +} + +function handleUpdateHitCountsAction(state: SyntheticsReducerState, action: Action) { + const newHitCount = [...state.hitCount]; + const hitTime = Date.now(); + action.payload.forEach((hash) => { + const countItem = newHitCount.find((item) => item.hash === hash); + if (!countItem) { + newHitCount.push({ hash, hitTime }); + } else { + countItem.hitTime = hitTime; + } + }); + // sorts in descending order + newHitCount.sort((a, b) => b.hitTime - a.hitTime); + return { + ...state, + hitCount: newHitCount, + }; +} diff --git a/x-pack/plugins/uptime/public/state/selectors/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/index.test.ts index e4094c72a6e10..520ebdac0c1e0 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.test.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.test.ts @@ -109,6 +109,11 @@ describe('state selectors', () => { }, journeys: {}, networkEvents: {}, + synthetics: { + blocks: {}, + cacheSize: 0, + hitCount: [], + }, }; it('selects base path from state', () => { diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 6c4ea8201398c..222687c78a868 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -97,3 +97,5 @@ export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId; export const journeySelector = ({ journeys }: AppState) => journeys; export const networkEventsSelector = ({ networkEvents }: AppState) => networkEvents; + +export const syntheticsSelector = ({ synthetics }: AppState) => synthetics; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts index 3d8bc04a10565..15cee91606e66 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts @@ -12,6 +12,7 @@ describe('getJourneyScreenshot', () => { it('returns screenshot data', async () => { const screenshotResult = { _id: 'id', + _index: 'index', _source: { synthetics: { blob_mime: 'image/jpeg', @@ -26,8 +27,14 @@ describe('getJourneyScreenshot', () => { expect( await getJourneyScreenshot({ uptimeEsClient: mockSearchResult([], { - // @ts-expect-error incomplete search result - step: { image: { hits: { hits: [screenshotResult] } } }, + step: { + image: { + hits: { + total: 1, + hits: [screenshotResult], + }, + }, + }, }), checkGroup: 'checkGroup', stepIndex: 0, @@ -48,6 +55,7 @@ describe('getJourneyScreenshot', () => { it('returns ref data', async () => { const screenshotRefResult = { _id: 'id', + _index: 'index', _source: { '@timestamp': '123', monitor: { @@ -86,8 +94,7 @@ describe('getJourneyScreenshot', () => { expect( await getJourneyScreenshot({ uptimeEsClient: mockSearchResult([], { - // @ts-expect-error incomplete search result - step: { image: { hits: { hits: [screenshotRefResult] } } }, + step: { image: { hits: { hits: [screenshotRefResult], total: 1 } } }, }), checkGroup: 'checkGroup', stepIndex: 0, diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index d4d0e13bd23db..8ae878669ba32 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -12,7 +12,7 @@ import { createGetPingsRoute, createJourneyRoute, createJourneyScreenshotRoute, - createJourneyScreenshotBlockRoute, + createJourneyScreenshotBlocksRoute, } from './pings'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings'; import { createLogPageViewRoute } from './telemetry'; @@ -52,8 +52,8 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ createGetMonitorDurationRoute, createJourneyRoute, createJourneyScreenshotRoute, - createJourneyScreenshotBlockRoute, createNetworkEventsRoute, createJourneyFailedStepsRoute, createLastSuccessfulStepRoute, + createJourneyScreenshotBlocksRoute, ]; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/index.ts b/x-pack/plugins/uptime/server/rest_api/pings/index.ts index 45cd23dea42ed..0e1cc7baa9ad1 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/index.ts @@ -9,4 +9,4 @@ export { createGetPingsRoute } from './get_pings'; export { createGetPingHistogramRoute } from './get_ping_histogram'; export { createJourneyRoute } from './journeys'; export { createJourneyScreenshotRoute } from './journey_screenshots'; -export { createJourneyScreenshotBlockRoute } from './journey_screenshot_blocks'; +export { createJourneyScreenshotBlocksRoute } from './journey_screenshot_blocks'; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.test.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.test.ts new file mode 100644 index 0000000000000..4909e2eb80108 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 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 { createJourneyScreenshotBlocksRoute } from './journey_screenshot_blocks'; + +describe('journey screenshot blocks route', () => { + let libs: unknown; + beforeEach(() => { + libs = { + uptimeEsClient: jest.fn(), + request: { + body: { + hashes: ['hash1', 'hash2'], + }, + }, + response: { + badRequest: jest.fn().mockReturnValue({ status: 400, message: 'Bad request.' }), + ok: jest.fn((responseData) => ({ ...responseData, status: 200, message: 'Ok' })), + notFound: jest.fn().mockReturnValue({ status: 404, message: 'Not found.' }), + }, + }; + }); + + it('returns status code 400 if hash list is invalid', async () => { + // @ts-expect-error incomplete implementation for testing + const route = createJourneyScreenshotBlocksRoute(); + + libs = Object.assign({}, libs, { request: { body: { hashes: undefined } } }); + + // @ts-expect-error incomplete implementation for testing + const response = await route.handler(libs); + expect(response.status).toBe(400); + }); + + it('returns status code 404 if result is empty set', async () => { + const route = createJourneyScreenshotBlocksRoute({ + // @ts-expect-error incomplete implementation for testing + requests: { + getJourneyScreenshotBlocks: jest.fn().mockReturnValue([]), + }, + }); + + // @ts-expect-error incomplete implementation for testing + expect((await route.handler(libs)).status).toBe(404); + }); + + it('returns blocks for request', async () => { + const responseData = [ + { + id: 'hash1', + synthetics: { + blob: 'blob1', + blob_mime: 'image/jpeg', + }, + }, + { + id: 'hash2', + synthetics: { + blob: 'blob2', + blob_mime: 'image/jpeg', + }, + }, + ]; + const route = createJourneyScreenshotBlocksRoute({ + // @ts-expect-error incomplete implementation for testing + requests: { + getJourneyScreenshotBlocks: jest.fn().mockReturnValue(responseData), + }, + }); + + // @ts-expect-error incomplete implementation for testing + const response = await route.handler(libs); + expect(response.status).toBe(200); + // @ts-expect-error incomplete implementation for testing + expect(response.body).toEqual(responseData); + }); +}); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts index 63c2cfe7e2d48..3127c34590ef5 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts @@ -10,44 +10,38 @@ import { isRight } from 'fp-ts/lib/Either'; import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; -import { ScreenshotBlockDoc } from '../../../common/runtime_types/ping/synthetics'; -export const createJourneyScreenshotBlockRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ - method: 'GET', +function isStringArray(data: unknown): data is string[] { + return isRight(t.array(t.string).decode(data)); +} + +export const createJourneyScreenshotBlocksRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'POST', path: '/api/uptime/journey/screenshot/block', validate: { + body: schema.object({ + hashes: schema.arrayOf(schema.string()), + }), query: schema.object({ - hash: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ request, response, uptimeEsClient }) => { - const { hash } = request.query; + const { hashes: blockIds } = request.body; + + if (!isStringArray(blockIds)) return response.badRequest(); + + const result = await libs.requests.getJourneyScreenshotBlocks({ + blockIds, + uptimeEsClient, + }); - const decoded = t.union([t.string, t.array(t.string)]).decode(hash); - if (!isRight(decoded)) { - return response.badRequest(); - } - const { right: data } = decoded; - let result: ScreenshotBlockDoc[]; - try { - result = await libs.requests.getJourneyScreenshotBlocks({ - blockIds: Array.isArray(data) ? data : [data], - uptimeEsClient, - }); - } catch (e: unknown) { - return response.custom({ statusCode: 500, body: { message: e } }); - } if (result.length === 0) { return response.notFound(); } + return response.ok({ body: result, - headers: { - // we can cache these blocks with extreme prejudice as they are inherently unchanging - // when queried by ID, since the ID is the hash of the data - 'Cache-Control': 'max-age=604800', - }, }); }, }); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.test.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.test.ts new file mode 100644 index 0000000000000..22aef54fa10bd --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright 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 { createJourneyScreenshotRoute } from './journey_screenshots'; + +describe('journey screenshot route', () => { + let libs: unknown; + beforeEach(() => { + libs = { + uptimeEsClient: jest.fn(), + request: { + params: { + checkGroup: 'check_group', + stepIndex: 0, + }, + }, + response: { + ok: jest.fn((responseData) => ({ ...responseData, status: 200, message: 'Ok' })), + notFound: jest.fn().mockReturnValue({ status: 404, message: 'Not found.' }), + }, + }; + }); + + it('will 404 for missing screenshot', async () => { + const route = createJourneyScreenshotRoute({ + // @ts-expect-error incomplete implementation for testing + requests: { + getJourneyScreenshot: jest.fn(), + }, + }); + + // @ts-expect-error incomplete implementation for testing + expect(await route.handler(libs)).toMatchInlineSnapshot(` + Object { + "message": "Not found.", + "status": 404, + } + `); + }); + + it('returns screenshot ref', async () => { + const mock = { + '@timestamp': '123', + monitor: { + check_group: 'check_group', + }, + screenshot_ref: { + width: 100, + height: 200, + blocks: [{ hash: 'hash', top: 0, left: 0, height: 10, width: 10 }], + }, + synthetics: { + package_version: '1.0.0', + step: { + name: 'a step name', + index: 0, + }, + type: 'step/screenshot_ref', + }, + totalSteps: 3, + }; + + const route = createJourneyScreenshotRoute({ + // @ts-expect-error incomplete implementation for testing + requests: { + getJourneyScreenshot: jest.fn().mockReturnValue(mock), + }, + }); + + // @ts-expect-error incomplete implementation for testing + const response = await route.handler(libs); + expect(response.status).toBe(200); + // @ts-expect-error response doesn't match interface for testing + expect(response.headers).toMatchInlineSnapshot(` + Object { + "cache-control": "max-age=600", + "caption-name": "a step name", + "max-steps": "3", + } + `); + // @ts-expect-error response doesn't match interface for testing + expect(response.body.screenshotRef).toEqual(mock); + }); + + it('returns full screenshot blob', async () => { + const mock = { + synthetics: { + blob: 'a blob', + blob_mime: 'image/jpeg', + step: { + name: 'a step name', + }, + type: 'step/screenshot', + }, + totalSteps: 3, + }; + const route = createJourneyScreenshotRoute({ + // @ts-expect-error incomplete implementation for testing + requests: { + getJourneyScreenshot: jest.fn().mockReturnValue(mock), + }, + }); + + // @ts-expect-error incomplete implementation for testing + expect(await route.handler(libs)).toMatchInlineSnapshot(` + Object { + "body": Object { + "data": Array [ + 105, + 185, + 104, + ], + "type": "Buffer", + }, + "headers": Object { + "cache-control": "max-age=600", + "caption-name": "a step name", + "content-type": "image/jpeg", + "max-steps": "3", + }, + "message": "Ok", + "status": 200, + } + `); + }); + + it('defaults to png when mime is undefined', async () => { + const mock = { + synthetics: { + blob: 'a blob', + step: { + name: 'a step name', + }, + type: 'step/screenshot', + }, + }; + const route = createJourneyScreenshotRoute({ + // @ts-expect-error incomplete implementation for testing + requests: { + getJourneyScreenshot: jest.fn().mockReturnValue(mock), + }, + }); + + // @ts-expect-error incomplete implementation for testing + const response = await route.handler(libs); + + expect(response.status).toBe(200); + // @ts-expect-error incomplete implementation for testing + expect(response.headers['content-type']).toBe('image/png'); + }); + + it('returns 404 for screenshot missing blob', async () => { + const route = createJourneyScreenshotRoute({ + // @ts-expect-error incomplete implementation for testing + requests: { + getJourneyScreenshot: jest.fn().mockReturnValue({ + synthetics: { + step: { + name: 'a step name', + }, + type: 'step/screenshot', + }, + }), + }, + }); + + // @ts-expect-error incomplete implementation for testing + expect(await route.handler(libs)).toMatchInlineSnapshot(` + Object { + "message": "Not found.", + "status": 404, + } + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts index bd7cf6af4f843..5f0825279ecfa 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts @@ -6,11 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { - isRefResult, - isFullScreenshot, - ScreenshotBlockDoc, -} from '../../../common/runtime_types/ping/synthetics'; +import { isRefResult, isFullScreenshot } from '../../../common/runtime_types/ping/synthetics'; import { UMServerLibs } from '../../lib/lib'; import { ScreenshotReturnTypesUnion } from '../../lib/requests/get_journey_screenshot'; import { UMRestApiRouteFactory } from '../types'; @@ -39,22 +35,13 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ handler: async ({ uptimeEsClient, request, response }) => { const { checkGroup, stepIndex } = request.params; - let result: ScreenshotReturnTypesUnion | null = null; - try { - result = await libs.requests.getJourneyScreenshot({ - uptimeEsClient, - checkGroup, - stepIndex, - }); - } catch (e) { - return response.customError({ body: { message: e }, statusCode: 500 }); - } - - if (isFullScreenshot(result)) { - if (!result.synthetics.blob) { - return response.notFound(); - } + const result: ScreenshotReturnTypesUnion | null = await libs.requests.getJourneyScreenshot({ + uptimeEsClient, + checkGroup, + stepIndex, + }); + if (isFullScreenshot(result) && typeof result.synthetics?.blob !== 'undefined') { return response.ok({ body: Buffer.from(result.synthetics.blob, 'base64'), headers: { @@ -63,22 +50,11 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ }, }); } else if (isRefResult(result)) { - const blockIds = result.screenshot_ref.blocks.map(({ hash }) => hash); - let blocks: ScreenshotBlockDoc[]; - try { - blocks = await libs.requests.getJourneyScreenshotBlocks({ - uptimeEsClient, - blockIds, - }); - } catch (e: unknown) { - return response.custom({ statusCode: 500, body: { message: e } }); - } return response.ok({ body: { screenshotRef: result, - blocks, }, - headers: getSharedHeaders(result.synthetics.step.name, result.totalSteps ?? 0), + headers: getSharedHeaders(result.synthetics.step.name, result.totalSteps), }); }