diff --git a/fleet_packages.json b/fleet_packages.json index 836e65d0e95aa..bfde442c20d22 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -56,6 +56,6 @@ }, { "name": "security_detection_engine", - "version": "8.12.3" + "version": "8.12.4" } ] \ No newline at end of file diff --git a/packages/kbn-search-connectors/types/native_connectors.ts b/packages/kbn-search-connectors/types/native_connectors.ts index 62ac7445e8b06..849e669c4a810 100644 --- a/packages/kbn-search-connectors/types/native_connectors.ts +++ b/packages/kbn-search-connectors/types/native_connectors.ts @@ -1793,7 +1793,7 @@ export const NATIVE_CONNECTOR_DEFINITIONS: Record @@ -345,7 +347,11 @@ export const EditorFooter = memo(function EditorFooter({ justifyContent="spaceBetween" > - {isSpaceReduced + {allowQueryCancellation && isLoading + ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.cancel', { + defaultMessage: 'Cancel', + }) + : isSpaceReduced ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.run', { defaultMessage: 'Run', }) @@ -361,7 +367,7 @@ export const EditorFooter = memo(function EditorFooter({ size="xs" css={css` border: 1px solid - ${Boolean(disableSubmitAction) + ${Boolean(disableSubmitAction && !allowQueryCancellation) ? euiTheme.colors.disabled : euiTheme.colors.emptyShade}; padding: 0 ${euiTheme.size.xs}; @@ -370,7 +376,7 @@ export const EditorFooter = memo(function EditorFooter({ border-radius: ${euiTheme.size.xs}; `} > - {COMMAND_KEY}⏎ + {allowQueryCancellation && isLoading ? 'X' : `${COMMAND_KEY}⏎`} diff --git a/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts b/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts index e1ef2a5d4a8b3..f1013f1a4b329 100644 --- a/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts +++ b/packages/kbn-text-based-editor/src/fetch_fields_from_esql.ts @@ -24,6 +24,7 @@ export function fetchFieldsFromESQL( query: Query | AggregateQuery, expressions: ExpressionsStart, time?: TimeRange, + abortController?: AbortController, dataView?: DataView ) { return textBasedQueryStateToAstWithValidation({ @@ -33,7 +34,15 @@ export function fetchFieldsFromESQL( }) .then((ast) => { if (ast) { - const execution = expressions.run(ast, null); + const executionContract = expressions.execute(ast, null); + + if (abortController) { + abortController.signal.onabort = () => { + executionContract.cancel(); + }; + } + + const execution = executionContract.getData(); let finalData: Datatable; let error: string | undefined; execution.pipe(pluck('result')).subscribe((resp) => { diff --git a/packages/kbn-text-based-editor/src/helpers.ts b/packages/kbn-text-based-editor/src/helpers.ts index 88df8ef6a75e4..dffddc9514449 100644 --- a/packages/kbn-text-based-editor/src/helpers.ts +++ b/packages/kbn-text-based-editor/src/helpers.ts @@ -116,6 +116,17 @@ export const parseErrors = (errors: Error[], code: string): MonacoMessage[] => { endLineNumber: Number(lineNumber), severity: monaco.MarkerSeverity.Error, }; + } else if (error.message.includes('expression was aborted')) { + return { + message: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.aborted', { + defaultMessage: 'Request was aborted', + }), + startColumn: 1, + startLineNumber: 1, + endColumn: 10, + endLineNumber: 1, + severity: monaco.MarkerSeverity.Warning, + }; } else { // unknown error message return { diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx index 7bdfce427bc21..241679734e248 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx @@ -71,7 +71,10 @@ export interface TextBasedLanguagesEditorProps { /** Callback running everytime the query changes */ onTextLangQueryChange: (query: AggregateQuery) => void; /** Callback running when the user submits the query */ - onTextLangQuerySubmit: (query?: AggregateQuery) => void; + onTextLangQuerySubmit: ( + query?: AggregateQuery, + abortController?: AbortController + ) => Promise; /** Can be used to expand/minimize the editor */ expandCodeEditor: (status: boolean) => void; /** If it is true, the editor initializes with height EDITOR_INITIAL_HEIGHT_EXPANDED */ @@ -105,6 +108,9 @@ export interface TextBasedLanguagesEditorProps { editorIsInline?: boolean; /** Disables the submit query action*/ disableSubmitAction?: boolean; + + /** when set to true enables query cancellation **/ + allowQueryCancellation?: boolean; } interface TextBasedEditorDeps { @@ -158,6 +164,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ editorIsInline, disableSubmitAction, dataTestSubj, + allowQueryCancellation, }: TextBasedLanguagesEditorProps) { const { euiTheme } = useEuiTheme(); const language = getAggregateQueryMode(query); @@ -176,7 +183,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ const [showLineNumbers, setShowLineNumbers] = useState(isCodeEditorExpanded); const [isCompactFocused, setIsCompactFocused] = useState(isCodeEditorExpanded); const [isCodeEditorExpandedFocused, setIsCodeEditorExpandedFocused] = useState(false); - + const [isQueryLoading, setIsQueryLoading] = useState(true); + const [abortController, setAbortController] = useState(new AbortController()); const [editorMessages, setEditorMessages] = useState<{ errors: MonacoMessage[]; warnings: MonacoMessage[]; @@ -186,12 +194,25 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ }); const onQuerySubmit = useCallback(() => { - const currentValue = editor1.current?.getValue(); - if (currentValue != null) { - setCodeStateOnSubmission(currentValue); + if (isQueryLoading && allowQueryCancellation) { + abortController?.abort(); + setIsQueryLoading(false); + } else { + setIsQueryLoading(true); + const abc = new AbortController(); + setAbortController(abc); + + const currentValue = editor1.current?.getValue(); + if (currentValue != null) { + setCodeStateOnSubmission(currentValue); + } + onTextLangQuerySubmit({ [language]: currentValue } as AggregateQuery, abc); } - onTextLangQuerySubmit({ [language]: currentValue } as AggregateQuery); - }, [language, onTextLangQuerySubmit]); + }, [language, onTextLangQuerySubmit, abortController, isQueryLoading, allowQueryCancellation]); + + useEffect(() => { + if (!isLoading) setIsQueryLoading(false); + }, [isLoading]); const [documentationSections, setDocumentationSections] = useState(); @@ -311,12 +332,13 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ const { cache: esqlFieldsCache, memoizedFieldsFromESQL } = useMemo(() => { // need to store the timing of the first request so we can atomically clear the cache per query const fn = memoize( - (...args: [{ esql: string }, ExpressionsStart]) => ({ + (...args: [{ esql: string }, ExpressionsStart, undefined, AbortController?]) => ({ timestamp: Date.now(), result: fetchFieldsFromESQL(...args), }), ({ esql }) => esql ); + return { cache: fn.cache, memoizedFieldsFromESQL: fn }; }, []); @@ -334,7 +356,12 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ // Check if there's a stale entry and clear it clearCacheWhenOld(esqlFieldsCache, esqlQuery.esql); try { - const table = await memoizedFieldsFromESQL(esqlQuery, expressions).result; + const table = await memoizedFieldsFromESQL( + esqlQuery, + expressions, + undefined, + abortController + ).result; return table?.columns.map((c) => ({ name: c.name, type: c.meta.type })) || []; } catch (e) { // no action yet @@ -352,7 +379,14 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ return policies.map(({ type, query: policyQuery, ...rest }) => rest); }, }), - [dataViews, expressions, indexManagementApiService, esqlFieldsCache, memoizedFieldsFromESQL] + [ + dataViews, + expressions, + indexManagementApiService, + esqlFieldsCache, + memoizedFieldsFromESQL, + abortController, + ] ); const queryValidation = useCallback( @@ -867,7 +901,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ disableSubmitAction={disableSubmitAction} hideRunQueryText={hideRunQueryText} isSpaceReduced={isSpaceReduced} - isLoading={isLoading} + isLoading={isQueryLoading} + allowQueryCancellation={allowQueryCancellation} /> )} @@ -954,13 +989,16 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ lines={lines} containerCSS={styles.bottomContainer} onErrorClick={onErrorClick} - runQuery={onQuerySubmit} + runQuery={() => { + onQuerySubmit(); + }} detectTimestamp={detectTimestamp} hideRunQueryText={hideRunQueryText} editorIsInline={editorIsInline} disableSubmitAction={disableSubmitAction} isSpaceReduced={isSpaceReduced} - isLoading={isLoading} + isLoading={isQueryLoading} + allowQueryCancellation={allowQueryCancellation} {...editorMessages} /> )} diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 0e4020f5c70fa..dd9fb37258ed3 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -717,7 +717,7 @@ export const QueryBarTopRow = React.memo( errors={props.textBasedLanguageModeErrors} warning={props.textBasedLanguageModeWarning} detectTimestamp={detectTimestamp} - onTextLangQuerySubmit={() => + onTextLangQuerySubmit={async () => onSubmit({ query: queryRef.current, dateRange: dateRangeRef.current, diff --git a/x-pack/packages/ml/anomaly_utils/anomaly_utils.ts b/x-pack/packages/ml/anomaly_utils/anomaly_utils.ts index 4865aed1e4e97..8ef9fee14a273 100644 --- a/x-pack/packages/ml/anomaly_utils/anomaly_utils.ts +++ b/x-pack/packages/ml/anomaly_utils/anomaly_utils.ts @@ -59,6 +59,10 @@ export interface MlEntityField { * Optional entity field operation */ operation?: MlEntityFieldOperation; + /** + * Optional cardinality of field + */ + cardinality?: number; } // List of function descriptions for which actual values from record level results should be displayed. diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts index c0c3c032a0e61..5c50e79c145aa 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/service_inventory/service_inventory.cy.ts @@ -115,7 +115,8 @@ describe('Service inventory', () => { }); }); - describe('Table search', () => { + // Skipping this until we enable the table search on the Service inventory view + describe.skip('Table search', () => { beforeEach(() => { cy.updateAdvancedSettings({ 'observability:apmEnableTableSearchBar': true, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 08e7a840b5dfb..ba55defaaf4d7 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -357,6 +357,7 @@ export function ServiceList({ const tableSearchBar: TableSearchBar = useMemo(() => { return { + isEnabled: false, fieldsToSearch: ['serviceName'], maxCountExceeded, onChangeSearchQuery, diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index 7d6307d32ffb8..ae14f63f8d72b 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -111,7 +111,7 @@ function UnoptimizedManagedTable(props: { const { core } = useApmPluginContext(); const isTableSearchBarEnabled = core.uiSettings.get( apmEnableTableSearchBar, - false + true ); const { diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx index 6924cbd13f1c7..28be7c63f22b3 100644 --- a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx @@ -14,7 +14,9 @@ import { screen, waitFor } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { SeverityFilter } from './severity_filter'; -describe('Severity form field', () => { +// FLAKY: https://github.com/elastic/kibana/issues/176336 +// FLAKY: https://github.com/elastic/kibana/issues/176337 +describe.skip('Severity form field', () => { const onChange = jest.fn(); let appMockRender: AppMockRenderer; const props = { diff --git a/x-pack/plugins/cases/public/components/app/routes.test.tsx b/x-pack/plugins/cases/public/components/app/routes.test.tsx index 9c435e2b163ba..91b4b1ef5227f 100644 --- a/x-pack/plugins/cases/public/components/app/routes.test.tsx +++ b/x-pack/plugins/cases/public/components/app/routes.test.tsx @@ -60,7 +60,8 @@ describe('Cases routes', () => { }); }); - describe('Case view', () => { + // FLAKY: https://github.com/elastic/kibana/issues/163263 + describe.skip('Case view', () => { it.each(getCaseViewPaths())( 'navigates to the cases view page for path: %s', async (path: string) => { @@ -84,7 +85,9 @@ describe('Cases routes', () => { ); }); - describe('Create case', () => { + // FLAKY: https://github.com/elastic/kibana/issues/175229 + // FLAKY: https://github.com/elastic/kibana/issues/175230 + describe.skip('Create case', () => { it('navigates to the create case page', () => { renderWithRouter(['/cases/create']); expect(screen.getByText('Create case')).toBeInTheDocument(); @@ -96,7 +99,9 @@ describe('Cases routes', () => { }); }); - describe('Cases settings', () => { + // FLAKY: https://github.com/elastic/kibana/issues/175231 + // FLAKY: https://github.com/elastic/kibana/issues/175232 + describe.skip('Cases settings', () => { it('navigates to the cases settings page', () => { renderWithRouter(['/cases/configure']); expect(screen.getByText('Settings')).toBeInTheDocument(); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx index 111b0a2113403..9ea517d45eea1 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/top_values/top_values.tsx @@ -116,10 +116,10 @@ export const TopValues: FC = ({ stats, fieldFormat, barColor, compressed, > {Array.isArray(topValues) ? topValues.map((value) => { - const fieldValue = - value.key_as_string ?? (value.key ? value.key.toString() : EMPTY_EXAMPLE); + const fieldValue = value.key_as_string ?? (value.key ? value.key.toString() : ''); + const displayValue = fieldValue ?? EMPTY_EXAMPLE; return ( - + = ({ stats, fieldFormat, barColor, compressed, /> {fieldName !== undefined && - fieldValue !== undefined && + displayValue !== undefined && onAddFilter !== undefined ? (
= ({ stats, fieldFormat, barColor, compressed, 'xpack.dataVisualizer.dataGrid.field.addFilterAriaLabel', { defaultMessage: 'Filter for {fieldName}: "{value}"', - values: { fieldName, value: fieldValue }, + values: { fieldName, value: displayValue }, } )} - data-test-subj={`dvFieldDataTopValuesAddFilterButton-${fieldName}-${fieldValue}`} + data-test-subj={`dvFieldDataTopValuesAddFilterButton-${fieldName}-${displayValue}`} style={{ minHeight: 'auto', minWidth: 'auto', @@ -172,10 +172,10 @@ export const TopValues: FC = ({ stats, fieldFormat, barColor, compressed, 'xpack.dataVisualizer.dataGrid.field.removeFilterAriaLabel', { defaultMessage: 'Filter out {fieldName}: "{value}"', - values: { fieldName, value: fieldValue }, + values: { fieldName, value: displayValue }, } )} - data-test-subj={`dvFieldDataTopValuesExcludeFilterButton-${fieldName}-${fieldValue}`} + data-test-subj={`dvFieldDataTopValuesExcludeFilterButton-${fieldName}-${displayValue}`} style={{ minHeight: 'auto', minWidth: 'auto', diff --git a/x-pack/plugins/data_visualizer/public/application/common/hooks/use_data.ts b/x-pack/plugins/data_visualizer/public/application/common/hooks/use_data.ts index 65c882ba551d9..9b85720fc1df4 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/hooks/use_data.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/hooks/use_data.ts @@ -14,9 +14,9 @@ import { mlTimefilterRefresh$, useTimefilter } from '@kbn/ml-date-picker'; import { merge } from 'rxjs'; import { RandomSampler } from '@kbn/ml-random-sampler-utils'; import { mapAndFlattenFilters } from '@kbn/data-plugin/public'; -import { Query } from '@kbn/es-query'; +import { buildEsQuery, Query } from '@kbn/es-query'; import { SearchQueryLanguage } from '@kbn/ml-query-utils'; -import { createMergedEsQuery } from '../../index_data_visualizer/utils/saved_search_utils'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { useDataDriftStateManagerContext } from '../../data_drift/use_state_manager'; import type { InitialSettings } from '../../data_drift/use_data_drift_result'; import { @@ -74,7 +74,7 @@ export const useData = ( () => { const searchQuery = searchString !== undefined && searchQueryLanguage !== undefined - ? { query: searchString, language: searchQueryLanguage } + ? ({ query: searchString, language: searchQueryLanguage } as Query) : undefined; const timefilterActiveBounds = timeRange ?? timefilter.getActiveBounds(); @@ -90,24 +90,24 @@ export const useData = ( runtimeFieldMap: selectedDataView.getRuntimeMappings(), }; - const refQuery = createMergedEsQuery( - searchQuery, + const refQuery = buildEsQuery( + selectedDataView, + searchQuery ?? [], mapAndFlattenFilters([ ...queryManager.filterManager.getFilters(), ...(referenceStateManager.filters ?? []), ]), - selectedDataView, - uiSettings + uiSettings ? getEsQueryConfig(uiSettings) : undefined ); - const compQuery = createMergedEsQuery( - searchQuery, + const compQuery = buildEsQuery( + selectedDataView, + searchQuery ?? [], mapAndFlattenFilters([ ...queryManager.filterManager.getFilters(), ...(comparisonStateManager.filters ?? []), ]), - selectedDataView, - uiSettings + uiSettings ? getEsQueryConfig(uiSettings) : undefined ); return { diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/use_data_drift_result.ts b/x-pack/plugins/data_visualizer/public/application/data_drift/use_data_drift_result.ts index 07b74677e8ea9..05f24bdcb7b68 100644 --- a/x-pack/plugins/data_visualizer/public/application/data_drift/use_data_drift_result.ts +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/use_data_drift_result.ts @@ -8,6 +8,7 @@ import { chunk, cloneDeep, flatten } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lastValueFrom } from 'rxjs'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { @@ -30,7 +31,7 @@ import { computeChi2PValue, type Histogram } from '@kbn/ml-chi2test'; import { mapAndFlattenFilters } from '@kbn/data-plugin/public'; import type { AggregationsMultiTermsBucketKeys } from '@elastic/elasticsearch/lib/api/types'; -import { createMergedEsQuery } from '../index_data_visualizer/utils/saved_search_utils'; +import { buildEsQuery } from '@kbn/es-query'; import { useDataVisualizerKibana } from '../kibana_context'; import { useDataDriftStateManagerContext } from './use_state_manager'; @@ -758,18 +759,18 @@ export const useFetchDataComparisonResult = ( const kqlQuery = searchString !== undefined && searchQueryLanguage !== undefined - ? { query: searchString, language: searchQueryLanguage } + ? ({ query: searchString, language: searchQueryLanguage } as Query) : undefined; const refDataQuery = getDataComparisonQuery({ - searchQuery: createMergedEsQuery( - kqlQuery, + searchQuery: buildEsQuery( + currentDataView, + kqlQuery ?? [], mapAndFlattenFilters([ ...queryManager.filterManager.getFilters(), ...(referenceStateManager.filters ?? []), ]), - currentDataView, - uiSettings + uiSettings ? getEsQueryConfig(uiSettings) : undefined ), datetimeField: currentDataView?.timeFieldName, runtimeFields, @@ -827,14 +828,14 @@ export const useFetchDataComparisonResult = ( setLoaded(0.25); const prodDataQuery = getDataComparisonQuery({ - searchQuery: createMergedEsQuery( - kqlQuery, + searchQuery: buildEsQuery( + currentDataView, + kqlQuery ?? [], mapAndFlattenFilters([ ...queryManager.filterManager.getFilters(), ...(comparisonStateManager.filters ?? []), ]), - currentDataView, - uiSettings + uiSettings ? getEsQueryConfig(uiSettings) : undefined ), datetimeField: currentDataView?.timeFieldName, runtimeFields, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx index 0faa236e30c6f..2f91dce01b456 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx @@ -675,7 +675,7 @@ export const IndexDataVisualizerESQL: FC = (dataVi // Query that has been typed, but has not submitted with cmd + enter const [localQuery, setLocalQuery] = useState({ esql: '' }); - const onQueryUpdate = (q?: AggregateQuery) => { + const onQueryUpdate = async (q?: AggregateQuery) => { // When user submits a new query // resets all current requests and other data if (cancelOverallStatsRequest) { diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index cc17387886071..5d8ebe9e44d57 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -8,6 +8,7 @@ import { css } from '@emotion/react'; import React, { FC, useEffect, useMemo, useState, useCallback, useRef } from 'react'; import type { Required } from 'utility-types'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { useEuiBreakpoint, @@ -21,7 +22,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { type Filter, FilterStateStore, type Query } from '@kbn/es-query'; +import { type Filter, FilterStateStore, type Query, buildEsQuery } from '@kbn/es-query'; import { generateFilters } from '@kbn/data-plugin/public'; import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { usePageUrlState, useUrlState } from '@kbn/ml-url-state'; @@ -62,7 +63,6 @@ import { DocumentCountContent } from '../../../common/components/document_count_ import { OMIT_FIELDS } from '../../../../../common/constants'; import { SearchPanel } from '../search_panel'; import { ActionsPanel } from '../actions_panel'; -import { createMergedEsQuery } from '../../utils/saved_search_utils'; import { DataVisualizerDataViewManagement } from '../data_view_management'; import type { GetAdditionalLinks } from '../../../common/components/results_links'; import { useDataVisualizerGridData } from '../../hooks/use_data_visualizer_grid_data'; @@ -389,14 +389,14 @@ export const IndexDataVisualizerView: FC = (dataVi language: searchQueryLanguage, }; - const combinedQuery = createMergedEsQuery( + const combinedQuery = buildEsQuery( + currentDataView, { query: searchString || '', language: searchQueryLanguage, }, data.query.filterManager.getFilters() ?? [], - currentDataView, - uiSettings + uiSettings ? getEsQueryConfig(uiSettings) : undefined ); setSearchParams({ diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_bar.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_bar.tsx index 3ad691bbe11ce..d0f6812c4e253 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_bar.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_bar.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import type { Filter, Query, TimeRange } from '@kbn/es-query'; +import { buildEsQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { isDefined } from '@kbn/ml-is-defined'; import { DataView } from '@kbn/data-views-plugin/common'; import type { SearchQueryLanguage } from '@kbn/ml-query-utils'; -import { createMergedEsQuery } from '../../utils/saved_search_utils'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { useDataVisualizerKibana } from '../../../kibana_context'; export const SearchPanelContent = ({ @@ -63,16 +63,17 @@ export const SearchPanelContent = ({ const searchHandler = ({ query, filters }: { query?: Query; filters?: Filter[] }) => { const mergedQuery = isDefined(query) ? query : searchInput; const mergedFilters = isDefined(filters) ? filters : queryManager.filterManager.getFilters(); + try { if (mergedFilters) { queryManager.filterManager.setFilters(mergedFilters); } - const combinedQuery = createMergedEsQuery( - mergedQuery, - queryManager.filterManager.getFilters() ?? [], + const combinedQuery = buildEsQuery( dataView, - uiSettings + mergedQuery ? [mergedQuery] : [], + queryManager.filterManager.getFilters() ?? [], + uiSettings ? getEsQueryConfig(uiSettings) : undefined ); setSearchParams({ diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts index b012d049ae04f..4570a2019af26 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts @@ -137,10 +137,11 @@ export const useDataVisualizerGridData = ( }); if (searchData === undefined || dataVisualizerListState.searchString !== '') { - if (dataVisualizerListState.filters) { + if (filterManager) { const globalFilters = filterManager?.getGlobalFilters(); - if (filterManager) filterManager.setFilters(dataVisualizerListState.filters); + if (dataVisualizerListState.filters) + filterManager.setFilters(dataVisualizerListState.filters); if (globalFilters) filterManager?.addFilters(globalFilters); } return { @@ -169,6 +170,7 @@ export const useDataVisualizerGridData = ( currentFilters, }), lastRefresh, + data.query.filterManager, ]); const _timeBuckets = useTimeBuckets(); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts index c43483a34e34c..2b25a5e8d2b8c 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts @@ -5,14 +5,10 @@ * 2.0. */ -import { - getQueryFromSavedSearchObject, - createMergedEsQuery, - getEsQueryFromSavedSearch, -} from './saved_search_utils'; +import { getQueryFromSavedSearchObject, getEsQueryFromSavedSearch } from './saved_search_utils'; import type { SavedSearchSavedObject } from '../../../../common/types'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; -import { type Filter, FilterStateStore } from '@kbn/es-query'; +import { FilterStateStore } from '@kbn/es-query'; import { stubbedSavedObjectIndexPattern } from '@kbn/data-views-plugin/common/data_view.stub'; import { DataView } from '@kbn/data-views-plugin/public'; import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks'; @@ -217,80 +213,6 @@ describe('getQueryFromSavedSearchObject()', () => { }); }); -describe('createMergedEsQuery()', () => { - const luceneQuery = { - query: 'responsetime:>50', - language: 'lucene', - }; - const kqlQuery = { - query: 'responsetime > 49', - language: 'kuery', - }; - const mockFilters: Filter[] = [ - { - meta: { - index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0', - negate: false, - disabled: false, - alias: null, - type: 'phrase', - key: 'airline', - params: { - query: 'ASA', - }, - }, - query: { - match: { - airline: { - query: 'ASA', - type: 'phrase', - }, - }, - }, - $state: { - store: 'appState' as FilterStateStore, - }, - }, - ]; - - it('return formatted ES bool query with both the original query and filters combined', () => { - expect(createMergedEsQuery(luceneQuery, mockFilters)).toEqual({ - bool: { - filter: [{ match_phrase: { airline: { query: 'ASA' } } }], - must: [{ query_string: { query: 'responsetime:>50' } }], - must_not: [], - should: [], - }, - }); - expect(createMergedEsQuery(kqlQuery, mockFilters)).toEqual({ - bool: { - filter: [{ match_phrase: { airline: { query: 'ASA' } } }], - minimum_should_match: 1, - must_not: [], - should: [{ range: { responsetime: { gt: '49' } } }], - }, - }); - }); - it('return formatted ES bool query without filters ', () => { - expect(createMergedEsQuery(luceneQuery)).toEqual({ - bool: { - filter: [], - must: [{ query_string: { query: 'responsetime:>50' } }], - must_not: [], - should: [], - }, - }); - expect(createMergedEsQuery(kqlQuery)).toEqual({ - bool: { - filter: [], - minimum_should_match: 1, - must_not: [], - should: [{ range: { responsetime: { gt: '49' } } }], - }, - }); - }); -}); - describe('getEsQueryFromSavedSearch()', () => { it('return undefined if saved search is not provided', () => { expect( diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts index 04bc52bf08057..3ecc8a3a7a3d8 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts @@ -9,22 +9,15 @@ // `x-pack/plugins/apm/public/components/app/correlations/progress_controls.tsx` import { cloneDeep } from 'lodash'; import { IUiSettingsClient } from '@kbn/core/public'; -import { - fromKueryExpression, - toElasticsearchQuery, - buildQueryFromFilters, - buildEsQuery, - Query, - Filter, - AggregateQuery, -} from '@kbn/es-query'; +import { buildEsQuery, Query, Filter } from '@kbn/es-query'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { DataView } from '@kbn/data-views-plugin/public'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; -import { getEsQueryConfig, isQuery, SearchSource } from '@kbn/data-plugin/common'; +import { getEsQueryConfig, SearchSource } from '@kbn/data-plugin/common'; import { FilterManager, mapAndFlattenFilters } from '@kbn/data-plugin/public'; import { getDefaultDSLQuery } from '@kbn/ml-query-utils'; -import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '@kbn/ml-query-utils'; +import { SearchQueryLanguage } from '@kbn/ml-query-utils'; +import { isDefined } from '@kbn/ml-is-defined'; import { isSavedSearchSavedObject, SavedSearchSavedObject } from '../../../../common/types'; /** @@ -59,53 +52,8 @@ export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObjec return parsed; } -/** - * Create an Elasticsearch query that combines both lucene/kql query string and filters - * Should also form a valid query if only the query or filters is provided - */ -export function createMergedEsQuery( - query?: Query | AggregateQuery | undefined, - filters?: Filter[], - dataView?: DataView, - uiSettings?: IUiSettingsClient -) { - let combinedQuery = getDefaultDSLQuery() as QueryDslQueryContainer; - - if (isQuery(query) && query.language === SEARCH_QUERY_LANGUAGE.KUERY) { - const ast = fromKueryExpression(query.query); - if (query.query !== '') { - combinedQuery = toElasticsearchQuery(ast, dataView); - } - if (combinedQuery.bool !== undefined) { - const filterQuery = buildQueryFromFilters(filters, dataView); - - if (!Array.isArray(combinedQuery.bool.filter)) { - combinedQuery.bool.filter = - combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; - } - - if (!Array.isArray(combinedQuery.bool.must_not)) { - combinedQuery.bool.must_not = - combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; - } - - combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; - combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; - } - } else { - combinedQuery = buildEsQuery( - dataView, - query ? [query] : [], - filters ? filters : [], - uiSettings ? getEsQueryConfig(uiSettings) : undefined - ); - } - - return combinedQuery; -} - -function getSavedSearchSource(savedSearch: SavedSearch) { - return savedSearch && +function getSavedSearchSource(savedSearch?: SavedSearch | null) { + return isDefined(savedSearch) && 'searchSource' in savedSearch && savedSearch?.searchSource instanceof SearchSource ? savedSearch.searchSource @@ -131,11 +79,15 @@ export function getEsQueryFromSavedSearch({ filters?: Filter[]; filterManager?: FilterManager; }) { - if (!dataView || !savedSearch) return; + if (!dataView && !savedSearch) return; const userQuery = query; const userFilters = filters; + if (filterManager && userFilters) { + filterManager.addFilters(userFilters); + } + const savedSearchSource = getSavedSearchSource(savedSearch); // If saved search has a search source with nested parent @@ -146,8 +98,8 @@ export function getEsQueryFromSavedSearch({ // Flattened query from search source may contain a clause that narrows the time range // which might interfere with global time pickers so we need to remove const savedQuery = - cloneDeep(savedSearch.searchSource.getSearchRequestBody()?.query) ?? getDefaultDSLQuery(); - const timeField = savedSearch.searchSource.getField('index')?.timeFieldName; + cloneDeep(savedSearchSource.getSearchRequestBody()?.query) ?? getDefaultDSLQuery(); + const timeField = savedSearchSource.getField('index')?.timeFieldName; if (Array.isArray(savedQuery.bool.filter) && timeField !== undefined) { savedQuery.bool.filter = savedQuery.bool.filter.filter( @@ -155,6 +107,7 @@ export function getEsQueryFromSavedSearch({ !(c.hasOwnProperty('range') && c.range?.hasOwnProperty(timeField)) ); } + return { searchQuery: savedQuery, searchString: userQuery.query, @@ -163,39 +116,38 @@ export function getEsQueryFromSavedSearch({ } // If no saved search available, use user's query and filters - if (!savedSearch && userQuery) { - if (filterManager && userFilters) filterManager.addFilters(userFilters); - - const combinedQuery = createMergedEsQuery( - userQuery, - Array.isArray(userFilters) ? userFilters : [], + if ( + !savedSearch && + (userQuery || userFilters || (filterManager && filterManager.getGlobalFilters()?.length > 0)) + ) { + const combinedQuery = buildEsQuery( dataView, - uiSettings + userQuery ?? [], + filterManager?.getFilters() ?? [], + uiSettings ? getEsQueryConfig(uiSettings) : undefined ); return { searchQuery: combinedQuery, - searchString: userQuery.query, - queryLanguage: userQuery.language as SearchQueryLanguage, + searchString: userQuery?.query ?? '', + queryLanguage: (userQuery?.language ?? 'kuery') as SearchQueryLanguage, }; } // If saved search available, merge saved search with the latest user query or filters // which might differ from extracted saved search data if (savedSearchSource) { - const globalFilters = filterManager?.getGlobalFilters(); // FIXME: Add support for AggregateQuery type #150091 const currentQuery = userQuery ?? (savedSearchSource.getField('query') as Query); const currentFilters = userFilters ?? mapAndFlattenFilters(savedSearchSource.getField('filter') as Filter[]); - if (filterManager) filterManager.setFilters(currentFilters); - if (globalFilters) filterManager?.addFilters(globalFilters); + if (filterManager) filterManager.addFilters(currentFilters); - const combinedQuery = createMergedEsQuery( + const combinedQuery = buildEsQuery( + dataView, currentQuery, filterManager ? filterManager?.getFilters() : currentFilters, - dataView, - uiSettings + uiSettings ? getEsQueryConfig(uiSettings) : undefined ); return { diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts index 475862664c336..803fcbf169935 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts @@ -15,7 +15,11 @@ import type { LensPluginStartDependencies } from '../../../plugin'; import type { DatasourceMap, VisualizationMap } from '../../../types'; import { suggestionsApi } from '../../../lens_suggestions_api'; -export const getQueryColumns = async (query: AggregateQuery, deps: LensPluginStartDependencies) => { +export const getQueryColumns = async ( + query: AggregateQuery, + deps: LensPluginStartDependencies, + abortController?: AbortController +) => { // Fetching only columns for ES|QL for performance reasons with limit 0 // Important note: ES doesnt return the warnings for 0 limit, // I am skipping them in favor of performance now @@ -24,7 +28,12 @@ export const getQueryColumns = async (query: AggregateQuery, deps: LensPluginSta if ('esql' in performantQuery && performantQuery.esql) { performantQuery.esql = `${performantQuery.esql} | limit 0`; } - const table = await fetchFieldsFromESQL(performantQuery, deps.expressions); + const table = await fetchFieldsFromESQL( + performantQuery, + deps.expressions, + undefined, + abortController + ); return table?.columns; }; @@ -34,7 +43,8 @@ export const getSuggestions = async ( datasourceMap: DatasourceMap, visualizationMap: VisualizationMap, adHocDataViews: DataViewSpec[], - setErrors: (errors: Error[]) => void + setErrors: (errors: Error[]) => void, + abortController?: AbortController ) => { try { let indexPattern = ''; @@ -55,7 +65,7 @@ export const getSuggestions = async ( if (dataView.fields.getByName('@timestamp')?.type === 'date' && !dataViewSpec) { dataView.timeFieldName = '@timestamp'; } - const columns = await getQueryColumns(query, deps); + const columns = await getQueryColumns(query, deps, abortController); const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx index 834929d4ca2a5..3e2bf4f60aa2b 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx @@ -279,14 +279,15 @@ export function LensEditConfigurationFlyout({ const adHocDataViews = Object.values(attributes.state.adHocDataViews ?? {}); const runQuery = useCallback( - async (q) => { + async (q, abortController) => { const attrs = await getSuggestions( q, startDependencies, datasourceMap, visualizationMap, adHocDataViews, - setErrors + setErrors, + abortController ); if (attrs) { setCurrentAttributes?.(attrs); @@ -442,13 +443,13 @@ export function LensEditConfigurationFlyout({ hideMinimizeButton editorIsInline hideRunQueryText - disableSubmitAction={isEqual(query, prevQuery.current)} - onTextLangQuerySubmit={(q) => { + onTextLangQuerySubmit={async (q, a) => { if (q) { - runQuery(q); + await runQuery(q, a); } }} isDisabled={false} + allowQueryCancellation={true} /> )} diff --git a/x-pack/plugins/maps/public/classes/layers/invalid_layer.ts b/x-pack/plugins/maps/public/classes/layers/invalid_layer.ts new file mode 100644 index 0000000000000..eed2ab37b32e0 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/invalid_layer.ts @@ -0,0 +1,89 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import { i18n } from '@kbn/i18n'; +import { LayerDescriptor } from '../../../common/descriptor_types'; +import { AbstractLayer } from './layer'; +import { AbstractSource } from '../sources/source'; +import { IStyle } from '../styles/style'; + +class InvalidSource extends AbstractSource { + constructor(id?: string) { + super({ + id, + type: 'INVALID', + }); + } +} + +export class InvalidLayer extends AbstractLayer { + private readonly _error: Error; + private readonly _style: IStyle; + + constructor(layerDescriptor: LayerDescriptor, error: Error) { + super({ + layerDescriptor, + source: new InvalidSource(layerDescriptor.sourceDescriptor?.id), + }); + this._error = error; + this._style = { + getType() { + return 'INVALID'; + }, + renderEditor() { + return null; + }, + }; + } + + hasErrors() { + return true; + } + + getErrors() { + return [ + { + title: i18n.translate('xpack.maps.invalidLayer.errorTitle', { + defaultMessage: `Unable to create layer`, + }), + body: this._error.message, + }, + ]; + } + + getStyleForEditing() { + return this._style; + } + + getStyle() { + return this._style; + } + + getCurrentStyle() { + return this._style; + } + + getMbLayerIds() { + return []; + } + + ownsMbLayerId() { + return false; + } + + ownsMbSourceId() { + return false; + } + + syncLayerWithMB() {} + + getLayerTypeIconName() { + return 'error'; + } +} diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 1fccaf7f6d0a5..aa39cf017eb0d 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -245,7 +245,7 @@ export class AbstractLayer implements ILayer { const sourceDisplayName = source ? await source.getDisplayName() : await this.getSource().getDisplayName(); - return sourceDisplayName || `Layer ${this._descriptor.id}`; + return sourceDisplayName || this._descriptor.id; } async getAttributions(): Promise { diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index ec035ac1c5623..78950c1ab2e7f 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -23,6 +23,7 @@ import { import { VectorStyle } from '../classes/styles/vector/vector_style'; import { isLayerGroup, LayerGroup } from '../classes/layers/layer_group'; import { HeatmapLayer } from '../classes/layers/heatmap_layer'; +import { InvalidLayer } from '../classes/layers/invalid_layer'; import { getTimeFilter } from '../kibana_services'; import { getChartsPaletteServiceGetColor } from '../reducers/non_serializable_instances'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from '../reducers/copy_persistent_state'; @@ -76,54 +77,58 @@ export function createLayerInstance( customIcons: CustomIcon[], chartsPaletteServiceGetColor?: (value: string) => string | null ): ILayer { - if (layerDescriptor.type === LAYER_TYPE.LAYER_GROUP) { - return new LayerGroup({ layerDescriptor: layerDescriptor as LayerGroupDescriptor }); - } + try { + if (layerDescriptor.type === LAYER_TYPE.LAYER_GROUP) { + return new LayerGroup({ layerDescriptor: layerDescriptor as LayerGroupDescriptor }); + } - const source: ISource = createSourceInstance(layerDescriptor.sourceDescriptor); - switch (layerDescriptor.type) { - case LAYER_TYPE.RASTER_TILE: - return new RasterTileLayer({ layerDescriptor, source: source as IRasterSource }); - case LAYER_TYPE.EMS_VECTOR_TILE: - return new EmsVectorTileLayer({ - layerDescriptor: layerDescriptor as EMSVectorTileLayerDescriptor, - source: source as EMSTMSSource, - }); - case LAYER_TYPE.HEATMAP: - return new HeatmapLayer({ - layerDescriptor: layerDescriptor as HeatmapLayerDescriptor, - source: source as ESGeoGridSource, - }); - case LAYER_TYPE.GEOJSON_VECTOR: - return new GeoJsonVectorLayer({ - layerDescriptor: layerDescriptor as VectorLayerDescriptor, - source: source as IVectorSource, - joins: createJoinInstances( - layerDescriptor as VectorLayerDescriptor, - source as IVectorSource - ), - customIcons, - chartsPaletteServiceGetColor, - }); - case LAYER_TYPE.BLENDED_VECTOR: - return new BlendedVectorLayer({ - layerDescriptor: layerDescriptor as VectorLayerDescriptor, - source: source as IVectorSource, - customIcons, - chartsPaletteServiceGetColor, - }); - case LAYER_TYPE.MVT_VECTOR: - return new MvtVectorLayer({ - layerDescriptor: layerDescriptor as VectorLayerDescriptor, - source: source as IVectorSource, - joins: createJoinInstances( - layerDescriptor as VectorLayerDescriptor, - source as IVectorSource - ), - customIcons, - }); - default: - throw new Error(`Unrecognized layerType ${layerDescriptor.type}`); + const source: ISource = createSourceInstance(layerDescriptor.sourceDescriptor); + switch (layerDescriptor.type) { + case LAYER_TYPE.RASTER_TILE: + return new RasterTileLayer({ layerDescriptor, source: source as IRasterSource }); + case LAYER_TYPE.EMS_VECTOR_TILE: + return new EmsVectorTileLayer({ + layerDescriptor: layerDescriptor as EMSVectorTileLayerDescriptor, + source: source as EMSTMSSource, + }); + case LAYER_TYPE.HEATMAP: + return new HeatmapLayer({ + layerDescriptor: layerDescriptor as HeatmapLayerDescriptor, + source: source as ESGeoGridSource, + }); + case LAYER_TYPE.GEOJSON_VECTOR: + return new GeoJsonVectorLayer({ + layerDescriptor: layerDescriptor as VectorLayerDescriptor, + source: source as IVectorSource, + joins: createJoinInstances( + layerDescriptor as VectorLayerDescriptor, + source as IVectorSource + ), + customIcons, + chartsPaletteServiceGetColor, + }); + case LAYER_TYPE.BLENDED_VECTOR: + return new BlendedVectorLayer({ + layerDescriptor: layerDescriptor as VectorLayerDescriptor, + source: source as IVectorSource, + customIcons, + chartsPaletteServiceGetColor, + }); + case LAYER_TYPE.MVT_VECTOR: + return new MvtVectorLayer({ + layerDescriptor: layerDescriptor as VectorLayerDescriptor, + source: source as IVectorSource, + joins: createJoinInstances( + layerDescriptor as VectorLayerDescriptor, + source as IVectorSource + ), + customIcons, + }); + default: + throw new Error(`Unrecognized layerType ${layerDescriptor.type}`); + } + } catch (error) { + return new InvalidLayer(layerDescriptor, error); } } diff --git a/x-pack/plugins/ml/public/application/services/field_format_service.ts b/x-pack/plugins/ml/public/application/services/field_format_service.ts index a43e134f84cfb..8519a13e7d7bc 100644 --- a/x-pack/plugins/ml/public/application/services/field_format_service.ts +++ b/x-pack/plugins/ml/public/application/services/field_format_service.ts @@ -8,16 +8,20 @@ import { mlFunctionToESAggregation } from '../../../common/util/job_utils'; import { getDataViewById, getDataViewIdFromName } from '../util/index_utils'; import { mlJobService } from './job_service'; +import type { MlIndexUtils } from '../util/index_service'; +import type { MlApiServices } from './ml_api_service'; type FormatsByJobId = Record; type IndexPatternIdsByJob = Record; // Service for accessing FieldFormat objects configured for a Kibana data view // for use in formatting the actual and typical values from anomalies. -class FieldFormatService { +export class FieldFormatService { indexPatternIdsByJob: IndexPatternIdsByJob = {}; formatsByJob: FormatsByJobId = {}; + constructor(private mlApiServices?: MlApiServices, private mlIndexUtils?: MlIndexUtils) {} + // Populate the service with the FieldFormats for the list of jobs with the // specified IDs. List of Kibana data views is passed, with a title // attribute set in each pattern which will be compared to the indices @@ -32,10 +36,17 @@ class FieldFormatService { ( await Promise.all( jobIds.map(async (jobId) => { - const jobObj = mlJobService.getJob(jobId); + const getDataViewId = this.mlIndexUtils?.getDataViewIdFromName ?? getDataViewIdFromName; + let jobObj; + if (this.mlApiServices) { + const { jobs } = await this.mlApiServices.getJobs({ jobId }); + jobObj = jobs[0]; + } else { + jobObj = mlJobService.getJob(jobId); + } return { jobId, - dataViewId: await getDataViewIdFromName(jobObj.datafeed_config.indices.join(',')), + dataViewId: await getDataViewId(jobObj.datafeed_config!.indices.join(',')), }; }) ) @@ -68,41 +79,40 @@ class FieldFormatService { } } - getFormatsForJob(jobId: string): Promise { - return new Promise((resolve, reject) => { - const jobObj = mlJobService.getJob(jobId); - const detectors = jobObj.analysis_config.detectors || []; - const formatsByDetector: any[] = []; + async getFormatsForJob(jobId: string): Promise { + let jobObj; + const getDataView = this.mlIndexUtils?.getDataViewById ?? getDataViewById; + if (this.mlApiServices) { + const { jobs } = await this.mlApiServices.getJobs({ jobId }); + jobObj = jobs[0]; + } else { + jobObj = mlJobService.getJob(jobId); + } + const detectors = jobObj.analysis_config.detectors || []; + const formatsByDetector: any[] = []; - const dataViewId = this.indexPatternIdsByJob[jobId]; - if (dataViewId !== undefined) { - // Load the full data view configuration to obtain the formats of each field. - getDataViewById(dataViewId) - .then((dataView) => { - // Store the FieldFormat for each job by detector_index. - const fieldList = dataView.fields; - detectors.forEach((dtr) => { - const esAgg = mlFunctionToESAggregation(dtr.function); - // distinct_count detectors should fall back to the default - // formatter as the values are just counts. - if (dtr.field_name !== undefined && esAgg !== 'cardinality') { - const field = fieldList.getByName(dtr.field_name); - if (field !== undefined) { - formatsByDetector[dtr.detector_index!] = dataView.getFormatterForField(field); - } - } - }); + const dataViewId = this.indexPatternIdsByJob[jobId]; + if (dataViewId !== undefined) { + // Load the full data view configuration to obtain the formats of each field. + const dataView = await getDataView(dataViewId); + // Store the FieldFormat for each job by detector_index. + const fieldList = dataView.fields; + detectors.forEach((dtr) => { + const esAgg = mlFunctionToESAggregation(dtr.function); + // distinct_count detectors should fall back to the default + // formatter as the values are just counts. + if (dtr.field_name !== undefined && esAgg !== 'cardinality') { + const field = fieldList.getByName(dtr.field_name); + if (field !== undefined) { + formatsByDetector[dtr.detector_index!] = dataView.getFormatterForField(field); + } + } + }); + } - resolve(formatsByDetector); - }) - .catch((err) => { - reject(err); - }); - } else { - resolve(formatsByDetector); - } - }); + return formatsByDetector; } } export const mlFieldFormatService = new FieldFormatService(); +export type MlFieldFormatService = typeof mlFieldFormatService; diff --git a/x-pack/plugins/ml/public/application/services/field_format_service_factory.ts b/x-pack/plugins/ml/public/application/services/field_format_service_factory.ts new file mode 100644 index 0000000000000..daefab69154c5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/field_format_service_factory.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type MlFieldFormatService, FieldFormatService } from './field_format_service'; +import type { MlIndexUtils } from '../util/index_service'; +import type { MlApiServices } from './ml_api_service'; + +export function fieldFormatServiceFactory( + mlApiServices: MlApiServices, + mlIndexUtils: MlIndexUtils +): MlFieldFormatService { + return new FieldFormatService(mlApiServices, mlIndexUtils); +} diff --git a/x-pack/plugins/ml/public/application/services/forecast_service.d.ts b/x-pack/plugins/ml/public/application/services/forecast_service.d.ts index 0bfd8f56385d6..55df37b2307da 100644 --- a/x-pack/plugins/ml/public/application/services/forecast_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/forecast_service.d.ts @@ -32,3 +32,5 @@ export const mlForecastService: { getForecastDateRange: (job: Job, forecastId: string) => Promise; }; + +export type MlForecastService = typeof mlForecastService; diff --git a/x-pack/plugins/ml/public/application/services/forecast_service_provider.ts b/x-pack/plugins/ml/public/application/services/forecast_service_provider.ts new file mode 100644 index 0000000000000..c776a79a6f475 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/forecast_service_provider.ts @@ -0,0 +1,395 @@ +/* + * 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. + */ + +// Service for carrying out requests to run ML forecasts and to obtain +// data on forecasts that have been performed. +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { get, find, each } from 'lodash'; +import { map } from 'rxjs/operators'; +import type { MlApiServices } from './ml_api_service'; +import type { Job } from '../../../common/types/anomaly_detection_jobs'; + +export interface AggType { + avg: string; + max: string; + min: string; +} + +// TODO Consolidate with legacy code in +// `x-pack/plugins/ml/public/application/services/forecast_service.js` and +// `x-pack/plugins/ml/public/application/services/forecast_service.d.ts`. +export function forecastServiceProvider(mlApiServices: MlApiServices) { + return { + // Gets a basic summary of the most recently run forecasts for the specified + // job, with results at or later than the supplied timestamp. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a forecasts property, which is an array of objects + // containing id, earliest and latest keys. + getForecastsSummary(job: Job, query: any, earliestMs: number, maxResults: any) { + return new Promise((resolve, reject) => { + const obj: { success: boolean; forecasts: Record } = { + success: true, + forecasts: [], + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, result type and earliest time, plus + // the additional query if supplied. + const filterCriteria = [ + { + term: { result_type: 'model_forecast_request_stats' }, + }, + { + term: { job_id: job.job_id }, + }, + { + range: { + timestamp: { + gte: earliestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + filterCriteria.push(query); + } + + mlApiServices.results + .anomalySearch( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: maxResults, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + sort: [{ forecast_create_timestamp: { order: 'desc' } }], + }, + }, + [job.job_id] + ) + .then((resp) => { + if (resp.hits.total.value > 0) { + obj.forecasts = resp.hits.hits.map((hit) => hit._source); + } + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + // Obtains the earliest and latest timestamps for the forecast data from + // the forecast with the specified ID. + // Returned response contains earliest and latest properties which are the + // timestamps of the first and last model_forecast results. + getForecastDateRange(job: Job, forecastId: string) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + earliest: null, + latest: null, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, forecast ID, result type and time range. + const filterCriteria = [ + { + query_string: { + query: 'result_type:model_forecast', + analyze_wildcard: true, + }, + }, + { + term: { job_id: job.job_id }, + }, + { + term: { forecast_id: forecastId }, + }, + ]; + + // TODO - add in criteria for detector index and entity fields (by, over, partition) + // once forecasting with these parameters is supported. + + mlApiServices.results + .anomalySearch( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: 0, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + aggs: { + earliest: { + min: { + field: 'timestamp', + }, + }, + latest: { + max: { + field: 'timestamp', + }, + }, + }, + }, + }, + [job.job_id] + ) + .then((resp) => { + obj.earliest = get(resp, 'aggregations.earliest.value', null); + obj.latest = get(resp, 'aggregations.latest.value', null); + if (obj.earliest === null || obj.latest === null) { + reject(resp); + } else { + resolve(obj); + } + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + // Obtains the requested forecast model data for the forecast with the specified ID. + getForecastData( + job: Job, + detectorIndex: number, + forecastId: string, + entityFields: any, + earliestMs: number, + latestMs: number, + intervalMs: number, + aggType?: AggType + ) { + // Extract the partition, by, over fields on which to filter. + const criteriaFields = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (detector.partition_field_name !== undefined) { + const partitionEntity = find(entityFields, { fieldName: detector.partition_field_name }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); + } + } + + if (detector.over_field_name !== undefined) { + const overEntity = find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); + } + } + + if (detector.by_field_name !== undefined) { + const byEntity = find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); + } + } + + const obj: { success: boolean; results: Record } = { + success: true, + results: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, forecast ID, detector index, result type and time range. + const filterCriteria: estypes.QueryDslQueryContainer[] = [ + { + query_string: { + query: 'result_type:model_forecast', + analyze_wildcard: true, + }, + }, + { + term: { job_id: job.job_id }, + }, + { + term: { forecast_id: forecastId }, + }, + { + term: { detector_index: detectorIndex }, + }, + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + // Add in term queries for each of the specified criteria. + each(criteriaFields, (criteria) => { + filterCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); + + // If an aggType object has been passed in, use it. + // Otherwise default to avg, min and max aggs for the + // forecast prediction, upper and lower + const forecastAggs = + aggType === undefined + ? { avg: 'avg', max: 'max', min: 'min' } + : { + avg: aggType.avg, + max: aggType.max, + min: aggType.min, + }; + + return mlApiServices.results + .anomalySearch$( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: 0, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + aggs: { + times: { + date_histogram: { + field: 'timestamp', + fixed_interval: `${intervalMs}ms`, + min_doc_count: 1, + }, + aggs: { + prediction: { + [forecastAggs.avg]: { + field: 'forecast_prediction', + }, + }, + forecastUpper: { + [forecastAggs.max]: { + field: 'forecast_upper', + }, + }, + forecastLower: { + [forecastAggs.min]: { + field: 'forecast_lower', + }, + }, + }, + }, + }, + }, + }, + [job.job_id] + ) + .pipe( + map((resp) => { + const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); + each(aggregationsByTime, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = { + prediction: get(dataForTime, ['prediction', 'value']), + forecastUpper: get(dataForTime, ['forecastUpper', 'value']), + forecastLower: get(dataForTime, ['forecastLower', 'value']), + }; + }); + + return obj; + }) + ); + }, + // Runs a forecast + runForecast(jobId: string, duration?: string) { + // eslint-disable-next-line no-console + console.log('ML forecast service run forecast with duration:', duration); + return new Promise((resolve, reject) => { + mlApiServices + .forecast({ + jobId, + duration, + }) + .then((resp) => { + resolve(resp); + }) + .catch((err) => { + reject(err); + }); + }); + }, + // Gets stats for a forecast that has been run on the specified job. + // Returned response contains a stats property, including + // forecast_progress (a value from 0 to 1), + // and forecast_status ('finished' when complete) properties. + getForecastRequestStats(job: Job, forecastId: string) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + stats: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, result type and earliest time. + const filterCriteria = [ + { + query_string: { + query: 'result_type:model_forecast_request_stats', + analyze_wildcard: true, + }, + }, + { + term: { job_id: job.job_id }, + }, + { + term: { forecast_id: forecastId }, + }, + ]; + + mlApiServices.results + .anomalySearch( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: 1, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + }, + }, + [job.job_id] + ) + .then((resp) => { + if (resp.hits.total.value > 0) { + obj.stats = resp.hits.hits[0]._source; + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + }; +} + +export type MlForecastService = ReturnType; diff --git a/x-pack/plugins/ml/public/application/services/results_service/index.ts b/x-pack/plugins/ml/public/application/services/results_service/index.ts index 4fe6b7add2a6b..883b54dd73e72 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/index.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { useMemo } from 'react'; import { resultsServiceRxProvider } from './result_service_rx'; import { resultsServiceProvider } from './results_service'; import { ml, MlApiServices } from '../ml_api_service'; +import { useMlKibana } from '../../contexts/kibana'; export type MlResultsService = typeof mlResultsService; @@ -29,3 +31,14 @@ export function mlResultsServiceProvider(mlApiServices: MlApiServices) { ...resultsServiceRxProvider(mlApiServices), }; } + +export function useMlResultsService(): MlResultsService { + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const resultsService = useMemo(() => mlResultsServiceProvider(mlApiServices), [mlApiServices]); + return resultsService; +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx index c707bbee2c5b9..493e74755588b 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/plot_function_controls/plot_function_controls.tsx @@ -9,9 +9,11 @@ import React, { useCallback, useEffect } from 'react'; import { EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils'; +import { MlJob } from '@elastic/elasticsearch/lib/api/types'; import { mlJobService } from '../../../services/job_service'; import { getFunctionDescription, isMetricDetector } from '../../get_function_description'; import { useToastNotificationService } from '../../../services/toast_notification_service'; +import { useMlResultsService } from '../../../services/results_service'; import type { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs'; const plotByFunctionOptions = [ @@ -36,6 +38,7 @@ const plotByFunctionOptions = [ ]; export const PlotByFunctionControls = ({ functionDescription, + job, setFunctionDescription, selectedDetectorIndex, selectedJobId, @@ -43,6 +46,7 @@ export const PlotByFunctionControls = ({ entityControlsCount, }: { functionDescription: undefined | string; + job?: CombinedJob | MlJob; setFunctionDescription: (func: string) => void; selectedDetectorIndex: number; selectedJobId: string; @@ -50,6 +54,7 @@ export const PlotByFunctionControls = ({ entityControlsCount: number; }) => { const toastNotificationService = useToastNotificationService(); + const mlResultsService = useMlResultsService(); const getFunctionDescriptionToPlot = useCallback( async ( @@ -65,18 +70,19 @@ export const PlotByFunctionControls = ({ selectedJobId: _selectedJobId, selectedJob: _selectedJob, }, - toastNotificationService + toastNotificationService, + mlResultsService ); setFunctionDescription(functionToPlot); }, - [setFunctionDescription, toastNotificationService] + [setFunctionDescription, toastNotificationService, mlResultsService] ); useEffect(() => { if (functionDescription !== undefined) { return; } - const selectedJob = mlJobService.getJob(selectedJobId); + const selectedJob = (job ?? mlJobService.getJob(selectedJobId)) as CombinedJob; // if no controls, it's okay to fetch // if there are series controls, only fetch if user has selected something const validEntities = diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx index 23bc2f80eb1a8..666d56f15fbc8 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/series_controls/series_controls.tsx @@ -12,9 +12,10 @@ import { debounce } from 'lodash'; import { lastValueFrom } from 'rxjs'; import { useStorage } from '@kbn/ml-local-storage'; import type { MlEntityFieldType } from '@kbn/ml-anomaly-utils'; +import { MlJob } from '@elastic/elasticsearch/lib/api/types'; import { EntityControl } from '../entity_control'; import { mlJobService } from '../../../services/job_service'; -import { Detector, JobId } from '../../../../../common/types/anomaly_detection_jobs'; +import { CombinedJob, Detector, JobId } from '../../../../../common/types/anomaly_detection_jobs'; import { useMlKibana } from '../../../contexts/kibana'; import { APP_STATE_ACTION } from '../../timeseriesexplorer_constants'; import { @@ -67,12 +68,13 @@ const getDefaultFieldConfig = ( }; interface SeriesControlsProps { - selectedDetectorIndex: number; - selectedJobId: JobId; - bounds: any; appStateHandler: Function; + bounds: any; + functionDescription?: string; + job?: CombinedJob | MlJob; + selectedDetectorIndex: number; selectedEntities: Record; - functionDescription: string; + selectedJobId: JobId; setFunctionDescription: (func: string) => void; } @@ -80,13 +82,14 @@ interface SeriesControlsProps { * Component for handling the detector and entities controls. */ export const SeriesControls: FC = ({ - bounds, - selectedDetectorIndex, - selectedJobId, appStateHandler, + bounds, children, - selectedEntities, functionDescription, + job, + selectedDetectorIndex, + selectedEntities, + selectedJobId, setFunctionDescription, }) => { const { @@ -97,7 +100,11 @@ export const SeriesControls: FC = ({ }, } = useMlKibana(); - const selectedJob = useMemo(() => mlJobService.getJob(selectedJobId), [selectedJobId]); + const selectedJob: CombinedJob | MlJob = useMemo( + () => job ?? mlJobService.getJob(selectedJobId), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedJobId] + ); const isModelPlotEnabled = !!selectedJob.model_plot_config?.enabled; @@ -108,11 +115,17 @@ export const SeriesControls: FC = ({ index: number; detector_description: Detector['detector_description']; }> = useMemo(() => { - return getViewableDetectors(selectedJob); + return getViewableDetectors(selectedJob as CombinedJob); }, [selectedJob]); const entityControls = useMemo(() => { - return getControlsForDetector(selectedDetectorIndex, selectedEntities, selectedJobId); + return getControlsForDetector( + selectedDetectorIndex, + selectedEntities, + selectedJobId, + selectedJob as CombinedJob + ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDetectorIndex, selectedEntities, selectedJobId]); const [storageFieldsConfig, setStorageFieldsConfig] = useStorage< @@ -318,6 +331,7 @@ export const SeriesControls: FC = ({ ); })} `anomaly-marker multi-bucket ${getSeverityWithLow(d.anomalyScore).id}`); // Add rectangular markers for any scheduled events. - const scheduledEventMarkers = d3 + const scheduledEventMarkers = chartElement .select('.focus-chart-markers') .selectAll('.scheduled-event-marker') .data(data.filter((d) => d.scheduledEvents !== undefined)); @@ -898,7 +915,7 @@ class TimeseriesChartIntl extends Component { .attr('d', this.focusValuesLine(focusForecastData)) .classed('hidden', !showForecast); - const forecastDots = d3 + const forecastDots = chartElement .select('.focus-chart-markers.forecast') .selectAll('.metric-value') .data(focusForecastData); @@ -1007,7 +1024,7 @@ class TimeseriesChartIntl extends Component { const chartElement = d3.select(this.rootNode); chartElement.selectAll('.focus-zoom a').on('click', function () { d3.event.preventDefault(); - setZoomInterval(d3.select(this).attr('data-ms')); + setZoomInterval(this.getAttribute('data-ms')); }); } @@ -1129,7 +1146,7 @@ class TimeseriesChartIntl extends Component { .attr('y2', brushChartHeight); // Add x axis. - const timeBuckets = getTimeBucketsFromCache(); + const timeBuckets = this.getTimeBuckets(); timeBuckets.setInterval('auto'); timeBuckets.setBounds(bounds); const xAxisTickFormat = timeBuckets.getScaledDateFormat(); @@ -1328,6 +1345,7 @@ class TimeseriesChartIntl extends Component {
`); + const that = this; function brushing() { const brushExtent = brush.extent(); mask.reveal(brushExtent); @@ -1345,11 +1363,11 @@ class TimeseriesChartIntl extends Component { topBorder.attr('width', topBorderWidth); const isEmpty = brush.empty(); - d3.selectAll('.brush-handle').style('visibility', isEmpty ? 'hidden' : 'visible'); + const chartElement = d3.select(that.rootNode); + chartElement.selectAll('.brush-handle').style('visibility', isEmpty ? 'hidden' : 'visible'); } brushing(); - const that = this; function brushed() { const isEmpty = brush.empty(); const selectedBounds = isEmpty ? contextXScale.domain() : brush.extent(); @@ -1478,18 +1496,19 @@ class TimeseriesChartIntl extends Component { // Sets the extent of the brush on the context chart to the // supplied from and to Date objects. setContextBrushExtent = (from, to) => { + const chartElement = d3.select(this.rootNode); const brush = this.brush; const brushExtent = brush.extent(); const newExtent = [from, to]; brush.extent(newExtent); - brush(d3.select('.brush')); + brush(chartElement.select('.brush')); if ( newExtent[0].getTime() !== brushExtent[0].getTime() || newExtent[1].getTime() !== brushExtent[1].getTime() ) { - brush.event(d3.select('.brush')); + brush.event(chartElement.select('.brush')); } }; @@ -1867,12 +1886,13 @@ class TimeseriesChartIntl extends Component { anomalyTime, focusAggregationInterval ); + const chartElement = d3.select(this.rootNode); // Render an additional highlighted anomaly marker on the focus chart. // TODO - plot anomaly markers for cases where there is an anomaly due // to the absence of data and model plot is enabled. if (markerToSelect !== undefined) { - const selectedMarker = d3 + const selectedMarker = chartElement .select('.focus-chart-markers') .selectAll('.focus-chart-highlighted-marker') .data([markerToSelect]); @@ -1905,7 +1925,6 @@ class TimeseriesChartIntl extends Component { // Display the chart tooltip for this marker. // Note the values of the record and marker may differ depending on the levels of aggregation. - const chartElement = d3.select(this.rootNode); const anomalyMarker = chartElement.selectAll( '.focus-chart-markers .anomaly-marker.highlighted' ); @@ -1916,7 +1935,8 @@ class TimeseriesChartIntl extends Component { } unhighlightFocusChartAnomaly() { - d3.select('.focus-chart-markers').selectAll('.anomaly-marker.highlighted').remove(); + const chartElement = d3.select(this.rootNode); + chartElement.select('.focus-chart-markers').selectAll('.anomaly-marker.highlighted').remove(); this.props.tooltipService.hide(); } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx index 66da1e4222887..b9e09158bf280 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart_with_tooltip.tsx @@ -15,7 +15,7 @@ import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs' import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../../../common/constants/search'; import { Annotation } from '../../../../../common/types/annotations'; import { useMlKibana, useNotifications } from '../../../contexts/kibana'; -import { getBoundsRoundedToInterval } from '../../../util/time_buckets'; +import { useTimeBucketsService } from '../../../util/time_buckets_service'; import { getControlsForDetector } from '../../get_controls_for_detector'; import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context'; import { SourceIndicesWithGeoFields } from '../../../explorer/explorer_utils'; @@ -23,6 +23,7 @@ import { SourceIndicesWithGeoFields } from '../../../explorer/explorer_utils'; interface TimeSeriesChartWithTooltipsProps { bounds: any; detectorIndex: number; + embeddableMode?: boolean; renderFocusChartOnly: boolean; selectedJob: CombinedJob; selectedEntities: Record; @@ -41,6 +42,7 @@ interface TimeSeriesChartWithTooltipsProps { export const TimeSeriesChartWithTooltips: FC = ({ bounds, detectorIndex, + embeddableMode, renderFocusChartOnly, selectedJob, selectedEntities, @@ -80,13 +82,19 @@ export const TimeSeriesChartWithTooltips: FC = // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const mlTimeBucketsService = useTimeBucketsService(); + useEffect(() => { let unmounted = false; const entities = getControlsForDetector(detectorIndex, selectedEntities, selectedJob.job_id); const nonBlankEntities = Array.isArray(entities) ? entities.filter((entity) => entity.fieldValue !== null) : undefined; - const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, false); + const searchBounds = mlTimeBucketsService.getBoundsRoundedToInterval( + bounds, + contextAggregationInterval, + false + ); /** * Loads the full list of annotations for job without any aggs or time boundaries @@ -138,6 +146,7 @@ export const TimeSeriesChartWithTooltips: FC = annotationData={annotationData} bounds={bounds} detectorIndex={detectorIndex} + embeddableMode={embeddableMode} renderFocusChartOnly={renderFocusChartOnly} selectedJob={selectedJob} showAnnotations={showAnnotations} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts index cf8e1f0aa989c..30f097dabb8ab 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_controls_for_detector.ts @@ -7,7 +7,7 @@ import { mlJobService } from '../services/job_service'; import { Entity } from './components/entity_control/entity_control'; -import { JobId } from '../../../common/types/anomaly_detection_jobs'; +import type { JobId, CombinedJob } from '../../../common/types/anomaly_detection_jobs'; /** * Extracts entities from the detector configuration @@ -15,9 +15,10 @@ import { JobId } from '../../../common/types/anomaly_detection_jobs'; export function getControlsForDetector( selectedDetectorIndex: number, selectedEntities: Record, - selectedJobId: JobId + selectedJobId: JobId, + job?: CombinedJob ): Entity[] { - const selectedJob = mlJobService.getJob(selectedJobId); + const selectedJob = job ?? mlJobService.getJob(selectedJobId); const entities: Entity[] = []; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts index d0dfdc9ed372b..e6f1a2ec65afd 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/get_function_description.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { lastValueFrom } from 'rxjs'; import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils'; -import { mlResultsService } from '../services/results_service'; +import { type MlResultsService } from '../services/results_service'; import { ToastNotificationService } from '../services/toast_notification_service'; import { getControlsForDetector } from './get_controls_for_detector'; import { getCriteriaFields } from './get_criteria_fields'; @@ -41,7 +41,8 @@ export const getFunctionDescription = async ( selectedJobId: string; selectedJob: CombinedJob; }, - toastNotificationService: ToastNotificationService + toastNotificationService: ToastNotificationService, + mlResultsService: MlResultsService ) => { // if the detector's function is metric, fetch the highest scoring anomaly record // and set to plot the function_description (avg/min/max) of that record by default diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 757f4cb06543e..ad3f71e5df22d 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -77,6 +77,7 @@ import { processMetricPlotResults, processRecordScoreResults, getFocusData, + getTimeseriesexplorerDefaultState, } from './timeseriesexplorer_utils'; import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; import { getControlsForDetector } from './get_controls_for_detector'; @@ -96,46 +97,6 @@ const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionV defaultMessage: 'all', }); -function getTimeseriesexplorerDefaultState() { - return { - chartDetails: undefined, - contextAggregationInterval: undefined, - contextChartData: undefined, - contextForecastData: undefined, - // Not chartable if e.g. model plot with terms for a varp detector - dataNotChartable: false, - entitiesLoading: false, - entityValues: {}, - focusAnnotationData: [], - focusAggregationInterval: {}, - focusChartData: undefined, - focusForecastData: undefined, - fullRefresh: true, - hasResults: false, - // Counter to keep track of what data sets have been loaded. - loadCounter: 0, - loading: false, - modelPlotEnabled: false, - // Toggles display of annotations in the focus chart - showAnnotations: true, - showAnnotationsCheckbox: true, - // Toggles display of forecast data in the focus chart - showForecast: true, - showForecastCheckbox: false, - // Toggles display of model bounds in the focus chart - showModelBounds: true, - showModelBoundsCheckbox: false, - svgWidth: 0, - tableData: undefined, - zoomFrom: undefined, - zoomTo: undefined, - zoomFromFocusLoaded: undefined, - zoomToFocusLoaded: undefined, - chartDataError: undefined, - sourceIndicesWithGeoFields: {}, - }; -} - const containerPadding = 34; export class TimeSeriesExplorer extends React.Component { @@ -265,7 +226,7 @@ export class TimeSeriesExplorer extends React.Component { } /** - * Gets focus data for the current component state/ + * Gets focus data for the current component state */ getFocusData(selection) { const { selectedJobId, selectedForecastId, selectedDetectorIndex, functionDescription } = @@ -745,7 +706,6 @@ export class TimeSeriesExplorer extends React.Component { ); } } - // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', () => { @@ -1091,7 +1051,6 @@ export class TimeSeriesExplorer extends React.Component { entities={entityControls} /> )} - {arePartitioningFieldsProvided && jobs.length > 0 && (fullRefresh === false || loading === false) && diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts index d66dca5f565d7..5d13c73f8401f 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts @@ -17,7 +17,9 @@ export const APP_STATE_ACTION = { SET_ZOOM: 'SET_ZOOM', UNSET_ZOOM: 'UNSET_ZOOM', SET_FUNCTION_DESCRIPTION: 'SET_FUNCTION_DESCRIPTION', -}; +} as const; + +export type TimeseriesexplorerActionType = typeof APP_STATE_ACTION[keyof typeof APP_STATE_ACTION]; export const CHARTS_POINT_TARGET = 500; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/index.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/index.ts new file mode 100644 index 0000000000000..b81b4bc96a434 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { TimeSeriesExplorerEmbeddableChart } from './timeseriesexplorer_embeddable_chart'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_checkbox.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_checkbox.tsx new file mode 100644 index 0000000000000..e1136e54180ac --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_checkbox.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useMemo } from 'react'; +import { EuiCheckbox, EuiFlexItem, htmlIdGenerator } from '@elastic/eui'; + +interface Props { + id: string; + label: string; + checked: boolean; + onChange: (e: React.ChangeEvent) => void; +} + +export const TimeseriesExplorerCheckbox: FC = ({ id, label, checked, onChange }) => { + const checkboxId = useMemo(() => `id-${htmlIdGenerator()()}`, []); + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js new file mode 100644 index 0000000000000..fd6b0239199bc --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_embeddable_chart.js @@ -0,0 +1,897 @@ +/* + * 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. + */ + +/* + * React component for rendering Single Metric Viewer. + */ + +import { isEqual } from 'lodash'; +import moment from 'moment-timezone'; +import { Subject, Subscription, forkJoin } from 'rxjs'; +import { debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators'; + +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { context } from '@kbn/kibana-react-plugin/public'; + +import { + EuiCallOut, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiTextColor, +} from '@elastic/eui'; +import { TimeSeriesExplorerHelpPopover } from '../timeseriesexplorer_help_popover'; + +import { + isModelPlotEnabled, + isModelPlotChartableForDetector, + isSourceDataChartableForDetector, +} from '../../../../common/util/job_utils'; + +import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; +import { TimeseriesexplorerNoChartData } from '../components/timeseriesexplorer_no_chart_data'; + +import { + APP_STATE_ACTION, + CHARTS_POINT_TARGET, + TIME_FIELD_NAME, +} from '../timeseriesexplorer_constants'; +import { getControlsForDetector } from '../get_controls_for_detector'; +import { TimeSeriesChartWithTooltips } from '../components/timeseries_chart/timeseries_chart_with_tooltip'; +import { aggregationTypeTransform } from '@kbn/ml-anomaly-utils'; +import { isMetricDetector } from '../get_function_description'; +import { TimeseriesexplorerChartDataError } from '../components/timeseriesexplorer_chart_data_error'; +import { TimeseriesExplorerCheckbox } from './timeseriesexplorer_checkbox'; +import { timeBucketsServiceFactory } from '../../util/time_buckets_service'; +import { timeSeriesExplorerServiceFactory } from '../../util/time_series_explorer_service'; +import { getTimeseriesexplorerDefaultState } from '../timeseriesexplorer_utils'; + +// Used to indicate the chart is being plotted across +// all partition field values, where the cardinality of the field cannot be +// obtained as it is not aggregatable e.g. 'all distinct kpi_indicator values' +const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionValuesLabel', { + defaultMessage: 'all', +}); + +export class TimeSeriesExplorerEmbeddableChart extends React.Component { + static propTypes = { + appStateHandler: PropTypes.func.isRequired, + autoZoomDuration: PropTypes.number.isRequired, + bounds: PropTypes.object.isRequired, + chartWidth: PropTypes.number.isRequired, + lastRefresh: PropTypes.number.isRequired, + previousRefresh: PropTypes.number.isRequired, + selectedJobId: PropTypes.string.isRequired, + selectedDetectorIndex: PropTypes.number, + selectedEntities: PropTypes.object, + selectedForecastId: PropTypes.string, + zoom: PropTypes.object, + toastNotificationService: PropTypes.object, + dataViewsService: PropTypes.object, + }; + + state = getTimeseriesexplorerDefaultState(); + + subscriptions = new Subscription(); + + unmounted = false; + + /** + * Subject for listening brush time range selection. + */ + contextChart$ = new Subject(); + + /** + * Access ML services in react context. + */ + static contextType = context; + + getBoundsRoundedToInterval; + mlTimeSeriesExplorer; + + /** + * Returns field names that don't have a selection yet. + */ + getFieldNamesWithEmptyValues = () => { + const latestEntityControls = this.getControlsForDetector(); + return latestEntityControls + .filter(({ fieldValue }) => fieldValue === null) + .map(({ fieldName }) => fieldName); + }; + + /** + * Checks if all entity control dropdowns have a selection. + */ + arePartitioningFieldsProvided = () => { + const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues(); + return fieldNamesWithEmptyValues.length === 0; + }; + + toggleShowAnnotationsHandler = () => { + this.setState((prevState) => ({ + showAnnotations: !prevState.showAnnotations, + })); + }; + + toggleShowForecastHandler = () => { + this.setState((prevState) => ({ + showForecast: !prevState.showForecast, + })); + }; + + toggleShowModelBoundsHandler = () => { + this.setState({ + showModelBounds: !this.state.showModelBounds, + }); + }; + + setFunctionDescription = (selectedFuction) => { + this.props.appStateHandler(APP_STATE_ACTION.SET_FUNCTION_DESCRIPTION, selectedFuction); + }; + + previousChartProps = {}; + previousShowAnnotations = undefined; + previousShowForecast = undefined; + previousShowModelBounds = undefined; + + tableFilter = (field, value, operator) => { + const entities = this.getControlsForDetector(); + const entity = entities.find(({ fieldName }) => fieldName === field); + + if (entity === undefined) { + return; + } + + const { appStateHandler } = this.props; + + let resultValue = ''; + if (operator === '+' && entity.fieldValue !== value) { + resultValue = value; + } else if (operator === '-' && entity.fieldValue === value) { + resultValue = null; + } else { + return; + } + + const resultEntities = { + ...entities.reduce((appStateEntities, appStateEntity) => { + appStateEntities[appStateEntity.fieldName] = appStateEntity.fieldValue; + return appStateEntities; + }, {}), + [entity.fieldName]: resultValue, + }; + + appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities); + }; + + contextChartSelectedInitCallDone = false; + + getFocusAggregationInterval(selection) { + const { selectedJob } = this.props; + + // Calculate the aggregation interval for the focus chart. + const bounds = { min: moment(selection.from), max: moment(selection.to) }; + + return this.mlTimeSeriesExplorer.calculateAggregationInterval( + bounds, + CHARTS_POINT_TARGET, + selectedJob + ); + } + + /** + * Gets focus data for the current component state + */ + getFocusData(selection) { + const { selectedForecastId, selectedDetectorIndex, functionDescription, selectedJob } = + this.props; + const { modelPlotEnabled } = this.state; + if (isMetricDetector(selectedJob, selectedDetectorIndex) && functionDescription === undefined) { + return; + } + const entityControls = this.getControlsForDetector(); + + // Calculate the aggregation interval for the focus chart. + const bounds = { min: moment(selection.from), max: moment(selection.to) }; + const focusAggregationInterval = this.getFocusAggregationInterval(selection); + + // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. + // For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected + // to some extent with all detector functions if not searching complete buckets. + const searchBounds = this.getBoundsRoundedToInterval(bounds, focusAggregationInterval, false); + + return this.mlTimeSeriesExplorer.getFocusData( + this.getCriteriaFields(selectedDetectorIndex, entityControls), + selectedDetectorIndex, + focusAggregationInterval, + selectedForecastId, + modelPlotEnabled, + entityControls.filter((entity) => entity.fieldValue !== null), + searchBounds, + selectedJob, + functionDescription, + TIME_FIELD_NAME + ); + } + + contextChartSelected = (selection) => { + const zoomState = { + from: selection.from.toISOString(), + to: selection.to.toISOString(), + }; + + if ( + isEqual(this.props.zoom, zoomState) && + this.state.focusChartData !== undefined && + this.props.previousRefresh === this.props.lastRefresh + ) { + return; + } + + this.contextChart$.next(selection); + this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + }; + + setForecastId = (forecastId) => { + this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); + }; + + displayErrorToastMessages = (error, errorMsg) => { + if (this.props.toastNotificationService) { + this.props.toastNotificationService.displayErrorToast(error, errorMsg, 2000); + } + this.setState({ loading: false, chartDataError: errorMsg }); + }; + + loadSingleMetricData = (fullRefresh = true) => { + const { + autoZoomDuration, + bounds, + selectedDetectorIndex, + zoom, + functionDescription, + selectedJob, + } = this.props; + + const { loadCounter: currentLoadCounter } = this.state; + if (selectedJob === undefined) { + return; + } + if (isMetricDetector(selectedJob, selectedDetectorIndex) && functionDescription === undefined) { + return; + } + + const functionToPlotByIfMetric = aggregationTypeTransform.toES(functionDescription); + + this.contextChartSelectedInitCallDone = false; + + // Only when `fullRefresh` is true we'll reset all data + // and show the loading spinner within the page. + const entityControls = this.getControlsForDetector(); + this.setState( + { + fullRefresh, + loadCounter: currentLoadCounter + 1, + loading: true, + chartDataError: undefined, + ...(fullRefresh + ? { + chartDetails: undefined, + contextChartData: undefined, + contextForecastData: undefined, + focusChartData: undefined, + focusForecastData: undefined, + modelPlotEnabled: + isModelPlotChartableForDetector(selectedJob, selectedDetectorIndex) && + isModelPlotEnabled(selectedJob, selectedDetectorIndex, entityControls), + hasResults: false, + dataNotChartable: false, + } + : {}), + }, + () => { + const { loadCounter, modelPlotEnabled } = this.state; + const { selectedJob } = this.props; + + const detectorIndex = selectedDetectorIndex; + + let awaitingCount = 3; + + const stateUpdate = {}; + + // finish() function, called after each data set has been loaded and processed. + // The last one to call it will trigger the page render. + const finish = (counterVar) => { + awaitingCount--; + if (awaitingCount === 0 && counterVar === loadCounter) { + stateUpdate.hasResults = + (Array.isArray(stateUpdate.contextChartData) && + stateUpdate.contextChartData.length > 0) || + (Array.isArray(stateUpdate.contextForecastData) && + stateUpdate.contextForecastData.length > 0); + stateUpdate.loading = false; + + // Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically + // selecting the specified range in the context chart, and so loading that date range in the focus chart. + // Only touch the zoom range if data for the context chart has been loaded and all necessary + // partition fields have a selection. + if ( + stateUpdate.contextChartData.length && + this.arePartitioningFieldsProvided() === true + ) { + // Check for a zoom parameter in the appState (URL). + let focusRange = this.mlTimeSeriesExplorer.calculateInitialFocusRange( + zoom, + stateUpdate.contextAggregationInterval, + bounds + ); + if ( + focusRange === undefined || + this.previousSelectedForecastId !== this.props.selectedForecastId + ) { + focusRange = this.mlTimeSeriesExplorer.calculateDefaultFocusRange( + autoZoomDuration, + stateUpdate.contextAggregationInterval, + stateUpdate.contextChartData, + stateUpdate.contextForecastData + ); + this.previousSelectedForecastId = this.props.selectedForecastId; + } + + this.contextChartSelected({ + from: focusRange[0], + to: focusRange[1], + }); + } + + this.setState(stateUpdate); + } + }; + + const nonBlankEntities = entityControls.filter((entity) => { + return entity.fieldValue !== null; + }); + + if ( + modelPlotEnabled === false && + isSourceDataChartableForDetector(selectedJob, detectorIndex) === false && + nonBlankEntities.length > 0 + ) { + // For detectors where model plot has been enabled with a terms filter and the + // selected entity(s) are not in the terms list, indicate that data cannot be viewed. + stateUpdate.hasResults = false; + stateUpdate.loading = false; + stateUpdate.dataNotChartable = true; + this.setState(stateUpdate); + return; + } + + // Calculate the aggregation interval for the context chart. + // Context chart swimlane will display bucket anomaly score at the same interval. + stateUpdate.contextAggregationInterval = + this.mlTimeSeriesExplorer.calculateAggregationInterval( + bounds, + CHARTS_POINT_TARGET, + selectedJob + ); + + // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. + // For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected + // to some extent with all detector functions if not searching complete buckets. + const searchBounds = this.getBoundsRoundedToInterval( + bounds, + stateUpdate.contextAggregationInterval, + false + ); + + // Query 1 - load metric data at low granularity across full time range. + // Pass a counter flag into the finish() function to make sure we only process the results + // for the most recent call to the load the data in cases where the job selection and time filter + // have been altered in quick succession (such as from the job picker with 'Apply time range'). + const counter = loadCounter; + this.context.services.mlServices.mlTimeSeriesSearchService + .getMetricData( + selectedJob, + detectorIndex, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.asMilliseconds(), + functionToPlotByIfMetric + ) + .toPromise() + .then((resp) => { + const fullRangeChartData = this.mlTimeSeriesExplorer.processMetricPlotResults( + resp.results, + modelPlotEnabled + ); + stateUpdate.contextChartData = fullRangeChartData; + finish(counter); + }) + .catch((err) => { + const errorMsg = i18n.translate('xpack.ml.timeSeriesExplorer.metricDataErrorMessage', { + defaultMessage: 'Error getting metric data', + }); + this.displayErrorToastMessages(err, errorMsg); + }); + + // Query 2 - load max record score at same granularity as context chart + // across full time range for use in the swimlane. + this.context.services.mlServices.mlResultsService + .getRecordMaxScoreByTime( + selectedJob.job_id, + this.getCriteriaFields(detectorIndex, entityControls), + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.asMilliseconds(), + functionToPlotByIfMetric + ) + .then((resp) => { + const fullRangeRecordScoreData = this.mlTimeSeriesExplorer.processRecordScoreResults( + resp.results + ); + stateUpdate.swimlaneData = fullRangeRecordScoreData; + finish(counter); + }) + .catch((err) => { + const errorMsg = i18n.translate( + 'xpack.ml.timeSeriesExplorer.bucketAnomalyScoresErrorMessage', + { + defaultMessage: 'Error getting bucket anomaly scores', + } + ); + + this.displayErrorToastMessages(err, errorMsg); + }); + + // Query 3 - load details on the chart used in the chart title (charting function and entity(s)). + this.context.services.mlServices.mlTimeSeriesSearchService + .getChartDetails( + selectedJob, + detectorIndex, + entityControls, + searchBounds.min.valueOf(), + searchBounds.max.valueOf() + ) + .then((resp) => { + stateUpdate.chartDetails = resp.results; + finish(counter); + }) + .catch((err) => { + this.displayErrorToastMessages( + err, + i18n.translate('xpack.ml.timeSeriesExplorer.entityCountsErrorMessage', { + defaultMessage: 'Error getting entity counts', + }) + ); + }); + } + ); + }; + + /** + * Updates local state of detector related controls from the global state. + * @param callback to invoke after a state update. + */ + getControlsForDetector = () => { + const { selectedDetectorIndex, selectedEntities, selectedJobId, selectedJob } = this.props; + return getControlsForDetector( + selectedDetectorIndex, + selectedEntities, + selectedJobId, + selectedJob + ); + }; + + /** + * Updates criteria fields for API calls, e.g. getAnomaliesTableData + * @param detectorIndex + * @param entities + */ + getCriteriaFields(detectorIndex, entities) { + // Only filter on the entity if the field has a value. + const nonBlankEntities = entities.filter((entity) => entity.fieldValue !== null); + return [ + { + fieldName: 'detector_index', + fieldValue: detectorIndex, + }, + ...nonBlankEntities, + ]; + } + + async componentDidMount() { + this.getBoundsRoundedToInterval = timeBucketsServiceFactory( + this.context.services.uiSettings + ).getBoundsRoundedToInterval; + + this.mlTimeSeriesExplorer = timeSeriesExplorerServiceFactory( + this.context.services.uiSettings, + this.context.services.mlServices.mlApiServices, + this.context.services.mlServices.mlResultsService + ); + + // Listen for context chart updates. + this.subscriptions.add( + this.contextChart$ + .pipe( + tap((selection) => { + this.setState({ + zoomFrom: selection.from, + zoomTo: selection.to, + }); + }), + debounceTime(500), + tap((selection) => { + const { + contextChartData, + contextForecastData, + focusChartData, + zoomFromFocusLoaded, + zoomToFocusLoaded, + } = this.state; + + if ( + (contextChartData === undefined || contextChartData.length === 0) && + (contextForecastData === undefined || contextForecastData.length === 0) + ) { + return; + } + + if ( + (this.contextChartSelectedInitCallDone === false && focusChartData === undefined) || + zoomFromFocusLoaded.getTime() !== selection.from.getTime() || + zoomToFocusLoaded.getTime() !== selection.to.getTime() + ) { + this.contextChartSelectedInitCallDone = true; + + this.setState({ + loading: true, + fullRefresh: false, + }); + } + }), + switchMap((selection) => { + return forkJoin([this.getFocusData(selection)]); + }), + withLatestFrom(this.contextChart$) + ) + .subscribe(([[refreshFocusData, tableData], selection]) => { + const { modelPlotEnabled } = this.state; + + // All the data is ready now for a state update. + this.setState({ + focusAggregationInterval: this.getFocusAggregationInterval({ + from: selection.from, + to: selection.to, + }), + loading: false, + showModelBoundsCheckbox: modelPlotEnabled && refreshFocusData.focusChartData.length > 0, + zoomFromFocusLoaded: selection.from, + zoomToFocusLoaded: selection.to, + ...refreshFocusData, + ...tableData, + }); + }) + ); + + if (this.context && this.props.selectedJob !== undefined) { + // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. + this.context.services.mlServices.mlFieldFormatService.populateFormats([ + this.props.selectedJob.job_id, + ]); + } + + this.componentDidUpdate(); + } + + componentDidUpdate(previousProps) { + if ( + previousProps === undefined || + previousProps.selectedForecastId !== this.props.selectedForecastId + ) { + if (this.props.selectedForecastId !== undefined) { + // Ensure the forecast data will be shown if hidden previously. + this.setState({ showForecast: true }); + // Not best practice but we need the previous value for another comparison + // once all the data was loaded. + if (previousProps !== undefined) { + this.previousSelectedForecastId = previousProps.selectedForecastId; + } + } + } + + if ( + previousProps === undefined || + !isEqual(previousProps.bounds, this.props.bounds) || + (!isEqual(previousProps.lastRefresh, this.props.lastRefresh) && + previousProps.lastRefresh !== 0) || + !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || + previousProps.selectedForecastId !== this.props.selectedForecastId || + previousProps.selectedJobId !== this.props.selectedJobId || + previousProps.functionDescription !== this.props.functionDescription + ) { + const fullRefresh = + previousProps === undefined || + !isEqual(previousProps.bounds, this.props.bounds) || + !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || + previousProps.selectedForecastId !== this.props.selectedForecastId || + previousProps.selectedJobId !== this.props.selectedJobId || + previousProps.functionDescription !== this.props.functionDescription; + this.loadSingleMetricData(fullRefresh); + } + + if (previousProps === undefined) { + return; + } + } + + componentWillUnmount() { + this.subscriptions.unsubscribe(); + this.unmounted = true; + } + + render() { + const { + autoZoomDuration, + bounds, + chartWidth, + lastRefresh, + selectedDetectorIndex, + selectedJob, + } = this.props; + + const { + chartDetails, + contextAggregationInterval, + contextChartData, + contextForecastData, + dataNotChartable, + focusAggregationInterval, + focusAnnotationData, + focusChartData, + focusForecastData, + fullRefresh, + hasResults, + loading, + modelPlotEnabled, + showAnnotations, + showAnnotationsCheckbox, + showForecast, + showForecastCheckbox, + showModelBounds, + showModelBoundsCheckbox, + swimlaneData, + zoomFrom, + zoomTo, + zoomFromFocusLoaded, + zoomToFocusLoaded, + chartDataError, + } = this.state; + const chartProps = { + modelPlotEnabled, + contextChartData, + contextChartSelected: this.contextChartSelected, + contextForecastData, + contextAggregationInterval, + swimlaneData, + focusAnnotationData, + focusChartData, + focusForecastData, + focusAggregationInterval, + svgWidth: chartWidth, + zoomFrom, + zoomTo, + zoomFromFocusLoaded, + zoomToFocusLoaded, + autoZoomDuration, + }; + + const entityControls = this.getControlsForDetector(); + const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues(); + const arePartitioningFieldsProvided = this.arePartitioningFieldsProvided(); + + let renderFocusChartOnly = true; + + if ( + isEqual(this.previousChartProps.focusForecastData, chartProps.focusForecastData) && + isEqual(this.previousChartProps.focusChartData, chartProps.focusChartData) && + isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) && + this.previousShowForecast === showForecast && + this.previousShowModelBounds === showModelBounds && + this.props.previousRefresh === lastRefresh + ) { + renderFocusChartOnly = false; + } + + this.previousChartProps = chartProps; + this.previousShowForecast = showForecast; + this.previousShowModelBounds = showModelBounds; + + return ( + <> + {fieldNamesWithEmptyValues.length > 0 && ( + <> + + } + iconType="help" + size="s" + /> + + + )} + + {fullRefresh && loading === true && ( + + )} + + {loading === false && chartDataError !== undefined && ( + + )} + + {arePartitioningFieldsProvided && + selectedJob && + (fullRefresh === false || loading === false) && + hasResults === false && + chartDataError === undefined && ( + + )} + {arePartitioningFieldsProvided && + selectedJob && + (fullRefresh === false || loading === false) && + hasResults === true && ( +
+ + + +

+ + {i18n.translate( + 'xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', + { + defaultMessage: 'Single time series analysis of {functionLabel}', + values: { functionLabel: chartDetails.functionLabel }, + } + )} + +   + {chartDetails.entityData.count === 1 && ( + + {chartDetails.entityData.entities.length > 0 && '('} + {chartDetails.entityData.entities + .map((entity) => { + return `${entity.fieldName}: ${entity.fieldValue}`; + }) + .join(', ')} + {chartDetails.entityData.entities.length > 0 && ')'} + + )} + {chartDetails.entityData.count !== 1 && ( + + {chartDetails.entityData.entities.map((countData, i) => { + return ( + + {i18n.translate( + 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', + { + defaultMessage: + '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', + values: { + openBrace: i === 0 ? '(' : '', + closeBrace: + i === chartDetails.entityData.entities.length - 1 + ? ')' + : '', + cardinalityValue: + countData.cardinality === 0 + ? allValuesLabel + : countData.cardinality, + cardinality: countData.cardinality, + fieldName: countData.fieldName, + }, + } + )} + {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} + + ); + })} + + )} +

+
+
+ + + + +
+ + {showModelBoundsCheckbox && ( + + )} + + {showAnnotationsCheckbox && ( + + )} + + {showForecastCheckbox && ( + + + {i18n.translate('xpack.ml.timeSeriesExplorer.showForecastLabel', { + defaultMessage: 'show forecast', + })} + + } + checked={showForecast} + onChange={this.toggleShowForecastHandler} + /> + + )} + + + +
+ )} + + ); + } +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_help_popover.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_help_popover.tsx index afd93fd5acee1..3557523c113fc 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_help_popover.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_help_popover.tsx @@ -5,12 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { HelpPopover } from '../components/help_popover/help_popover'; -export const TimeSeriesExplorerHelpPopover = () => { +export const TimeSeriesExplorerHelpPopover: FC<{ embeddableMode: boolean }> = ({ + embeddableMode, +}) => { return ( { defaultMessage="If you create a forecast, predicted data values are added to the chart. A shaded area around these values represents the confidence level; as you forecast further into the future, the confidence level generally decreases." />

-

- -

+ {!embeddableMode && ( +

+ +

+ )}

{ + if ( + isModelPlotChartableForDetector(job, detectorIndex) && + isModelPlotEnabled(job, detectorIndex, entityFields) + ) { + // Extract the partition, by, over fields on which to filter. + const criteriaFields = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (detector.partition_field_name !== undefined) { + const partitionEntity: any = find(entityFields, { + fieldName: detector.partition_field_name, + }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); + } + } + + if (detector.over_field_name !== undefined) { + const overEntity: any = find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); + } + } + + if (detector.by_field_name !== undefined) { + const byEntity: any = find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); + } + } + + return mlResultsService.getModelPlotOutput( + job.job_id, + detectorIndex, + criteriaFields, + earliestMs, + latestMs, + intervalMs + ); + } else { + const obj: ModelPlotOutput = { + success: true, + results: {}, + }; + + const chartConfig = buildConfigFromDetector(job, detectorIndex); + + return mlResultsService + .getMetricData( + chartConfig.datafeedConfig.indices.join(','), + entityFields, + chartConfig.datafeedConfig.query, + esMetricFunction ?? chartConfig.metricFunction, + chartConfig.metricFieldName, + chartConfig.summaryCountFieldName, + chartConfig.timeField, + earliestMs, + latestMs, + intervalMs, + chartConfig?.datafeedConfig + ) + .pipe( + map((resp) => { + each(resp.results, (value, time) => { + // @ts-ignore + obj.results[time] = { + actual: value, + }; + }); + return obj; + }) + ); + } + }, + // Builds chart detail information (charting function description and entity counts) used + // in the title area of the time series chart. + // Queries Elasticsearch if necessary to obtain the distinct count of entities + // for which data is being plotted. + getChartDetails( + job: Job, + detectorIndex: number, + entityFields: any[], + earliestMs: number, + latestMs: number + ) { + return new Promise((resolve, reject) => { + const obj: any = { + success: true, + results: { functionLabel: '', entityData: { entities: [] } }, + }; + + const chartConfig = buildConfigFromDetector(job, detectorIndex); + let functionLabel: string | null = chartConfig.metricFunction; + if (chartConfig.metricFieldName !== undefined) { + functionLabel += ' '; + functionLabel += chartConfig.metricFieldName; + } + obj.results.functionLabel = functionLabel; + + const blankEntityFields = filter(entityFields, (entity) => { + return entity.fieldValue === null; + }); + + // Look to see if any of the entity fields have defined values + // (i.e. blank input), and if so obtain the cardinality. + if (blankEntityFields.length === 0) { + obj.results.entityData.count = 1; + obj.results.entityData.entities = entityFields; + resolve(obj); + } else { + const entityFieldNames: string[] = blankEntityFields.map((f) => f.fieldName); + mlApiServices + .getCardinalityOfFields({ + index: chartConfig.datafeedConfig.indices.join(','), + fieldNames: entityFieldNames, + query: chartConfig.datafeedConfig.query, + timeFieldName: chartConfig.timeField, + earliestMs, + latestMs, + }) + .then((results: any) => { + each(blankEntityFields, (field) => { + // results will not contain keys for non-aggregatable fields, + // so store as 0 to indicate over all field values. + obj.results.entityData.entities.push({ + fieldName: field.fieldName, + cardinality: get(results, field.fieldName, 0), + }); + }); + + resolve(obj); + }) + .catch((resp: any) => { + reject(resp); + }); + } + }); + }, + }; +} + +export type MlTimeSeriesSeachService = ReturnType; diff --git a/x-pack/plugins/ml/public/application/util/index_service.ts b/x-pack/plugins/ml/public/application/util/index_service.ts new file mode 100644 index 0000000000000..f4b6a1fc13d77 --- /dev/null +++ b/x-pack/plugins/ml/public/application/util/index_service.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { Job } from '../../../common/types/anomaly_detection_jobs'; + +// TODO Consolidate with legacy code in `ml/public/application/util/index_utils.ts`. +export function indexServiceFactory(dataViewsService: DataViewsContract) { + return { + /** + * Retrieves the data view ID from the given name. + * If a job is passed in, a temporary data view will be created if the requested data view doesn't exist. + * @param name - The name or index pattern of the data view. + * @param job - Optional job object. + * @returns The data view ID or null if it doesn't exist. + */ + async getDataViewIdFromName(name: string, job?: Job): Promise { + if (dataViewsService === null) { + throw new Error('Data views are not initialized!'); + } + const dataViews = await dataViewsService.find(name); + const dataView = dataViews.find((dv) => dv.getIndexPattern() === name); + if (!dataView) { + if (job !== undefined) { + const tempDataView = await dataViewsService.create({ + id: undefined, + name, + title: name, + timeFieldName: job.data_description.time_field!, + }); + return tempDataView.id ?? null; + } + return null; + } + return dataView.id ?? dataView.getIndexPattern(); + }, + getDataViewById(id: string): Promise { + if (dataViewsService === null) { + throw new Error('Data views are not initialized!'); + } + + if (id) { + return dataViewsService.get(id); + } else { + return dataViewsService.create({}); + } + }, + }; +} + +export type MlIndexUtils = ReturnType; diff --git a/x-pack/plugins/ml/public/application/util/time_buckets.d.ts b/x-pack/plugins/ml/public/application/util/time_buckets.d.ts index 9a5410918a099..0f413ed9c2c71 100644 --- a/x-pack/plugins/ml/public/application/util/time_buckets.d.ts +++ b/x-pack/plugins/ml/public/application/util/time_buckets.d.ts @@ -33,6 +33,7 @@ export declare class TimeBuckets { public setBounds(bounds: TimeRangeBounds): void; public getBounds(): { min: any; max: any }; public getInterval(): TimeBucketsInterval; + public getIntervalToNearestMultiple(divisorSecs: any): TimeBucketsInterval; public getScaledDateFormat(): string; } diff --git a/x-pack/plugins/ml/public/application/util/time_buckets_service.ts b/x-pack/plugins/ml/public/application/util/time_buckets_service.ts new file mode 100644 index 0000000000000..480f279a603b1 --- /dev/null +++ b/x-pack/plugins/ml/public/application/util/time_buckets_service.ts @@ -0,0 +1,57 @@ +/* + * 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 { useMemo } from 'react'; +import type { IUiSettingsClient } from '@kbn/core/public'; +import { UI_SETTINGS } from '@kbn/data-plugin/public'; +import moment from 'moment'; +import { type TimeRangeBounds, type TimeBucketsInterval, TimeBuckets } from './time_buckets'; +import { useMlKibana } from '../contexts/kibana'; + +// TODO Consolidate with legacy code in `ml/public/application/util/time_buckets.js`. +export function timeBucketsServiceFactory(uiSettings: IUiSettingsClient) { + function getTimeBuckets(): InstanceType { + return new TimeBuckets({ + [UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + [UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + } + function getBoundsRoundedToInterval( + bounds: TimeRangeBounds, + interval: TimeBucketsInterval, + inclusiveEnd: boolean = false + ): Required { + // Returns new bounds, created by flooring the min of the provided bounds to the start of + // the specified interval (a moment duration), and rounded upwards (Math.ceil) to 1ms before + // the start of the next interval (Kibana dashboards search >= bounds min, and <= bounds max, + // so we subtract 1ms off the max to avoid querying start of the new Elasticsearch aggregation bucket). + const intervalMs = interval.asMilliseconds(); + const adjustedMinMs = Math.floor(bounds.min!.valueOf() / intervalMs) * intervalMs; + let adjustedMaxMs = Math.ceil(bounds.max!.valueOf() / intervalMs) * intervalMs; + + // Don't include the start ms of the next bucket unless specified.. + if (inclusiveEnd === false) { + adjustedMaxMs = adjustedMaxMs - 1; + } + return { min: moment(adjustedMinMs), max: moment(adjustedMaxMs) }; + } + + return { getTimeBuckets, getBoundsRoundedToInterval }; +} + +export type TimeBucketsService = ReturnType; + +export function useTimeBucketsService(): TimeBucketsService { + const { + services: { uiSettings }, + } = useMlKibana(); + + const mlTimeBucketsService = useMemo(() => timeBucketsServiceFactory(uiSettings), [uiSettings]); + return mlTimeBucketsService; +} diff --git a/x-pack/plugins/ml/public/application/util/time_series_explorer_service.ts b/x-pack/plugins/ml/public/application/util/time_series_explorer_service.ts new file mode 100644 index 0000000000000..4af8d98093cbd --- /dev/null +++ b/x-pack/plugins/ml/public/application/util/time_series_explorer_service.ts @@ -0,0 +1,648 @@ +/* + * 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 { useMemo } from 'react'; +import type { IUiSettingsClient } from '@kbn/core/public'; +import { aggregationTypeTransform } from '@kbn/ml-anomaly-utils'; +import { isMultiBucketAnomaly, ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils'; +import { extractErrorMessage } from '@kbn/ml-error-utils'; +import moment from 'moment'; +import { forkJoin, Observable, of } from 'rxjs'; +import { each, get } from 'lodash'; +import { catchError, map } from 'rxjs/operators'; +import { type MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils'; +import { parseInterval } from '../../../common/util/parse_interval'; +import type { GetAnnotationsResponse } from '../../../common/types/annotations'; +import { mlFunctionToESAggregation } from '../../../common/util/job_utils'; +import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; +import { CHARTS_POINT_TARGET } from '../timeseriesexplorer/timeseriesexplorer_constants'; +import { timeBucketsServiceFactory } from './time_buckets_service'; +import type { TimeRangeBounds } from './time_buckets'; +import type { Job } from '../../../common/types/anomaly_detection_jobs'; +import type { TimeBucketsInterval } from './time_buckets'; +import type { + ChartDataPoint, + FocusData, + Interval, +} from '../timeseriesexplorer/timeseriesexplorer_utils/get_focus_data'; +import type { CriteriaField } from '../services/results_service'; +import { + MAX_SCHEDULED_EVENTS, + TIME_FIELD_NAME, +} from '../timeseriesexplorer/timeseriesexplorer_constants'; +import type { MlApiServices } from '../services/ml_api_service'; +import { mlResultsServiceProvider, type MlResultsService } from '../services/results_service'; +import { forecastServiceProvider } from '../services/forecast_service_provider'; +import { timeSeriesSearchServiceFactory } from '../timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service'; +import { useMlKibana } from '../contexts/kibana'; + +// TODO Consolidate with legacy code in +// `ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js`. +export function timeSeriesExplorerServiceFactory( + uiSettings: IUiSettingsClient, + mlApiServices: MlApiServices, + mlResultsService: MlResultsService +) { + const timeBuckets = timeBucketsServiceFactory(uiSettings); + const mlForecastService = forecastServiceProvider(mlApiServices); + const mlTimeSeriesSearchService = timeSeriesSearchServiceFactory(mlResultsService, mlApiServices); + + function getAutoZoomDuration(selectedJob: Job) { + // Calculate the 'auto' zoom duration which shows data at bucket span granularity. + // Get the minimum bucket span of selected jobs. + let autoZoomDuration; + if (selectedJob.analysis_config.bucket_span) { + const bucketSpan = parseInterval(selectedJob.analysis_config.bucket_span); + const bucketSpanSeconds = bucketSpan!.asSeconds(); + + // In most cases the duration can be obtained by simply multiplying the points target + // Check that this duration returns the bucket span when run back through the + // TimeBucket interval calculation. + autoZoomDuration = bucketSpanSeconds * 1000 * (CHARTS_POINT_TARGET - 1); + + // Use a maxBars of 10% greater than the target. + const maxBars = Math.floor(1.1 * CHARTS_POINT_TARGET); + const buckets = timeBuckets.getTimeBuckets(); + buckets.setInterval('auto'); + buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET)); + buckets.setMaxBars(maxBars); + + // Set bounds from 'now' for testing the auto zoom duration. + const nowMs = new Date().getTime(); + const max = moment(nowMs); + const min = moment(nowMs - autoZoomDuration); + buckets.setBounds({ min, max }); + + const calculatedInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); + const calculatedIntervalSecs = calculatedInterval.asSeconds(); + if (calculatedIntervalSecs !== bucketSpanSeconds) { + // If we haven't got the span back, which may occur depending on the 'auto' ranges + // used in TimeBuckets and the bucket span of the job, then multiply by the ratio + // of the bucket span to the calculated interval. + autoZoomDuration = autoZoomDuration * (bucketSpanSeconds / calculatedIntervalSecs); + } + } + + return autoZoomDuration; + } + + function calculateAggregationInterval( + bounds: TimeRangeBounds, + bucketsTarget: number | undefined, + selectedJob: Job + ) { + // Aggregation interval used in queries should be a function of the time span of the chart + // and the bucket span of the selected job(s). + const barTarget = bucketsTarget !== undefined ? bucketsTarget : 100; + // Use a maxBars of 10% greater than the target. + const maxBars = Math.floor(1.1 * barTarget); + const buckets = timeBuckets.getTimeBuckets(); + buckets.setInterval('auto'); + buckets.setBounds(bounds); + buckets.setBarTarget(Math.floor(barTarget)); + buckets.setMaxBars(maxBars); + let aggInterval; + + if (selectedJob.analysis_config.bucket_span) { + // Ensure the aggregation interval is always a multiple of the bucket span to avoid strange + // behaviour such as adjacent chart buckets holding different numbers of job results. + const bucketSpan = parseInterval(selectedJob.analysis_config.bucket_span); + const bucketSpanSeconds = bucketSpan!.asSeconds(); + aggInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); + + // Set the interval back to the job bucket span if the auto interval is smaller. + const secs = aggInterval.asSeconds(); + if (secs < bucketSpanSeconds) { + buckets.setInterval(bucketSpanSeconds + 's'); + aggInterval = buckets.getInterval(); + } + } + + return aggInterval; + } + + function calculateInitialFocusRange( + zoomState: any, + contextAggregationInterval: any, + bounds: TimeRangeBounds + ) { + if (zoomState !== undefined) { + // Check that the zoom times are valid. + // zoomFrom must be at or after context chart search bounds earliest, + // zoomTo must be at or before context chart search bounds latest. + const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); + const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); + const searchBounds = timeBuckets.getBoundsRoundedToInterval( + bounds, + contextAggregationInterval, + true + ); + const earliest = searchBounds.min; + const latest = searchBounds.max; + + if ( + zoomFrom.isValid() && + zoomTo.isValid() && + zoomTo.isAfter(zoomFrom) && + zoomFrom.isBetween(earliest, latest, null, '[]') && + zoomTo.isBetween(earliest, latest, null, '[]') + ) { + return [zoomFrom.toDate(), zoomTo.toDate()]; + } + } + + return undefined; + } + + function calculateDefaultFocusRange( + autoZoomDuration: any, + contextAggregationInterval: any, + contextChartData: any, + contextForecastData: any + ) { + const isForecastData = contextForecastData !== undefined && contextForecastData.length > 0; + + const combinedData = + isForecastData === false ? contextChartData : contextChartData.concat(contextForecastData); + const earliestDataDate = combinedData[0].date; + const latestDataDate = combinedData[combinedData.length - 1].date; + + let rangeEarliestMs; + let rangeLatestMs; + + if (isForecastData === true) { + // Return a range centred on the start of the forecast range, depending + // on the time range of the forecast and data. + const earliestForecastDataDate = contextForecastData[0].date; + const latestForecastDataDate = contextForecastData[contextForecastData.length - 1].date; + + rangeLatestMs = Math.min( + earliestForecastDataDate.getTime() + autoZoomDuration / 2, + latestForecastDataDate.getTime() + ); + rangeEarliestMs = Math.max(rangeLatestMs - autoZoomDuration, earliestDataDate.getTime()); + } else { + // Returns the range that shows the most recent data at bucket span granularity. + rangeLatestMs = latestDataDate.getTime() + contextAggregationInterval.asMilliseconds(); + rangeEarliestMs = Math.max(earliestDataDate.getTime(), rangeLatestMs - autoZoomDuration); + } + + return [new Date(rangeEarliestMs), new Date(rangeLatestMs)]; + } + + // Return dataset in format used by the swimlane. + // i.e. array of Objects with keys date (JavaScript date) and score. + function processRecordScoreResults(scoreData: any) { + const bucketScoreData: any = []; + each(scoreData, (dataForTime, time) => { + bucketScoreData.push({ + date: new Date(+time), + score: dataForTime.score, + }); + }); + + return bucketScoreData; + } + + // Return dataset in format used by the single metric chart. + // i.e. array of Objects with keys date (JavaScript date) and value, + // plus lower and upper keys if model plot is enabled for the series. + function processMetricPlotResults(metricPlotData: any, modelPlotEnabled: any) { + const metricPlotChartData: any = []; + if (modelPlotEnabled === true) { + each(metricPlotData, (dataForTime, time) => { + metricPlotChartData.push({ + date: new Date(+time), + lower: dataForTime.modelLower, + value: dataForTime.actual, + upper: dataForTime.modelUpper, + }); + }); + } else { + each(metricPlotData, (dataForTime, time) => { + metricPlotChartData.push({ + date: new Date(+time), + value: dataForTime.actual, + }); + }); + } + + return metricPlotChartData; + } + + // Returns forecast dataset in format used by the single metric chart. + // i.e. array of Objects with keys date (JavaScript date), isForecast, + // value, lower and upper keys. + function processForecastResults(forecastData: any) { + const forecastPlotChartData: any = []; + each(forecastData, (dataForTime, time) => { + forecastPlotChartData.push({ + date: new Date(+time), + isForecast: true, + lower: dataForTime.forecastLower, + value: dataForTime.prediction, + upper: dataForTime.forecastUpper, + }); + }); + + return forecastPlotChartData; + } + + // Finds the chart point which corresponds to an anomaly with the + // specified time. + function findChartPointForAnomalyTime( + chartData: any, + anomalyTime: any, + aggregationInterval: any + ) { + let chartPoint; + if (chartData === undefined) { + return chartPoint; + } + + for (let i = 0; i < chartData.length; i++) { + if (chartData[i].date.getTime() === anomalyTime) { + chartPoint = chartData[i]; + break; + } + } + + if (chartPoint === undefined) { + // Find the time of the point which falls immediately before the + // time of the anomaly. This is the start of the chart 'bucket' + // which contains the anomalous bucket. + let foundItem; + const intervalMs = aggregationInterval.asMilliseconds(); + for (let i = 0; i < chartData.length; i++) { + const itemTime = chartData[i].date.getTime(); + if (anomalyTime - itemTime < intervalMs) { + foundItem = chartData[i]; + break; + } + } + + chartPoint = foundItem; + } + + return chartPoint; + } + + // Uses data from the list of anomaly records to add anomalyScore, + // function, actual and typical properties, plus causes and multi-bucket + // info if applicable, to the chartData entries for anomalous buckets. + function processDataForFocusAnomalies( + chartData: ChartDataPoint[], + anomalyRecords: MlAnomalyRecordDoc[], + aggregationInterval: Interval, + modelPlotEnabled: boolean, + functionDescription?: string + ) { + const timesToAddPointsFor: number[] = []; + + // Iterate through the anomaly records making sure we have chart points for each anomaly. + const intervalMs = aggregationInterval.asMilliseconds(); + let lastChartDataPointTime: any; + if (chartData !== undefined && chartData.length > 0) { + lastChartDataPointTime = chartData[chartData.length - 1].date.getTime(); + } + anomalyRecords.forEach((record: MlAnomalyRecordDoc) => { + const recordTime = record[TIME_FIELD_NAME]; + const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); + if (chartPoint === undefined) { + const timeToAdd = Math.floor(recordTime / intervalMs) * intervalMs; + if (timesToAddPointsFor.indexOf(timeToAdd) === -1 && timeToAdd !== lastChartDataPointTime) { + timesToAddPointsFor.push(timeToAdd); + } + } + }); + + timesToAddPointsFor.sort((a, b) => a - b); + + timesToAddPointsFor.forEach((time) => { + const pointToAdd: ChartDataPoint = { + date: new Date(time), + value: null, + }; + + if (modelPlotEnabled === true) { + pointToAdd.upper = null; + pointToAdd.lower = null; + } + chartData.push(pointToAdd); + }); + + // Iterate through the anomaly records adding the + // various properties required for display. + anomalyRecords.forEach((record) => { + // Look for a chart point with the same time as the record. + // If none found, find closest time in chartData set. + const recordTime = record[TIME_FIELD_NAME]; + if ( + record.function === ML_JOB_AGGREGATION.METRIC && + record.function_description !== functionDescription + ) + return; + + const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); + if (chartPoint !== undefined) { + // If chart aggregation interval > bucket span, there may be more than + // one anomaly record in the interval, so use the properties from + // the record with the highest anomalyScore. + const recordScore = record.record_score; + const pointScore = chartPoint.anomalyScore; + if (pointScore === undefined || pointScore < recordScore) { + chartPoint.anomalyScore = recordScore; + chartPoint.function = record.function; + + if (record.actual !== undefined) { + // If cannot match chart point for anomaly time + // substitute the value with the record's actual so it won't plot as null/0 + if (chartPoint.value === null || record.function === ML_JOB_AGGREGATION.METRIC) { + chartPoint.value = Array.isArray(record.actual) ? record.actual[0] : record.actual; + } + + chartPoint.actual = record.actual; + chartPoint.typical = record.typical; + } else { + const causes = get(record, 'causes', []); + if (causes.length > 0) { + chartPoint.byFieldName = record.by_field_name; + chartPoint.numberOfCauses = causes.length; + if (causes.length === 1) { + // If only a single cause, copy actual and typical values to the top level. + const cause = record.causes![0]; + chartPoint.actual = cause.actual; + chartPoint.typical = cause.typical; + // substitute the value with the record's actual so it won't plot as null/0 + if (chartPoint.value === null) { + chartPoint.value = cause.actual; + } + } + } + } + + if ( + record.anomaly_score_explanation !== undefined && + record.anomaly_score_explanation.multi_bucket_impact !== undefined + ) { + chartPoint.multiBucketImpact = record.anomaly_score_explanation.multi_bucket_impact; + } + + chartPoint.isMultiBucketAnomaly = isMultiBucketAnomaly(record); + } + } + }); + + return chartData; + } + + function findChartPointForScheduledEvent(chartData: any, eventTime: any) { + let chartPoint; + if (chartData === undefined) { + return chartPoint; + } + + for (let i = 0; i < chartData.length; i++) { + if (chartData[i].date.getTime() === eventTime) { + chartPoint = chartData[i]; + break; + } + } + + return chartPoint; + } + // Adds a scheduledEvents property to any points in the chart data set + // which correspond to times of scheduled events for the job. + function processScheduledEventsForChart( + chartData: ChartDataPoint[], + scheduledEvents: Array<{ events: any; time: number }> | undefined, + aggregationInterval: TimeBucketsInterval + ) { + if (scheduledEvents !== undefined) { + const timesToAddPointsFor: number[] = []; + + // Iterate through the scheduled events making sure we have a chart point for each event. + const intervalMs = aggregationInterval.asMilliseconds(); + let lastChartDataPointTime: number | undefined; + if (chartData !== undefined && chartData.length > 0) { + lastChartDataPointTime = chartData[chartData.length - 1].date.getTime(); + } + + // In case there's no chart data/sparse data during these scheduled events + // ensure we add chart points at every aggregation interval for these scheduled events. + let sortRequired = false; + each(scheduledEvents, (events, time) => { + const exactChartPoint = findChartPointForScheduledEvent(chartData, +time); + + if (exactChartPoint !== undefined) { + exactChartPoint.scheduledEvents = events; + } else { + const timeToAdd: number = Math.floor(time / intervalMs) * intervalMs; + if ( + timesToAddPointsFor.indexOf(timeToAdd) === -1 && + timeToAdd !== lastChartDataPointTime + ) { + const pointToAdd = { + date: new Date(timeToAdd), + value: null, + scheduledEvents: events, + }; + + chartData.push(pointToAdd); + sortRequired = true; + } + } + }); + + // Sort chart data by time if extra points were added at the end of the array for scheduled events. + if (sortRequired) { + chartData.sort((a, b) => a.date.getTime() - b.date.getTime()); + } + } + + return chartData; + } + + function getFocusData( + criteriaFields: CriteriaField[], + detectorIndex: number, + focusAggregationInterval: TimeBucketsInterval, + forecastId: string, + modelPlotEnabled: boolean, + nonBlankEntities: any[], + searchBounds: any, + selectedJob: Job, + functionDescription?: string | undefined + ): Observable { + const esFunctionToPlotIfMetric = + functionDescription !== undefined + ? aggregationTypeTransform.toES(functionDescription) + : functionDescription; + + return forkJoin([ + // Query 1 - load metric data across selected time range. + mlTimeSeriesSearchService.getMetricData( + selectedJob, + detectorIndex, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.asMilliseconds(), + esFunctionToPlotIfMetric + ), + // Query 2 - load all the records across selected time range for the chart anomaly markers. + mlApiServices.results.getAnomalyRecords$( + [selectedJob.job_id], + criteriaFields, + 0, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.expression, + functionDescription + ), + // Query 3 - load any scheduled events for the selected job. + mlResultsService.getScheduledEventsByBucket( + [selectedJob.job_id], + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.asMilliseconds(), + 1, + MAX_SCHEDULED_EVENTS + ), + // Query 4 - load any annotations for the selected job. + mlApiServices.annotations + .getAnnotations$({ + jobIds: [selectedJob.job_id], + earliestMs: searchBounds.min.valueOf(), + latestMs: searchBounds.max.valueOf(), + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + detectorIndex, + entities: nonBlankEntities, + }) + .pipe( + catchError((resp) => + of({ + annotations: {}, + totalCount: 0, + error: extractErrorMessage(resp), + success: false, + } as GetAnnotationsResponse) + ) + ), + // Plus query for forecast data if there is a forecastId stored in the appState. + forecastId !== undefined + ? (() => { + let aggType; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + const esAgg = mlFunctionToESAggregation(detector.function); + if (!modelPlotEnabled && (esAgg === 'sum' || esAgg === 'count')) { + aggType = { avg: 'sum', max: 'sum', min: 'sum' }; + } + return mlForecastService.getForecastData( + selectedJob, + detectorIndex, + forecastId, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.asMilliseconds(), + aggType + ); + })() + : of(null), + ]).pipe( + map( + ([metricData, recordsForCriteria, scheduledEventsByBucket, annotations, forecastData]) => { + // Sort in descending time order before storing in scope. + const anomalyRecords = recordsForCriteria?.records + .sort((a, b) => a[TIME_FIELD_NAME] - b[TIME_FIELD_NAME]) + .reverse(); + + const scheduledEvents = scheduledEventsByBucket?.events[selectedJob.job_id]; + + let focusChartData = processMetricPlotResults(metricData.results, modelPlotEnabled); + // Tell the results container directives to render the focus chart. + focusChartData = processDataForFocusAnomalies( + focusChartData, + anomalyRecords, + focusAggregationInterval, + modelPlotEnabled, + functionDescription + ); + focusChartData = processScheduledEventsForChart( + focusChartData, + scheduledEvents, + focusAggregationInterval + ); + + const refreshFocusData: FocusData = { + scheduledEvents, + anomalyRecords, + focusChartData, + }; + + if (annotations) { + if (annotations.error !== undefined) { + refreshFocusData.focusAnnotationError = annotations.error; + refreshFocusData.focusAnnotationData = []; + } else { + refreshFocusData.focusAnnotationData = ( + annotations.annotations[selectedJob.job_id] ?? [] + ) + .sort((a, b) => { + return a.timestamp - b.timestamp; + }) + .map((d, i: number) => { + d.key = (i + 1).toString(); + return d; + }); + } + } + + if (forecastData) { + refreshFocusData.focusForecastData = processForecastResults(forecastData.results); + refreshFocusData.showForecastCheckbox = refreshFocusData.focusForecastData.length > 0; + } + return refreshFocusData; + } + ) + ); + } + + return { + getAutoZoomDuration, + calculateAggregationInterval, + calculateInitialFocusRange, + calculateDefaultFocusRange, + processRecordScoreResults, + processMetricPlotResults, + processForecastResults, + findChartPointForAnomalyTime, + processDataForFocusAnomalies, + findChartPointForScheduledEvent, + processScheduledEventsForChart, + getFocusData, + }; +} + +export function useTimeSeriesExplorerService(): TimeSeriesExplorerService { + const { + services: { + uiSettings, + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + const mlResultsService = mlResultsServiceProvider(mlApiServices); + + const mlTimeSeriesExplorer = useMemo( + () => timeSeriesExplorerServiceFactory(uiSettings, mlApiServices, mlResultsService), + [uiSettings, mlApiServices, mlResultsService] + ); + return mlTimeSeriesExplorer; +} + +export type TimeSeriesExplorerService = ReturnType; diff --git a/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx index 00c4a02d4e929..182d070266c9a 100644 --- a/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx +++ b/x-pack/plugins/ml/public/embeddables/common/resolve_job_selection.tsx @@ -26,7 +26,8 @@ import { JobSelectorFlyout } from './components/job_selector_flyout'; */ export async function resolveJobSelection( coreStart: CoreStart, - selectedJobIds?: JobId[] + selectedJobIds?: JobId[], + singleSelection: boolean = false ): Promise<{ jobIds: string[]; groups: Array<{ groupId: string; jobIds: string[] }> }> { const { http, @@ -74,7 +75,7 @@ export async function resolveJobSelection( selectedIds={selectedJobIds} withTimeRangeSelector={false} dateFormatTz={dateFormatTz} - singleSelection={false} + singleSelection={singleSelection} timeseriesOnly={true} onFlyoutClose={onFlyoutClose} onSelectionConfirmed={onSelectionConfirmed} diff --git a/x-pack/plugins/ml/public/embeddables/constants.ts b/x-pack/plugins/ml/public/embeddables/constants.ts index cfe50f25cd889..1001cd89c7498 100644 --- a/x-pack/plugins/ml/public/embeddables/constants.ts +++ b/x-pack/plugins/ml/public/embeddables/constants.ts @@ -7,6 +7,7 @@ export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane' as const; export const ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE = 'ml_anomaly_charts' as const; +export const ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE = 'ml_single_metric_viewer' as const; export type AnomalySwimLaneEmbeddableType = typeof ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; export type AnomalyExplorerChartsEmbeddableType = typeof ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE; diff --git a/x-pack/plugins/ml/public/embeddables/index.ts b/x-pack/plugins/ml/public/embeddables/index.ts index 0a505fe04ea85..9f0d2d75b1162 100644 --- a/x-pack/plugins/ml/public/embeddables/index.ts +++ b/x-pack/plugins/ml/public/embeddables/index.ts @@ -9,6 +9,7 @@ import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane'; import type { MlCoreSetup } from '../plugin'; import { AnomalyChartsEmbeddableFactory } from './anomaly_charts'; +import { SingleMetricViewerEmbeddableFactory } from './single_metric_viewer'; export * from './constants'; export * from './types'; @@ -25,6 +26,8 @@ export function registerEmbeddables(embeddable: EmbeddableSetup, core: MlCoreSet ); const anomalyChartsFactory = new AnomalyChartsEmbeddableFactory(core.getStartServices); - embeddable.registerEmbeddableFactory(anomalyChartsFactory.type, anomalyChartsFactory); + + const singleMetricViewerFactory = new SingleMetricViewerEmbeddableFactory(core.getStartServices); + embeddable.registerEmbeddableFactory(singleMetricViewerFactory.type, singleMetricViewerFactory); } diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/_index.scss b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/_index.scss new file mode 100644 index 0000000000000..b6f91cc749dcc --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/_index.scss @@ -0,0 +1,6 @@ +// ML has it's own variables for coloring +@import '../../application/variables'; + +// Protect the rest of Kibana from ML generic namespacing +@import '../../application/timeseriesexplorer/timeseriesexplorer'; +@import '../../application/timeseriesexplorer/timeseriesexplorer_annotations'; \ No newline at end of file diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container.tsx new file mode 100644 index 0000000000000..88c120c9747e1 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { EuiResizeObserver } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import { throttle } from 'lodash'; +import { MlJob } from '@elastic/elasticsearch/lib/api/types'; +import usePrevious from 'react-use/lib/usePrevious'; +import { useToastNotificationService } from '../../application/services/toast_notification_service'; +import { useEmbeddableExecutionContext } from '../common/use_embeddable_execution_context'; +import { useSingleMetricViewerInputResolver } from './use_single_metric_viewer_input_resolver'; +import type { ISingleMetricViewerEmbeddable } from './single_metric_viewer_embeddable'; +import type { + SingleMetricViewerEmbeddableInput, + AnomalyChartsEmbeddableOutput, + SingleMetricViewerEmbeddableServices, +} from '..'; +import { ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE } from '..'; +import { TimeSeriesExplorerEmbeddableChart } from '../../application/timeseriesexplorer/timeseriesexplorer_embeddable_chart'; +import { APP_STATE_ACTION } from '../../application/timeseriesexplorer/timeseriesexplorer_constants'; +import { useTimeSeriesExplorerService } from '../../application/util/time_series_explorer_service'; +import './_index.scss'; + +const RESIZE_THROTTLE_TIME_MS = 500; + +interface AppStateZoom { + from?: string; + to?: string; +} + +export interface EmbeddableSingleMetricViewerContainerProps { + id: string; + embeddableContext: InstanceType; + embeddableInput: Observable; + services: SingleMetricViewerEmbeddableServices; + refresh: Observable; + onInputChange: (input: Partial) => void; + onOutputChange: (output: Partial) => void; + onRenderComplete: () => void; + onLoading: () => void; + onError: (error: Error) => void; +} + +export const EmbeddableSingleMetricViewerContainer: FC< + EmbeddableSingleMetricViewerContainerProps +> = ({ + id, + embeddableContext, + embeddableInput, + services, + refresh, + onInputChange, + onOutputChange, + onRenderComplete, + onError, + onLoading, +}) => { + useEmbeddableExecutionContext( + services[0].executionContext, + embeddableInput, + ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE, + id + ); + const [chartWidth, setChartWidth] = useState(0); + const [zoom, setZoom] = useState(); + const [selectedForecastId, setSelectedForecastId] = useState(); + const [detectorIndex, setDetectorIndex] = useState(0); + const [selectedJob, setSelectedJob] = useState(); + const [autoZoomDuration, setAutoZoomDuration] = useState(); + + const { mlApiServices } = services[2]; + const { data, bounds, lastRefresh } = useSingleMetricViewerInputResolver( + embeddableInput, + refresh, + services[1].data.query.timefilter.timefilter, + onRenderComplete + ); + const selectedJobId = data?.jobIds[0]; + const previousRefresh = usePrevious(lastRefresh ?? 0); + const mlTimeSeriesExplorer = useTimeSeriesExplorerService(); + + // Holds the container height for previously fetched data + const containerHeightRef = useRef(); + const toastNotificationService = useToastNotificationService(); + + useEffect( + function setUpSelectedJob() { + async function fetchSelectedJob() { + if (mlApiServices && selectedJobId !== undefined) { + const { jobs } = await mlApiServices.getJobs({ jobId: selectedJobId }); + const job = jobs[0]; + setSelectedJob(job); + } + } + fetchSelectedJob(); + }, + [selectedJobId, mlApiServices] + ); + + useEffect( + function setUpAutoZoom() { + let zoomDuration: number | undefined; + if (selectedJobId !== undefined && selectedJob !== undefined) { + zoomDuration = mlTimeSeriesExplorer.getAutoZoomDuration(selectedJob); + setAutoZoomDuration(zoomDuration); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedJobId, selectedJob?.job_id, mlTimeSeriesExplorer] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const resizeHandler = useCallback( + throttle((e: { width: number; height: number }) => { + // Keep previous container height so it doesn't change the page layout + containerHeightRef.current = e.height; + + if (Math.abs(chartWidth - e.width) > 20) { + setChartWidth(e.width); + } + }, RESIZE_THROTTLE_TIME_MS), + [chartWidth] + ); + + const appStateHandler = useCallback( + (action: string, payload?: any) => { + /** + * Empty zoom indicates that chart hasn't been rendered yet, + * hence any updates prior that should replace the URL state. + */ + + switch (action) { + case APP_STATE_ACTION.SET_DETECTOR_INDEX: + setDetectorIndex(payload); + break; + + case APP_STATE_ACTION.SET_FORECAST_ID: + setSelectedForecastId(payload); + setZoom(undefined); + break; + + case APP_STATE_ACTION.SET_ZOOM: + setZoom(payload); + break; + + case APP_STATE_ACTION.UNSET_ZOOM: + setZoom(undefined); + break; + } + }, + + [setZoom, setDetectorIndex, setSelectedForecastId] + ); + + const containerPadding = 10; + + return ( + + {(resizeRef) => ( +

+ {data !== undefined && autoZoomDuration !== undefined && ( + + )} +
+ )} + + ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default EmbeddableSingleMetricViewerContainer; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container_lazy.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container_lazy.tsx new file mode 100644 index 0000000000000..0a69aaf2c2deb --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/embeddable_single_metric_viewer_container_lazy.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const EmbeddableSingleMetricViewerContainer = React.lazy( + () => import('./embeddable_single_metric_viewer_container') +); diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/index.ts b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/index.ts new file mode 100644 index 0000000000000..9afdbe3d1298c --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SingleMetricViewerEmbeddableFactory } from './single_metric_viewer_embeddable_factory'; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable.tsx new file mode 100644 index 0000000000000..82a1b5abc8b63 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom'; +import { pick } from 'lodash'; + +import { Embeddable } from '@kbn/embeddable-plugin/public'; + +import { CoreStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { Subject } from 'rxjs'; +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { IContainer } from '@kbn/embeddable-plugin/public'; +import { DatePickerContextProvider, type DatePickerDependencies } from '@kbn/ml-date-picker'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { EmbeddableSingleMetricViewerContainer } from './embeddable_single_metric_viewer_container_lazy'; +import type { JobId } from '../../../common/types/anomaly_detection_jobs'; +import type { MlDependencies } from '../../application/app'; +import { + ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE, + SingleMetricViewerEmbeddableInput, + AnomalyChartsEmbeddableOutput, + SingleMetricViewerServices, +} from '..'; +import { EmbeddableLoading } from '../common/components/embeddable_loading_fallback'; + +export const getDefaultSingleMetricViewerPanelTitle = (jobIds: JobId[]) => + i18n.translate('xpack.ml.singleMetricViewerEmbeddable.title', { + defaultMessage: 'ML single metric viewer chart for {jobIds}', + values: { jobIds: jobIds.join(', ') }, + }); + +export type ISingleMetricViewerEmbeddable = typeof SingleMetricViewerEmbeddable; + +export class SingleMetricViewerEmbeddable extends Embeddable< + SingleMetricViewerEmbeddableInput, + AnomalyChartsEmbeddableOutput +> { + private node?: HTMLElement; + private reload$ = new Subject(); + public readonly type: string = ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE; + + constructor( + initialInput: SingleMetricViewerEmbeddableInput, + public services: [CoreStart, MlDependencies, SingleMetricViewerServices], + parent?: IContainer + ) { + super(initialInput, {} as AnomalyChartsEmbeddableOutput, parent); + } + + public onLoading() { + this.renderComplete.dispatchInProgress(); + this.updateOutput({ loading: true, error: undefined }); + } + + public onError(error: Error) { + this.renderComplete.dispatchError(); + this.updateOutput({ loading: false, error: { name: error.name, message: error.message } }); + } + + public onRenderComplete() { + this.renderComplete.dispatchComplete(); + this.updateOutput({ loading: false, error: undefined }); + } + + public render(node: HTMLElement) { + super.render(node); + this.node = node; + + // required for the export feature to work + this.node.setAttribute('data-shared-item', ''); + + const I18nContext = this.services[0].i18n.Context; + const theme$ = this.services[0].theme.theme$; + + const datePickerDeps: DatePickerDependencies = { + ...pick(this.services[0], ['http', 'notifications', 'theme', 'uiSettings', 'i18n']), + data: this.services[1].data, + uiSettingsKeys: UI_SETTINGS, + showFrozenDataTierChoice: false, + }; + + ReactDOM.render( + + + + + }> + + + + + + , + node + ); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public reload() { + this.reload$.next(); + } + + public supportedTriggers() { + return []; + } +} diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.ts new file mode 100644 index 0000000000000..06b2f9b024bfa --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import type { StartServicesAccessor } from '@kbn/core/public'; +import type { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; + +import { PLUGIN_ICON, PLUGIN_ID, ML_APP_NAME } from '../../../common/constants/app'; +import { + ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE, + SingleMetricViewerEmbeddableInput, + SingleMetricViewerEmbeddableServices, +} from '..'; +import type { MlPluginStart, MlStartDependencies } from '../../plugin'; +import type { MlDependencies } from '../../application/app'; +import { HttpService } from '../../application/services/http_service'; +import { AnomalyExplorerChartsService } from '../../application/services/anomaly_explorer_charts_service'; + +export class SingleMetricViewerEmbeddableFactory + implements EmbeddableFactoryDefinition +{ + public readonly type = ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE; + + public readonly grouping = [ + { + id: PLUGIN_ID, + getDisplayName: () => ML_APP_NAME, + getIconType: () => PLUGIN_ICON, + }, + ]; + + constructor( + private getStartServices: StartServicesAccessor + ) {} + + public async isEditable() { + return true; + } + + public getDisplayName() { + return i18n.translate('xpack.ml.components.mlSingleMetricViewerEmbeddable.displayName', { + defaultMessage: 'Single metric viewer', + }); + } + + public getDescription() { + return i18n.translate('xpack.ml.components.mlSingleMetricViewerEmbeddable.description', { + defaultMessage: 'View anomaly detection single metric results in a chart.', + }); + } + + public async getExplicitInput(): Promise> { + const [coreStart, pluginStart, singleMetricServices] = await this.getServices(); + + try { + const { resolveEmbeddableSingleMetricViewerUserInput } = await import( + './single_metric_viewer_setup_flyout' + ); + return await resolveEmbeddableSingleMetricViewerUserInput( + coreStart, + pluginStart, + singleMetricServices + ); + } catch (e) { + return Promise.reject(); + } + } + + private async getServices(): Promise { + const [ + [coreStart, pluginsStart], + { AnomalyDetectorService }, + { fieldFormatServiceFactory }, + { indexServiceFactory }, + { mlApiServicesProvider }, + { mlResultsServiceProvider }, + { timeSeriesSearchServiceFactory }, + ] = await Promise.all([ + await this.getStartServices(), + await import('../../application/services/anomaly_detector_service'), + await import('../../application/services/field_format_service_factory'), + await import('../../application/util/index_service'), + await import('../../application/services/ml_api_service'), + await import('../../application/services/results_service'), + await import( + '../../application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service' + ), + ]); + + const httpService = new HttpService(coreStart.http); + const anomalyDetectorService = new AnomalyDetectorService(httpService); + const mlApiServices = mlApiServicesProvider(httpService); + const mlResultsService = mlResultsServiceProvider(mlApiServices); + const mlIndexUtils = indexServiceFactory(pluginsStart.data.dataViews); + const mlTimeSeriesSearchService = timeSeriesSearchServiceFactory( + mlResultsService, + mlApiServices + ); + const mlFieldFormatService = fieldFormatServiceFactory(mlApiServices, mlIndexUtils); + + const anomalyExplorerService = new AnomalyExplorerChartsService( + pluginsStart.data.query.timefilter.timefilter, + mlApiServices, + mlResultsService + ); + + return [ + coreStart, + pluginsStart as MlDependencies, + { + anomalyDetectorService, + anomalyExplorerService, + mlResultsService, + mlApiServices, + mlTimeSeriesSearchService, + mlFieldFormatService, + }, + ]; + } + + public async create(initialInput: SingleMetricViewerEmbeddableInput, parent?: IContainer) { + const services = await this.getServices(); + const { SingleMetricViewerEmbeddable } = await import('./single_metric_viewer_embeddable'); + return new SingleMetricViewerEmbeddable(initialInput, services, parent); + } +} diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_initializer.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_initializer.tsx new file mode 100644 index 0000000000000..89af056068063 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_initializer.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiFieldText, + EuiModal, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { MlJob } from '@elastic/elasticsearch/lib/api/types'; +import type { SingleMetricViewerServices } from '..'; +import { TimeRangeBounds } from '../../application/util/time_buckets'; +import { SeriesControls } from '../../application/timeseriesexplorer/components/series_controls'; +import { + APP_STATE_ACTION, + type TimeseriesexplorerActionType, +} from '../../application/timeseriesexplorer/timeseriesexplorer_constants'; + +export interface SingleMetricViewerInitializerProps { + bounds: TimeRangeBounds; + defaultTitle: string; + initialInput?: SingleMetricViewerServices; + job: MlJob; + onCreate: (props: { + panelTitle: string; + functionDescription?: string; + selectedDetectorIndex: number; + selectedEntities: any; + }) => void; + onCancel: () => void; +} + +export const SingleMetricViewerInitializer: FC = ({ + bounds, + defaultTitle, + initialInput, + job, + onCreate, + onCancel, +}) => { + const [panelTitle, setPanelTitle] = useState(defaultTitle); + const [functionDescription, setFunctionDescription] = useState(); + const [selectedDetectorIndex, setSelectedDetectorIndex] = useState(0); + const [selectedEntities, setSelectedEntities] = useState(); + + const isPanelTitleValid = panelTitle.length > 0; + + const handleStateUpdate = (action: TimeseriesexplorerActionType, payload: any) => { + switch (action) { + case APP_STATE_ACTION.SET_ENTITIES: + setSelectedEntities(payload); + break; + case APP_STATE_ACTION.SET_FUNCTION_DESCRIPTION: + setFunctionDescription(payload); + break; + case APP_STATE_ACTION.SET_DETECTOR_INDEX: + setSelectedDetectorIndex(payload); + break; + default: + break; + } + }; + + return ( + + + + + + + + + + + } + isInvalid={!isPanelTitleValid} + > + setPanelTitle(e.target.value)} + isInvalid={!isPanelTitleValid} + /> + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx new file mode 100644 index 0000000000000..e9822c01f865a --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_setup_flyout.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { CoreStart } from '@kbn/core/public'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { getDefaultSingleMetricViewerPanelTitle } from './single_metric_viewer_embeddable'; +import type { SingleMetricViewerEmbeddableInput, SingleMetricViewerServices } from '..'; +import { resolveJobSelection } from '../common/resolve_job_selection'; +import { SingleMetricViewerInitializer } from './single_metric_viewer_initializer'; +import type { MlStartDependencies } from '../../plugin'; + +export async function resolveEmbeddableSingleMetricViewerUserInput( + coreStart: CoreStart, + pluginStart: MlStartDependencies, + input: SingleMetricViewerServices +): Promise> { + const { overlays, theme, i18n } = coreStart; + const { mlApiServices } = input; + const timefilter = pluginStart.data.query.timefilter.timefilter; + + return new Promise(async (resolve, reject) => { + try { + const { jobIds } = await resolveJobSelection(coreStart, undefined, true); + const title = getDefaultSingleMetricViewerPanelTitle(jobIds); + const { jobs } = await mlApiServices.getJobs({ jobId: jobIds.join(',') }); + + const modalSession = overlays.openModal( + toMountPoint( + + { + modalSession.close(); + resolve({ + jobIds, + title: panelTitle, + functionDescription, + panelTitle, + selectedDetectorIndex, + selectedEntities, + }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + , + { theme, i18n } + ) + ); + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/use_single_metric_viewer_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/use_single_metric_viewer_input_resolver.ts new file mode 100644 index 0000000000000..c9f9d57fd7803 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/use_single_metric_viewer_input_resolver.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState } from 'react'; +import { combineLatest, Observable } from 'rxjs'; +import { startWith } from 'rxjs/operators'; +import { TimefilterContract } from '@kbn/data-plugin/public'; +import { SingleMetricViewerEmbeddableInput } from '..'; +import type { TimeRangeBounds } from '../../application/util/time_buckets'; + +export function useSingleMetricViewerInputResolver( + embeddableInput: Observable, + refresh: Observable, + timefilter: TimefilterContract, + onRenderComplete: () => void +) { + const [data, setData] = useState(); + const [bounds, setBounds] = useState(); + const [lastRefresh, setLastRefresh] = useState(); + + useEffect(function subscribeToEmbeddableInput() { + const subscription = combineLatest([embeddableInput, refresh.pipe(startWith(null))]).subscribe( + (input) => { + if (input !== undefined) { + setData(input[0]); + if (timefilter !== undefined) { + const { timeRange } = input[0]; + const currentBounds = timefilter.calculateBounds(timeRange); + setBounds(currentBounds); + setLastRefresh(Date.now()); + } + onRenderComplete(); + } + } + ); + + return () => subscription.unsubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { data, bounds, lastRefresh }; +} diff --git a/x-pack/plugins/ml/public/embeddables/types.ts b/x-pack/plugins/ml/public/embeddables/types.ts index 48a7b5d43a5ac..56a33f488d534 100644 --- a/x-pack/plugins/ml/public/embeddables/types.ts +++ b/x-pack/plugins/ml/public/embeddables/types.ts @@ -27,6 +27,9 @@ import { MlEmbeddableTypes, } from './constants'; import { MlResultsService } from '../application/services/results_service'; +import type { MlApiServices } from '../application/services/ml_api_service'; +import type { MlFieldFormatService } from '../application/services/field_format_service'; +import type { MlTimeSeriesSeachService } from '../application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service'; export interface AnomalySwimlaneEmbeddableCustomInput { jobIds: JobId[]; @@ -100,13 +103,45 @@ export interface AnomalyChartsEmbeddableCustomInput { export type AnomalyChartsEmbeddableInput = EmbeddableInput & AnomalyChartsEmbeddableCustomInput; +export interface SingleMetricViewerEmbeddableCustomInput { + jobIds: JobId[]; + title: string; + functionDescription?: string; + panelTitle: string; + selectedDetectorIndex: number; + selectedEntities: MlEntityField[]; + // Embeddable inputs which are not included in the default interface + filters: Filter[]; + query: Query; + refreshConfig: RefreshInterval; + timeRange: TimeRange; +} + +export type SingleMetricViewerEmbeddableInput = EmbeddableInput & + SingleMetricViewerEmbeddableCustomInput; + export interface AnomalyChartsServices { anomalyDetectorService: AnomalyDetectorService; anomalyExplorerService: AnomalyExplorerChartsService; mlResultsService: MlResultsService; + mlApiServices?: MlApiServices; +} + +export interface SingleMetricViewerServices { + anomalyExplorerService: AnomalyExplorerChartsService; + anomalyDetectorService: AnomalyDetectorService; + mlApiServices: MlApiServices; + mlFieldFormatService: MlFieldFormatService; + mlResultsService: MlResultsService; + mlTimeSeriesSearchService?: MlTimeSeriesSeachService; } export type AnomalyChartsEmbeddableServices = [CoreStart, MlDependencies, AnomalyChartsServices]; +export type SingleMetricViewerEmbeddableServices = [ + CoreStart, + MlDependencies, + SingleMetricViewerServices +]; export interface AnomalyChartsCustomOutput { entityFields?: MlEntityField[]; severity?: number; diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/recall.ts b/x-pack/plugins/observability_ai_assistant/server/functions/recall.ts index ee0fae1f91ed1..909a823286cc6 100644 --- a/x-pack/plugins/observability_ai_assistant/server/functions/recall.ts +++ b/x-pack/plugins/observability_ai_assistant/server/functions/recall.ts @@ -9,7 +9,7 @@ import { decodeOrThrow, jsonRt } from '@kbn/io-ts-utils'; import type { Serializable } from '@kbn/utility-types'; import dedent from 'dedent'; import * as t from 'io-ts'; -import { last, omit } from 'lodash'; +import { compact, last, omit } from 'lodash'; import { lastValueFrom } from 'rxjs'; import { FunctionRegistrationParameters } from '.'; import { MessageRole, type Message } from '../../common/types'; @@ -88,12 +88,17 @@ export function registerRecallFunction({ messages.filter((message) => message.message.role === MessageRole.User) ); + const nonEmptyQueries = compact(queries); + + const queriesOrUserPrompt = nonEmptyQueries.length + ? nonEmptyQueries + : compact([userMessage?.message.content]); + const suggestions = await retrieveSuggestions({ userMessage, client, - signal, categories, - queries, + queries: queriesOrUserPrompt, }); if (suggestions.length === 0) { @@ -104,9 +109,8 @@ export function registerRecallFunction({ const relevantDocuments = await scoreSuggestions({ suggestions, - systemMessage, - userMessage, - queries, + queries: queriesOrUserPrompt, + messages, client, connectorId, signal, @@ -121,25 +125,17 @@ export function registerRecallFunction({ } async function retrieveSuggestions({ - userMessage, queries, client, categories, - signal, }: { userMessage?: Message; queries: string[]; client: ObservabilityAIAssistantClient; categories: Array<'apm' | 'lens'>; - signal: AbortSignal; }) { - const queriesWithUserPrompt = - userMessage && userMessage.message.content - ? [userMessage.message.content, ...queries] - : queries; - const recallResponse = await client.recall({ - queries: queriesWithUserPrompt, + queries, categories, }); @@ -156,18 +152,12 @@ const scoreFunctionRequestRt = t.type({ }); const scoreFunctionArgumentsRt = t.type({ - scores: t.array( - t.type({ - id: t.string, - score: t.number, - }) - ), + scores: t.string, }); async function scoreSuggestions({ suggestions, - systemMessage, - userMessage, + messages, queries, client, connectorId, @@ -175,35 +165,31 @@ async function scoreSuggestions({ resources, }: { suggestions: Awaited>; - systemMessage: Message; - userMessage?: Message; + messages: Message[]; queries: string[]; client: ObservabilityAIAssistantClient; connectorId: string; signal: AbortSignal; resources: RespondFunctionResources; }) { - resources.logger.debug(`Suggestions: ${JSON.stringify(suggestions, null, 2)}`); + const indexedSuggestions = suggestions.map((suggestion, index) => ({ ...suggestion, id: index })); - const systemMessageExtension = - dedent(`You have the function called score available to help you inform the user about how relevant you think a given document is to the conversation. - Please give a score between 1 and 7, fractions are allowed. - A higher score means it is more relevant.`); - const extendedSystemMessage = { - ...systemMessage, - message: { - ...systemMessage.message, - content: `${systemMessage.message.content}\n\n${systemMessageExtension}`, - }, - }; + const newUserMessageContent = + dedent(`Given the following question, score the documents that are relevant to the question. on a scale from 0 to 7, + 0 being completely relevant, and 7 being extremely relevant. Information is relevant to the question if it helps in + answering the question. Judge it according to the following criteria: - const userMessageOrQueries = - userMessage && userMessage.message.content ? userMessage.message.content : queries.join(','); + - The document is relevant to the question, and the rest of the conversation + - The document has information relevant to the question that is not mentioned, + or more detailed than what is available in the conversation + - The document has a high amount of information relevant to the question compared to other documents + - The document contains new information not mentioned before in the conversation - const newUserMessageContent = - dedent(`Given the question "${userMessageOrQueries}", can you give me a score for how relevant the following documents are? + Question: + ${queries.join('\n')} - ${JSON.stringify(suggestions, null, 2)}`); + Documents: + ${JSON.stringify(indexedSuggestions, null, 2)}`); const newUserMessage: Message = { '@timestamp': new Date().toISOString(), @@ -222,22 +208,13 @@ async function scoreSuggestions({ additionalProperties: false, properties: { scores: { - description: 'The document IDs and their scores', - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - id: { - description: 'The ID of the document', - type: 'string', - }, - score: { - description: 'The score for the document', - type: 'number', - }, - }, - }, + description: `The document IDs and their scores, as CSV. Example: + + my_id,7 + my_other_id,3 + my_third_id,4 + `, + type: 'string', }, }, required: ['score'], @@ -249,7 +226,7 @@ async function scoreSuggestions({ ( await client.chat('score_suggestions', { connectorId, - messages: [extendedSystemMessage, newUserMessage], + messages: [...messages.slice(0, -1), newUserMessage], functions: [scoreFunction], functionCall: 'score', signal, @@ -257,11 +234,18 @@ async function scoreSuggestions({ ).pipe(concatenateChatCompletionChunks()) ); const scoreFunctionRequest = decodeOrThrow(scoreFunctionRequestRt)(response); - const { scores } = decodeOrThrow(jsonRt.pipe(scoreFunctionArgumentsRt))( + const { scores: scoresAsString } = decodeOrThrow(jsonRt.pipe(scoreFunctionArgumentsRt))( scoreFunctionRequest.message.function_call.arguments ); - resources.logger.debug(`Scores: ${JSON.stringify(scores, null, 2)}`); + const scores = scoresAsString.split('\n').map((line) => { + const [index, score] = line + .split(',') + .map((value) => value.trim()) + .map(Number); + + return { id: suggestions[index].id, score }; + }); if (scores.length === 0) { return []; diff --git a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts index f3ab3e917979b..afd34aa8ea966 100644 --- a/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/service/client/index.ts @@ -14,7 +14,15 @@ import apm from 'elastic-apm-node'; import { decode, encode } from 'gpt-tokenizer'; import { compact, isEmpty, last, merge, noop, omit, pick, take } from 'lodash'; import type OpenAI from 'openai'; -import { filter, isObservable, lastValueFrom, Observable, shareReplay, toArray } from 'rxjs'; +import { + filter, + firstValueFrom, + isObservable, + lastValueFrom, + Observable, + shareReplay, + toArray, +} from 'rxjs'; import { Readable } from 'stream'; import { v4 } from 'uuid'; import { @@ -455,6 +463,8 @@ export class ObservabilityAIAssistantClient { ): Promise> => { const span = apm.startSpan(`chat ${name}`); + const spanId = (span?.ids['span.id'] || '').substring(0, 6); + const messagesForOpenAI: Array< Omit & { role: MessageRole; @@ -490,6 +500,8 @@ export class ObservabilityAIAssistantClient { this.dependencies.logger.debug(`Sending conversation to connector`); this.dependencies.logger.trace(JSON.stringify(request, null, 2)); + const now = performance.now(); + const executeResult = await this.dependencies.actionsClient.execute({ actionId: connectorId, params: { @@ -501,7 +513,11 @@ export class ObservabilityAIAssistantClient { }, }); - this.dependencies.logger.debug(`Received action client response: ${executeResult.status}`); + this.dependencies.logger.debug( + `Received action client response: ${executeResult.status} (took: ${Math.round( + performance.now() - now + )}ms)${spanId ? ` (${spanId})` : ''}` + ); if (executeResult.status === 'error' && executeResult?.serviceMessage) { const tokenLimitRegex = @@ -524,20 +540,34 @@ export class ObservabilityAIAssistantClient { const observable = streamIntoObservable(response).pipe(processOpenAiStream(), shareReplay()); - if (span) { - lastValueFrom(observable) - .then( - () => { - span.setOutcome('success'); - }, - () => { - span.setOutcome('failure'); - } - ) - .finally(() => { - span.end(); - }); - } + firstValueFrom(observable) + .catch(noop) + .finally(() => { + this.dependencies.logger.debug( + `Received first value after ${Math.round(performance.now() - now)}ms${ + spanId ? ` (${spanId})` : '' + }` + ); + }); + + lastValueFrom(observable) + .then( + () => { + span?.setOutcome('success'); + }, + () => { + span?.setOutcome('failure'); + } + ) + .finally(() => { + this.dependencies.logger.debug( + `Completed response in ${Math.round(performance.now() - now)}ms${ + spanId ? ` (${spanId})` : '' + }` + ); + + span?.end(); + }); return observable; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts index 62772fa029e66..cfa004b7ce6b2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts +++ b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_data.ts @@ -7,6 +7,7 @@ import type { ReactNode } from 'react'; import { useCallback, useMemo } from 'react'; import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { getSentinelOneAgentId } from '../../../common/utils/sentinelone_alert_check'; import type { ThirdPartyAgentInfo } from '../../../../common/types'; import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants'; @@ -74,17 +75,22 @@ export const useResponderActionData = ({ tooltip: ReactNode; } => { const isEndpointHost = agentType === 'endpoint'; + const isSentinelOneHost = agentType === 'sentinel_one'; const showResponseActionsConsole = useWithShowResponder(); + const isSentinelOneV1Enabled = useIsExperimentalFeatureEnabled( + 'responseActionsSentinelOneV1Enabled' + ); const { data: hostInfo, isFetching, error, - } = useGetEndpointDetails(endpointId, { enabled: Boolean(endpointId) }); + } = useGetEndpointDetails(endpointId, { enabled: Boolean(endpointId && isEndpointHost) }); const [isDisabled, tooltip]: [disabled: boolean, tooltip: ReactNode] = useMemo(() => { + // v8.13 disabled for third-party agent alerts if the feature flag is not enabled if (!isEndpointHost) { - return [false, undefined]; + return [isSentinelOneHost ? !isSentinelOneV1Enabled : true, undefined]; } // Still loading host info @@ -114,7 +120,14 @@ export const useResponderActionData = ({ } return [false, undefined]; - }, [isEndpointHost, isFetching, error, hostInfo?.host_status]); + }, [ + isEndpointHost, + isSentinelOneHost, + isSentinelOneV1Enabled, + isFetching, + error, + hostInfo?.host_status, + ]); const handleResponseActionsClick = useCallback(() => { if (!isEndpointHost) { diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx index 7e3aa104f5a60..fd9a63f5547e0 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx @@ -116,6 +116,7 @@ export const EsqlQueryExpression: React.FC< from: new Date(now - timeWindow).toISOString(), to: new Date(now).toISOString(), }, + undefined, // create a data view with the timefield to pass into the query new DataView({ spec: { timeFieldName: timeField }, @@ -219,7 +220,7 @@ export const EsqlQueryExpression: React.FC< }, 1000)} expandCodeEditor={() => true} isCodeEditorExpanded={true} - onTextLangQuerySubmit={() => {}} + onTextLangQuerySubmit={async () => {}} detectTimestamp={detectTimestamp} hideMinimizeButton={true} hideRunQueryText={true} diff --git a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts index 92ae21c7c09c0..8f5c9ae95f8af 100644 --- a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts +++ b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts @@ -60,7 +60,7 @@ export default function ({ getService }: FtrProviderContext) { es_point_to_point: { min: 1, max: 1, total: 1, avg: 0.03571428571428571 }, es_top_hits: { min: 1, max: 1, total: 2, avg: 0.07142857142857142 }, es_agg_heatmap: { min: 1, max: 1, total: 1, avg: 0.03571428571428571 }, - esql: { min: 1, max: 1, total: 1, avg: 0.03571428571428571 }, + esql: { min: 1, max: 1, total: 2, avg: 0.07142857142857142 }, kbn_tms_raster: { min: 1, max: 1, total: 1, avg: 0.03571428571428571 }, ems_basemap: { min: 1, max: 1, total: 1, avg: 0.03571428571428571 }, ems_region: { min: 1, max: 1, total: 1, avg: 0.03571428571428571 }, @@ -81,8 +81,8 @@ export default function ({ getService }: FtrProviderContext) { min: 0, }, dataSourcesCount: { - avg: 1.1785714285714286, - max: 6, + avg: 1.2142857142857142, + max: 7, min: 1, }, emsVectorLayersCount: { @@ -104,8 +104,8 @@ export default function ({ getService }: FtrProviderContext) { min: 1, }, GEOJSON_VECTOR: { - avg: 0.8214285714285714, - max: 5, + avg: 0.8571428571428571, + max: 6, min: 1, }, HEATMAP: { @@ -125,8 +125,8 @@ export default function ({ getService }: FtrProviderContext) { }, }, layersCount: { - avg: 1.2142857142857142, - max: 7, + avg: 1.25, + max: 8, min: 1, }, }, diff --git a/x-pack/test/functional/apps/index_management/index_template_wizard.ts b/x-pack/test/functional/apps/index_management/index_template_wizard.ts index 8776c6de06e40..2a16849f2f5de 100644 --- a/x-pack/test/functional/apps/index_management/index_template_wizard.ts +++ b/x-pack/test/functional/apps/index_management/index_template_wizard.ts @@ -117,13 +117,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.setValue('indexPatternsField', 'test-index-pattern'); // Go to Mappings step - await pageObjects.indexManagement.clickNextButton(); - expect(await testSubjects.getVisibleText('stepTitle')).to.be( - 'Component templates (optional)' - ); - await pageObjects.indexManagement.clickNextButton(); - expect(await testSubjects.getVisibleText('stepTitle')).to.be('Index settings (optional)'); - await pageObjects.indexManagement.clickNextButton(); + await testSubjects.click('formWizardStep-3'); expect(await testSubjects.getVisibleText('stepTitle')).to.be('Mappings (optional)'); }); diff --git a/x-pack/test/functional/apps/maps/group4/layer_errors.js b/x-pack/test/functional/apps/maps/group4/layer_errors.js index e47c0e582c8f4..9f8a570a46d96 100644 --- a/x-pack/test/functional/apps/maps/group4/layer_errors.js +++ b/x-pack/test/functional/apps/maps/group4/layer_errors.js @@ -18,6 +18,20 @@ export default function ({ getPageObjects, getService }) { await PageObjects.maps.loadSavedMap('layer with errors'); }); + describe('Layer with invalid descriptor', () => { + const INVALID_LAYER_NAME = 'fff76ebb-57a6-4067-a373-1d191b9bd1a3'; + + it('should diplay error icon in legend', async () => { + await PageObjects.maps.hasErrorIconExistsOrFail(INVALID_LAYER_NAME); + }); + + it('should allow deletion of layer', async () => { + await PageObjects.maps.removeLayer(INVALID_LAYER_NAME); + const exists = await PageObjects.maps.doesLayerExist(INVALID_LAYER_NAME); + expect(exists).to.be(false); + }); + }); + describe('Layer with EsError', () => { after(async () => { await inspector.close(); diff --git a/x-pack/test/functional/fixtures/kbn_archiver/maps.json b/x-pack/test/functional/fixtures/kbn_archiver/maps.json index 74f27e360cfa1..1ce5bc3fd6aa1 100644 --- a/x-pack/test/functional/fixtures/kbn_archiver/maps.json +++ b/x-pack/test/functional/fixtures/kbn_archiver/maps.json @@ -747,7 +747,7 @@ "version": "WzU1LDFd", "attributes": { "description": "", - "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"KIBANA_TILEMAP\"},\"id\":\"ap0ys\",\"label\":\"Custom_TMS\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"RASTER_TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"idThatDoesNotExitForEMSTile\",\"lightModeDefault\":\"road_map\"},\"temporary\":false,\"id\":\"plw9l\",\"label\":\"EMS_tiles\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"EMS_VECTOR_TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_FILE\",\"id\":\"idThatDoesNotExitForEMSFileSource\"},\"temporary\":false,\"id\":\"2gro0\",\"label\":\"EMS_vector_shapes\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\"},{\"sourceDescriptor\":{\"type\":\"ES_GEO_GRID\",\"id\":\"f67fe707-95dd-46d6-89b8-82617b251b61\",\"geoField\":\"geo.coordinates\",\"requestType\":\"grid\",\"resolution\":\"COARSE\",\"applyGlobalQuery\":true,\"indexPatternRefName\":\"layer_3_source_index_pattern\"},\"temporary\":false,\"id\":\"pl5qd\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"minSize\":4,\"maxSize\":32,\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\"},{\"sourceDescriptor\":{\"id\":\"a07072bb-3a92-4320-bd37-250ef6d04db7\",\"type\":\"ES_SEARCH\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"tooltipProperties\":[],\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\",\"indexPatternRefName\":\"layer_4_source_index_pattern\"},\"temporary\":false,\"id\":\"9bw8h\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\"},{\"id\":\"n1t6f\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"62eca1fc-fe42-11e8-8eb2-f2801f1b9fd1\",\"type\":\"ES_SEARCH\",\"geoField\":\"geometry\",\"limit\":2048,\"filterByMapBounds\":false,\"showTooltip\":true,\"tooltipProperties\":[],\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\",\"indexPatternRefName\":\"layer_5_source_index_pattern\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"max(prop1) group by meta_for_geo_shapes*.shape_name\",\"name\":\"__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"origin\":\"join\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"temporary\":true,\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\",\"joins\":[{\"leftField\":\"name\",\"right\":{\"id\":\"855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"indexPatternTitle\":\"meta_for_geo_shapes*\",\"term\":\"shape_name\",\"metrics\":[{\"type\":\"max\",\"field\":\"prop1\"}],\"applyGlobalQuery\":true,\"type\":\"ES_TERM_SOURCE\",\"indexPatternRefName\":\"layer_5_join_0_index_pattern\"}}]},{\"sourceDescriptor\":{\"geoField\":\"destination\",\"scalingType\":\"LIMIT\",\"id\":\"ed01aac3-c0be-491e-98c9-f1cb6e37f185\",\"type\":\"ES_SEARCH\",\"applyGlobalQuery\":true,\"applyGlobalTime\":true,\"applyForceRefresh\":true,\"filterByMapBounds\":true,\"tooltipProperties\":[],\"sortField\":\"\",\"sortOrder\":\"desc\",\"topHitsGroupByTimeseries\":false,\"topHitsSplitField\":\"\",\"topHitsSize\":1,\"indexPatternRefName\":\"layer_6_source_index_pattern\"},\"id\":\"1cfaa7fa-dc73-419d-b362-7238e2270a1c\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}},\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#54B399\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#41937c\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":0}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":6}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"labelText\":{\"type\":\"STATIC\",\"options\":{\"value\":\"\"}},\"labelColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#000000\"}},\"labelSize\":{\"type\":\"STATIC\",\"options\":{\"size\":14}},\"labelZoomRange\":{\"options\":{\"useLayerZoomRange\":true,\"minZoom\":0,\"maxZoom\":24}},\"labelBorderColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"labelBorderSize\":{\"options\":{\"size\":\"SMALL\"}},\"labelPosition\":{\"options\":{\"position\":\"CENTER\"}}},\"isTimeAware\":true},\"includeInFitToBounds\":true,\"type\":\"GEOJSON_VECTOR\",\"joins\":[],\"disableTooltips\":false}]", + "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"KIBANA_TILEMAP\"},\"id\":\"ap0ys\",\"label\":\"Custom_TMS\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"RASTER_TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"idThatDoesNotExitForEMSTile\",\"lightModeDefault\":\"road_map\"},\"temporary\":false,\"id\":\"plw9l\",\"label\":\"EMS_tiles\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"TILE\",\"properties\":{},\"previousStyle\":null},\"type\":\"EMS_VECTOR_TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_FILE\",\"id\":\"idThatDoesNotExitForEMSFileSource\"},\"temporary\":false,\"id\":\"2gro0\",\"label\":\"EMS_vector_shapes\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\"},{\"sourceDescriptor\":{\"type\":\"ES_GEO_GRID\",\"id\":\"f67fe707-95dd-46d6-89b8-82617b251b61\",\"geoField\":\"geo.coordinates\",\"requestType\":\"grid\",\"resolution\":\"COARSE\",\"applyGlobalQuery\":true,\"indexPatternRefName\":\"layer_3_source_index_pattern\"},\"temporary\":false,\"id\":\"pl5qd\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"minSize\":4,\"maxSize\":32,\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\"},{\"sourceDescriptor\":{\"id\":\"a07072bb-3a92-4320-bd37-250ef6d04db7\",\"type\":\"ES_SEARCH\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"tooltipProperties\":[],\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\",\"indexPatternRefName\":\"layer_4_source_index_pattern\"},\"temporary\":false,\"id\":\"9bw8h\",\"label\":\"\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\"},{\"id\":\"n1t6f\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"62eca1fc-fe42-11e8-8eb2-f2801f1b9fd1\",\"type\":\"ES_SEARCH\",\"geoField\":\"geometry\",\"limit\":2048,\"filterByMapBounds\":false,\"showTooltip\":true,\"tooltipProperties\":[],\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\",\"indexPatternRefName\":\"layer_5_source_index_pattern\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"max(prop1) group by meta_for_geo_shapes*.shape_name\",\"name\":\"__kbnjoin__max_of_prop1__855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"origin\":\"join\"},\"color\":\"Blues\",\"fieldMetaOptions\":{\"isEnabled\":false,\"sigma\":3}}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"temporary\":true,\"previousStyle\":null},\"type\":\"GEOJSON_VECTOR\",\"joins\":[{\"leftField\":\"name\",\"right\":{\"id\":\"855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"indexPatternTitle\":\"meta_for_geo_shapes*\",\"term\":\"shape_name\",\"metrics\":[{\"type\":\"max\",\"field\":\"prop1\"}],\"applyGlobalQuery\":true,\"type\":\"ES_TERM_SOURCE\",\"indexPatternRefName\":\"layer_5_join_0_index_pattern\"}}]},{\"sourceDescriptor\":{\"geoField\":\"destination\",\"scalingType\":\"LIMIT\",\"id\":\"ed01aac3-c0be-491e-98c9-f1cb6e37f185\",\"type\":\"ES_SEARCH\",\"applyGlobalQuery\":true,\"applyGlobalTime\":true,\"applyForceRefresh\":true,\"filterByMapBounds\":true,\"tooltipProperties\":[],\"sortField\":\"\",\"sortOrder\":\"desc\",\"topHitsGroupByTimeseries\":false,\"topHitsSplitField\":\"\",\"topHitsSize\":1,\"indexPatternRefName\":\"layer_6_source_index_pattern\"},\"id\":\"1cfaa7fa-dc73-419d-b362-7238e2270a1c\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}},\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#54B399\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#41937c\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":0}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":6}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"labelText\":{\"type\":\"STATIC\",\"options\":{\"value\":\"\"}},\"labelColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#000000\"}},\"labelSize\":{\"type\":\"STATIC\",\"options\":{\"size\":14}},\"labelZoomRange\":{\"options\":{\"useLayerZoomRange\":true,\"minZoom\":0,\"maxZoom\":24}},\"labelBorderColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"labelBorderSize\":{\"options\":{\"size\":\"SMALL\"}},\"labelPosition\":{\"options\":{\"position\":\"CENTER\"}}},\"isTimeAware\":true},\"includeInFitToBounds\":true,\"type\":\"GEOJSON_VECTOR\",\"joins\":[],\"disableTooltips\":false},{\"sourceDescriptor\":{\"id\":\"d4d6d4cf-58ee-4a0d-a792-532c0711fa2a\",\"type\":\"ESQL\"},\"id\":\"fff76ebb-57a6-4067-a373-1d191b9bd1a3\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}},\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#6092C0\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#4379aa\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":6}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"labelText\":{\"type\":\"STATIC\",\"options\":{\"value\":\"\"}},\"labelColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#000000\"}},\"labelSize\":{\"type\":\"STATIC\",\"options\":{\"size\":14}},\"labelZoomRange\":{\"options\":{\"useLayerZoomRange\":true,\"minZoom\":0,\"maxZoom\":24}},\"labelBorderColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"labelBorderSize\":{\"options\":{\"size\":\"SMALL\"}},\"labelPosition\":{\"options\":{\"position\":\"CENTER\"}}},\"isTimeAware\":true},\"includeInFitToBounds\":true,\"type\":\"GEOJSON_VECTOR\",\"joins\":[],\"disableTooltips\":false}]", "mapStateJSON": "{\"adHocDataViews\":[],\"zoom\":3.38,\"center\":{\"lon\":76.34937,\"lat\":-77.25604},\"timeFilters\":{\"from\":\"now-7d\",\"to\":\"now\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":0},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"index\":\"561253e0-f731-11e8-8487-11b9dd924f96\",\"type\":\"custom\",\"disabled\":false,\"negate\":false,\"alias\":\"connections shard failure\",\"key\":\"query\",\"value\":\"{\\\"error_query\\\":{\\\"indices\\\":[{\\\"error_type\\\":\\\"exception\\\",\\\"message\\\":\\\"simulated shard failure\\\",\\\"name\\\":\\\"connections\\\"}]}}\"},\"$state\":{\"store\":\"appState\"},\"query\":{\"error_query\":{\"indices\":[{\"error_type\":\"exception\",\"message\":\"simulated shard failure\",\"name\":\"connections\"}]}}}],\"settings\":{\"autoFitToDataBounds\":false,\"backgroundColor\":\"#ffffff\",\"customIcons\":[],\"disableInteractive\":false,\"disableTooltipControl\":false,\"hideToolbarOverlay\":false,\"hideLayerControl\":false,\"hideViewControl\":false,\"initialLocation\":\"LAST_SAVED_LOCATION\",\"fixedLocation\":{\"lat\":0,\"lon\":0,\"zoom\":2},\"browserLocation\":{\"zoom\":2},\"keydownScrollZoom\":false,\"maxZoom\":24,\"minZoom\":0,\"showScaleControl\":false,\"showSpatialFilters\":true,\"showTimesliderToggleButton\":true,\"spatialFiltersAlpa\":0.3,\"spatialFiltersFillColor\":\"#DA8B45\",\"spatialFiltersLineColor\":\"#DA8B45\"}}", "title": "layer with errors", "uiStateJSON": "{\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"1cfaa7fa-dc73-419d-b362-7238e2270a1c\"]}"