diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd4fbed1..26df0a18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- feat: exposed an API to check if a give agent config name has configured with agent id([#307](https://github.com/opensearch-project/dashboards-assistant/pull/307))
- feat: check all required agents before enabling index pattern selection for text to visualization([313](https://github.com/opensearch-project/dashboards-assistant/pull/313))
- fix: pass data source id for alert summary/insight([#321](https://github.com/opensearch-project/dashboards-assistant/pull/321))
+- feat: support navigating to discover in alerting popover([#316](https://github.com/opensearch-project/dashboards-assistant/pull/316))
### 📈 Features/Enhancements
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 8a56f2c9..76f5950f 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 { ContextObj, 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
@@ -160,6 +179,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 ? (
@@ -251,6 +312,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$',
+ ],
};