From 732fca4037c8a4406612ed8882ed52091c72a210 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 20 Sep 2024 09:50:50 +0000 Subject: [PATCH] feat: support navigating to discover in alerting popover (#316) * feat: support navigate to discover in alerting popover Signed-off-by: tygao * feat: support MDS and fix test env error Signed-off-by: tygao * doc: add changelog Signed-off-by: tygao * add tests Signed-off-by: tygao --------- Signed-off-by: tygao (cherry picked from commit 93744fee5aca42cfadbc993dfe2f516d10a1d3de) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md --- .../generate_popover_body.test.tsx | 135 ++++++++++++++++++ .../generate_popover_body.tsx | 74 +++++++++- public/components/incontext_insight/index.tsx | 6 +- public/plugin.tsx | 1 + public/utils/alerting.ts | 135 ++++++++++++++++++ public/utils/index.ts | 1 + test/jest.config.js | 5 + 7 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 public/utils/alerting.ts diff --git a/public/components/incontext_insight/generate_popover_body.test.tsx b/public/components/incontext_insight/generate_popover_body.test.tsx index 2581d776..4b3c1302 100644 --- a/public/components/incontext_insight/generate_popover_body.test.tsx +++ b/public/components/incontext_insight/generate_popover_body.test.tsx @@ -10,9 +10,16 @@ import { GeneratePopoverBody } from './generate_popover_body'; import { HttpSetup } from '../../../../../src/core/public'; import { SUMMARY_ASSISTANT_API } from '../../../common/constants/llm'; import { usageCollectionPluginMock } from '../../../../../src/plugins/usage_collection/public/mocks'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; jest.mock('../../services'); +jest.mock('../../utils', () => ({ + createIndexPatterns: jest.fn().mockResolvedValue('index pattern'), + buildUrlQuery: jest.fn().mockResolvedValue('query'), +})); + const mockToasts = { addDanger: jest.fn(), }; @@ -33,6 +40,36 @@ const mockHttpSetup: HttpSetup = ({ post: mockPost, } as unknown) as HttpSetup; // Mocking HttpSetup +const mockDSL = `{ + "query": { + "bool": { + "filter": [ + { + "range": { + "timestamp": { + "from": "2024-09-06T04:02:52||-1h", + "to": "2024-09-06T04:02:52", + "include_lower": true, + "include_upper": true, + "boost": 1 + } + } + }, + { + "term": { + "FlightDelay": { + "value": "true", + "boost": 1 + } + } + } + ], + "adjust_pure_negative": true, + "boost": 1 + } + } +}`; + describe('GeneratePopoverBody', () => { const incontextInsightMock = { contextProvider: jest.fn(), @@ -240,4 +277,102 @@ describe('GeneratePopoverBody', () => { // insight tip icon is not visible for this alert expect(screen.queryAllByLabelText('How was this generated?')).toHaveLength(0); }); + + it('should not display discover link if monitor type is not query_level_monitor or bucket_level_monitor', async () => { + incontextInsightMock.contextProvider = jest.fn().mockResolvedValue({ + additionalInfo: { + dsl: mockDSL, + index: 'mock_index', + dataSourceId: `test-data-source-id`, + monitorType: 'mock_type', + }, + }); + mockPost.mockImplementation((path: string, body) => { + let value; + switch (path) { + case SUMMARY_ASSISTANT_API.SUMMARIZE: + value = { + summary: 'Generated summary content', + insightAgentIdExists: true, + }; + break; + + case SUMMARY_ASSISTANT_API.INSIGHT: + value = 'Generated insight content'; + break; + + default: + return null; + } + return Promise.resolve(value); + }); + + const { queryByText } = render( + + ); + + await waitFor(() => { + expect(queryByText('Discover details')).not.toBeInTheDocument(); + }); + }); + + it('handle navigate to discover after clicking link', async () => { + incontextInsightMock.contextProvider = jest.fn().mockResolvedValue({ + additionalInfo: { + dsl: mockDSL, + index: 'mock_index', + dataSourceId: `test-data-source-id`, + monitorType: 'query_level_monitor', + }, + }); + mockPost.mockImplementation((path: string, body) => { + let value; + switch (path) { + case SUMMARY_ASSISTANT_API.SUMMARIZE: + value = { + summary: 'Generated summary content', + insightAgentIdExists: true, + }; + break; + + case SUMMARY_ASSISTANT_API.INSIGHT: + value = 'Generated insight content'; + break; + + default: + return null; + } + return Promise.resolve(value); + }); + + const coreStart = coreMock.createStart(); + const dataStart = dataPluginMock.createStartContract(); + const getStartServices = jest.fn().mockResolvedValue([ + coreStart, + { + data: dataStart, + }, + ]); + const { getByText } = render( + + ); + + await waitFor(() => { + const button = getByText('Discover details'); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(coreStart.application.navigateToUrl).toHaveBeenCalledWith( + 'data-explorer/discover#?query' + ); + }); + }); }); diff --git a/public/components/incontext_insight/generate_popover_body.tsx b/public/components/incontext_insight/generate_popover_body.tsx index 2bb39737..9ef69e8b 100644 --- a/public/components/incontext_insight/generate_popover_body.tsx +++ b/public/components/incontext_insight/generate_popover_body.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { i18n } from '@osd/i18n'; import { EuiFlexGroup, @@ -17,24 +17,29 @@ import { EuiSpacer, EuiText, EuiTitle, + EuiButton, } from '@elastic/eui'; import { useEffectOnce } from 'react-use'; import { METRIC_TYPE } from '@osd/analytics'; import { MessageActions } from '../../tabs/chat/messages/message_action'; import { IncontextInsight as IncontextInsightInput } from '../../types'; import { getNotifications } from '../../services'; -import { HttpSetup } from '../../../../../src/core/public'; +import { HttpSetup, StartServicesAccessor } from '../../../../../src/core/public'; import { SUMMARY_ASSISTANT_API } from '../../../common/constants/llm'; import shiny_sparkle from '../../assets/shiny_sparkle.svg'; import { UsageCollectionSetup } from '../../../../../src/plugins/usage_collection/public'; import { reportMetric } from '../../utils/report_metric'; +import { buildUrlQuery, createIndexPatterns } from '../../utils'; +import { AssistantPluginStartDependencies } from '../../types'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; export const GeneratePopoverBody: React.FC<{ incontextInsight: IncontextInsightInput; httpSetup?: HttpSetup; usageCollection?: UsageCollectionSetup; closePopover: () => void; -}> = ({ incontextInsight, httpSetup, usageCollection, closePopover }) => { + getStartServices?: StartServicesAccessor; +}> = ({ incontextInsight, httpSetup, usageCollection, closePopover, getStartServices }) => { const [summary, setSummary] = useState(''); const [insight, setInsight] = useState(''); const [insightAvailable, setInsightAvailable] = useState(false); @@ -43,6 +48,20 @@ export const GeneratePopoverBody: React.FC<{ const toasts = getNotifications().toasts; + const [displayDiscoverButton, setDisplayDiscoverButton] = useState(false); + + useEffect(() => { + const getMonitorType = async () => { + const context = await incontextInsight.contextProvider?.(); + const monitorType = context?.additionalInfo?.monitorType; + // Only this two types from alerting contain DSL and index. + const shoudDisplayDiscoverButton = + monitorType === 'query_level_monitor' || monitorType === 'bucket_level_monitor'; + setDisplayDiscoverButton(shoudDisplayDiscoverButton); + }; + getMonitorType(); + }, [incontextInsight, setDisplayDiscoverButton]); + useEffectOnce(() => { onGenerateSummary( incontextInsight.suggestions && incontextInsight.suggestions.length > 0 @@ -154,6 +173,48 @@ export const GeneratePopoverBody: React.FC<{ return generateInsight(); }; + const handleNavigateToDiscover = async () => { + const context = await incontextInsight?.contextProvider?.(); + const dsl = context?.additionalInfo?.dsl; + const indexName = context?.additionalInfo?.index; + if (!dsl || !indexName) return; + const dslObject = JSON.parse(dsl); + const filters = dslObject?.query?.bool?.filter; + if (!filters) return; + const timeDslIndex = filters?.findIndex((filter: Record) => filter?.range); + const timeDsl = filters[timeDslIndex]?.range; + const timeFieldName = Object.keys(timeDsl)[0]; + if (!timeFieldName) return; + filters?.splice(timeDslIndex, 1); + + if (getStartServices) { + const [coreStart, startDeps] = await getStartServices(); + const newDiscoverEnabled = coreStart.uiSettings.get(UI_SETTINGS.QUERY_ENHANCEMENTS_ENABLED); + if (!newDiscoverEnabled) { + // Only new discover supports DQL with filters. + coreStart.uiSettings.set(UI_SETTINGS.QUERY_ENHANCEMENTS_ENABLED, true); + } + + const indexPattern = await createIndexPatterns( + startDeps.data, + indexName, + timeFieldName, + context?.dataSourceId + ); + if (!indexPattern) return; + const query = await buildUrlQuery( + startDeps.data, + coreStart.savedObjects, + indexPattern, + dslObject, + timeDsl[timeFieldName], + context?.dataSourceId + ); + // Navigate to new discover with query built to populate + coreStart.application.navigateToUrl(`data-explorer/discover#?${query}`); + } + }; + const renderContent = () => { const content = showInsight && insightAvailable ? insight : summary; return content ? ( @@ -245,6 +306,13 @@ export const GeneratePopoverBody: React.FC<{ <> {renderInnerTitle()} {renderContent()} + {displayDiscoverButton && ( + + {i18n.translate('assistantDashboards.incontextInsight.discover', { + defaultMessage: 'Discover details', + })} + + )} ); }; diff --git a/public/components/incontext_insight/index.tsx b/public/components/incontext_insight/index.tsx index 76a1b5a8..c0732604 100644 --- a/public/components/incontext_insight/index.tsx +++ b/public/components/incontext_insight/index.tsx @@ -31,14 +31,16 @@ import { getIncontextInsightRegistry, getNotifications } from '../../services'; // TODO: Replace with getChrome().logos.Chat.url import chatIcon from '../../assets/chat.svg'; import sparkle from '../../assets/sparkle.svg'; -import { HttpSetup } from '../../../../../src/core/public'; +import { HttpSetup, StartServicesAccessor } from '../../../../../src/core/public'; import { GeneratePopoverBody } from './generate_popover_body'; import { UsageCollectionSetup } from '../../../../../src/plugins/usage_collection/public/plugin'; +import { AssistantPluginStartDependencies } from '../../types'; export interface IncontextInsightProps { children?: React.ReactNode; httpSetup?: HttpSetup; usageCollection?: UsageCollectionSetup; + getStartServices?: StartServicesAccessor; } // TODO: add saved objects / config to store seed suggestions @@ -46,6 +48,7 @@ export const IncontextInsight = ({ children, httpSetup, usageCollection, + getStartServices, }: IncontextInsightProps) => { const anchor = useRef(null); const [isVisible, setIsVisible] = useState(false); @@ -287,6 +290,7 @@ export const IncontextInsight = ({ httpSetup={httpSetup} usageCollection={usageCollection} closePopover={closePopover} + getStartServices={getStartServices} /> ); case 'summary': diff --git a/public/plugin.tsx b/public/plugin.tsx index 6d28c37c..bcc6d4ff 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -302,6 +302,7 @@ export class AssistantPlugin {...props} httpSetup={httpSetup} usageCollection={setupDeps.usageCollection} + getStartServices={core.getStartServices} /> ); }, diff --git a/public/utils/alerting.ts b/public/utils/alerting.ts new file mode 100644 index 00000000..cf1712d0 --- /dev/null +++ b/public/utils/alerting.ts @@ -0,0 +1,135 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import rison from 'rison-node'; +import { stringify } from 'query-string'; +import { buildCustomFilter } from '../../../../src/plugins/data/common'; +import { url } from '../../../../src/plugins/opensearch_dashboards_utils/public'; +import { + DataPublicPluginStart, + opensearchFilters, + DuplicateIndexPatternError, + IndexPattern, +} from '../../../../src/plugins/data/public'; +import { CoreStart } from '../../../../src/core/public'; + +export const buildFilter = (indexPatternId: string, dsl: Record) => { + const filterAlias = 'Alerting-filters'; + return buildCustomFilter( + indexPatternId, + dsl, + false, + false, + filterAlias, + opensearchFilters.FilterStateStore.APP_STATE + ); +}; + +export const createIndexPatterns = async ( + dataStart: DataPublicPluginStart, + patternName: string, + timeFieldName: string, + dataSourceId?: string +) => { + let pattern: IndexPattern | undefined; + const dataSourceRef = dataSourceId + ? { + type: 'data-source', + id: dataSourceId, + } + : undefined; + try { + pattern = await dataStart.indexPatterns.createAndSave({ + id: '', + title: patternName, + timeFieldName, + dataSourceRef, + }); + } catch (err) { + if (err instanceof DuplicateIndexPatternError) { + const result = await dataStart.indexPatterns.find(patternName); + if (result && result[0]) { + pattern = result[0]; + } + console.error('Duplicate index pattern', err.message); + } else { + console.error('err', err.message); + } + } + return pattern; +}; + +export const buildUrlQuery = async ( + dataStart: DataPublicPluginStart, + savedObjects: CoreStart['savedObjects'], + indexPattern: IndexPattern, + dsl: Record, + timeDsl: Record<'from' | 'to', string>, + dataSourceId?: string +) => { + const filter = buildFilter(indexPattern.id!, dsl); + + const filterManager = dataStart.query.filterManager; + // There are some map and flatten operations to filters in filterManager, use this to keep aligned with discover. + filterManager.setAppFilters([filter]); + const filters = filterManager.getAppFilters(); + + const refreshInterval = { + pause: true, + value: 0, + }; + const time = { + from: timeDsl.from, + to: timeDsl.to, + }; + let indexPatternTitle = indexPattern.title; + if (dataSourceId) { + try { + const dataSourceObject = await savedObjects.client.get('data-source', dataSourceId); + const dataSourceTitle = dataSourceObject?.get('title'); + // If index pattern refers to a data source, discover list will display data source name as dataSourceTitle::indexPatternTitle + indexPatternTitle = `${dataSourceTitle}::indexPatternTitle`; + } catch (e) { + console.error('Get data source object error'); + } + } + const queryState = { + filters, + query: { + dataset: { + type: 'INDEX_PATTERN', + id: indexPattern.id, + timeFiledName: indexPattern.timeFieldName, + title: indexPatternTitle, + }, + language: 'kuery', + query: '', + }, + }; + + // This hash is encoded based on the interface of new discover + const hash = stringify( + url.encodeQuery({ + _a: rison.encode({ + discover: { + columns: ['_source'], + isDirty: false, + sort: [], + }, + metadata: { + view: 'discover', + }, + }), + _g: rison.encode({ + filters: [], + refreshInterval, + time, + }), + _q: rison.encode(queryState), + }), + { encode: false, sort: false } + ); + return hash; +}; diff --git a/public/utils/index.ts b/public/utils/index.ts index f3bd4a64..c2d7b7ba 100644 --- a/public/utils/index.ts +++ b/public/utils/index.ts @@ -5,3 +5,4 @@ export * from './notebook'; export * from './find_last_index'; +export * from './alerting'; diff --git a/test/jest.config.js b/test/jest.config.js index f5a1419f..f074b573 100644 --- a/test/jest.config.js +++ b/test/jest.config.js @@ -28,4 +28,9 @@ module.exports = { '^!!raw-loader!.*': 'jest-raw-loader', }, testEnvironment: 'jsdom', + transformIgnorePatterns: [ + // ignore all node_modules except those which require babel transforms to handle dynamic import() + // since ESM modules are not natively supported in Jest yet (https://github.com/facebook/jest/issues/4842) + '[/\\\\]node_modules(?![\\/\\\\](monaco-editor|weak-lru-cache|ordered-binary|d3-color|axios))[/\\\\].+\\.js$', + ], };