diff --git a/common/constants/data_sources.ts b/common/constants/data_sources.ts index 931f1f7290..7918516628 100644 --- a/common/constants/data_sources.ts +++ b/common/constants/data_sources.ts @@ -5,6 +5,8 @@ export const DATA_SOURCE_NAME_URL_PARAM_KEY = 'datasourceName'; export const DATA_SOURCE_TYPE_URL_PARAM_KEY = 'datasourceType'; +export const OLLY_QUESTION_URL_PARAM_KEY = 'olly_q'; +export const INDEX_URL_PARAM_KEY = 'indexPattern'; export const DEFAULT_DATA_SOURCE_TYPE = 'DEFAULT_INDEX_PATTERNS'; export const DEFAULT_DATA_SOURCE_NAME = 'Default cluster'; export const DEFAULT_DATA_SOURCE_OBSERVABILITY_DISPLAY_NAME = 'OpenSearch'; diff --git a/common/constants/explorer.ts b/common/constants/explorer.ts index 187d5d40c3..d94957c3e8 100644 --- a/common/constants/explorer.ts +++ b/common/constants/explorer.ts @@ -18,6 +18,7 @@ export const RAW_QUERY = 'rawQuery'; export const FINAL_QUERY = 'finalQuery'; export const SELECTED_DATE_RANGE = 'selectedDateRange'; export const INDEX = 'index'; +export const OLLY_QUERY_ASSISTANT = 'ollyQueryAssistant'; export const SELECTED_PATTERN_FIELD = 'selectedPatternField'; export const PATTERN_REGEX = 'patternRegex'; export const FILTERED_PATTERN = 'filteredPattern'; diff --git a/common/constants/query_assist.ts b/common/constants/query_assist.ts new file mode 100644 index 0000000000..04b38c1299 --- /dev/null +++ b/common/constants/query_assist.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +const QUERY_ASSIST_API_PREFIX = '/api/observability/query_assist'; +export const QUERY_ASSIST_API = { + GENERATE_PPL: `${QUERY_ASSIST_API_PREFIX}/generate_ppl`, + SUMMARIZE: `${QUERY_ASSIST_API_PREFIX}/summarize`, +}; + +export const ML_COMMONS_API_PREFIX = '/_plugins/_ml'; diff --git a/common/constants/shared.ts b/common/constants/shared.ts index 9baffcf101..e6e84b37bd 100644 --- a/common/constants/shared.ts +++ b/common/constants/shared.ts @@ -256,3 +256,6 @@ export const S3_DATASOURCE_TYPE = 'S3_DATASOURCE'; export const ASYNC_QUERY_SESSION_ID = 'async-query-session-id'; export const DIRECT_DUMMY_QUERY = 'select 1'; + +export const QUERY_ASSISTANT_FIXED_START_TIME = 'now-40y'; +export const QUERY_ASSISTANT_FIXED_END_TIME = 'now'; diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index c05e9a9023..efaf215872 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -18,8 +18,6 @@ "urlForwarding", "visualizations" ], - "optionalPlugins": [ - "managementOverview", - "assistantDashboards" - ] -} \ No newline at end of file + "optionalPlugins": ["managementOverview", "assistantDashboards"], + "configPath": ["observability"] +} diff --git a/public/.DS_Store b/public/.DS_Store deleted file mode 100644 index dbd07c1a1c..0000000000 Binary files a/public/.DS_Store and /dev/null differ diff --git a/public/components/common/live_tail/live_tail_button.tsx b/public/components/common/live_tail/live_tail_button.tsx index 0e53152fa3..66bb91591a 100644 --- a/public/components/common/live_tail/live_tail_button.tsx +++ b/public/components/common/live_tail/live_tail_button.tsx @@ -3,13 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -//Define pop over interval options for live tail button in your plugin +// Define pop over interval options for live tail button in your plugin -import { EuiButton } from "@elastic/eui"; -import React, { useMemo } from "react"; -import { LiveTailProps } from "common/types/explorer"; +import { EuiButton } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { LiveTailProps } from 'common/types/explorer'; -//Live Tail Button +// Live Tail Button export const LiveTailButton = ({ isLiveTailOn, isLiveTailPopoverOpen, @@ -20,7 +20,7 @@ export const LiveTailButton = ({ const liveButton = useMemo(() => { return ( setIsLiveTailPopoverOpen(!isLiveTailPopoverOpen)} data-test-subj={dataTestSubj} diff --git a/public/components/common/search/date_picker.tsx b/public/components/common/search/date_picker.tsx index 75087c9f13..5f9a463750 100644 --- a/public/components/common/search/date_picker.tsx +++ b/public/components/common/search/date_picker.tsx @@ -3,25 +3,57 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { EuiSuperDatePicker } from '@elastic/eui'; -import { IDatePickerProps } from './search'; +import { EuiSuperDatePicker, EuiToolTip } from '@elastic/eui'; +import React, { useEffect } from 'react'; import { uiSettingsService } from '../../../../common/utils'; +import { coreRefs } from '../../../framework/core_refs'; +import { IDatePickerProps } from './search'; +import { + QUERY_ASSISTANT_FIXED_END_TIME, + QUERY_ASSISTANT_FIXED_START_TIME, +} from '../../../../common/constants/shared'; export function DatePicker(props: IDatePickerProps) { - const { startTime, endTime, handleTimePickerChange, handleTimeRangePickerRefresh } = props; + const { + startTime, + endTime, + handleTimePickerChange, + handleTimeRangePickerRefresh, + isAppAnalytics, + } = props; const handleTimeChange = (e: any) => handleTimePickerChange([e.start, e.end]); + const allowTimeChanging = !coreRefs.queryAssistEnabled || isAppAnalytics; + + // set the time range to be 40 years rather than the standard 15 minutes if using query assistant + useEffect(() => { + if (!allowTimeChanging) { + handleTimePickerChange([QUERY_ASSISTANT_FIXED_START_TIME, QUERY_ASSISTANT_FIXED_END_TIME]); + } + }, []); return ( - + <> + + + + ); } diff --git a/public/components/common/search/query_area.tsx b/public/components/common/search/query_area.tsx new file mode 100644 index 0000000000..ffa486a366 --- /dev/null +++ b/public/components/common/search/query_area.tsx @@ -0,0 +1,81 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import React, { useEffect, useMemo } from 'react'; +import { coreRefs } from '../../../framework/core_refs'; +import { QueryAssistInput } from '../../event_analytics/explorer/query_assist/input'; +import { useFetchEvents } from '../../event_analytics/hooks/use_fetch_events'; + +export function QueryArea({ + tabId, + handleQueryChange, + handleTimeRangePickerRefresh, + runQuery, + tempQuery, + setNeedsUpdate, + setFillRun, + selectedIndex, + nlqInput, + setNlqInput, + pplService, +}: any) { + const requestParams = { tabId }; + const { getAvailableFields } = useFetchEvents({ + pplService, + requestParams, + }); + + // use effect that sets the editor text and populates sidebar field for a particular index upon initialization + const memoizedGetAvailableFields = useMemo(() => getAvailableFields, []); + const memoizedHandleQueryChange = useMemo(() => handleQueryChange, []); + useEffect(() => { + const indexQuery = `source = ${selectedIndex[0].label}`; + memoizedHandleQueryChange(indexQuery); + memoizedGetAvailableFields(indexQuery); + }, [selectedIndex, memoizedGetAvailableFields, memoizedHandleQueryChange]); + + return ( + + + + { + handleQueryChange(query); + // query is considered updated when the last run query is not the same as whats in the editor + // setUpdatedQuery(runQuery !== query); + setNeedsUpdate(runQuery !== query); + }} + onFocus={() => setFillRun(true)} + onBlur={() => setFillRun(false)} + value={tempQuery} + wrapEnabled={true} + /> + + {coreRefs.queryAssistEnabled && ( + + + + )} + + + ); +} diff --git a/public/components/common/search/query_assist_summarization.tsx b/public/components/common/search/query_assist_summarization.tsx new file mode 100644 index 0000000000..bb7d92e051 --- /dev/null +++ b/public/components/common/search/query_assist_summarization.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiBadge, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiMarkdownFormat, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import chatLogo from '../../datasources/icons/query-assistant-logo.svg'; +import React from 'react'; + +export function QueryAssistSummarization({ + queryAssistantSummarization, + setNlqInput, + showFlyout, +}: any) { + return ( + + + + + Generated by Opensearch Assistant + + + + + + + } + > + {queryAssistantSummarization?.summary?.length > 0 && ( + <> + + {queryAssistantSummarization?.isPPLError ? ( + <> + + {queryAssistantSummarization.summary} + + + + + Suggestions: + + {queryAssistantSummarization.suggestedQuestions.map((question) => ( + + setNlqInput(question)} + onClickAriaLabel="Set input to the suggested question" + > + {question} + + + ))} + + + PPL Documentation + + + + + ) : ( + + {queryAssistantSummarization.summary} + + )} + + + + The OpenSearch Assistant may produce inaccurate information. Verify all information + before using it in any environment or workload. Share feedback via{' '} + + Forum + {' '} + or{' '} + + Slack + + + + + )} + + + ); +} diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index c849c25cbd..278919533d 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -3,38 +3,71 @@ * SPDX-License-Identifier: Apache-2.0 */ -import './search.scss'; - import '@algolia/autocomplete-theme-classic'; import { + EuiAccordion, EuiBadge, EuiButton, EuiButtonEmpty, + EuiCallOut, + EuiComboBox, + EuiComboBoxOptionOption, EuiContextMenuItem, EuiContextMenuPanel, EuiFlexGroup, EuiFlexItem, + EuiIcon, + EuiLink, + EuiMarkdownFormat, + EuiPanel, EuiPopover, EuiPopoverFooter, + EuiSpacer, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, EuiToolTip, } from '@elastic/eui'; import { isEqual } from 'lodash'; import React, { useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { APP_ANALYTICS_TAB_ID_REGEX } from '../../../../common/constants/explorer'; +import { batch, useDispatch, useSelector } from 'react-redux'; +import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; +import { + APP_ANALYTICS_TAB_ID_REGEX, + INDEX, + OLLY_QUERY_ASSISTANT, + RAW_QUERY, +} from '../../../../common/constants/explorer'; import { PPL_SPAN_REGEX } from '../../../../common/constants/shared'; import { uiSettingsService } from '../../../../common/utils'; import { useFetchEvents } from '../../../components/event_analytics/hooks'; import { usePolling } from '../../../components/hooks/use_polling'; import { coreRefs } from '../../../framework/core_refs'; import { SQLService } from '../../../services/requests/sql'; +import { + useCatIndices, + useGetIndexPatterns, +} from '../../event_analytics/explorer/query_assist/hooks'; import { SavePanel } from '../../event_analytics/explorer/save_panel'; +import { + resetSummary, + selectQueryAssistantSummarization, +} from '../../event_analytics/redux/slices/query_assistant_summarization_slice'; +import { reset } from '../../event_analytics/redux/slices/query_result_slice'; +import { + changeData, + changeQuery, + selectQueries, +} from '../../event_analytics/redux/slices/query_slice'; import { update as updateSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPLReferenceFlyout } from '../helpers'; import { LiveTailButton, StopLiveButton } from '../live_tail/live_tail_button'; -import { Autocomplete } from './autocomplete'; import { DatePicker } from './date_picker'; -import { QUERY_LANGUAGE } from '../../../../common/constants/data_sources'; +import { QueryArea } from './query_area'; +import './search.scss'; +import { QueryAssistSummarization } from './query_assist_summarization'; +import { Autocomplete } from './autocomplete'; + export interface IQueryBarProps { query: string; tempQuery: string; @@ -46,12 +79,13 @@ export interface IQueryBarProps { export interface IDatePickerProps { startTime: string; endTime: string; - setStartTime: () => void; - setEndTime: () => void; + setStartTime: (start: string) => void; + setEndTime: (end: string) => void; setTimeRange: () => void; setIsOutputStale: () => void; handleTimePickerChange: (timeRange: string[]) => any; handleTimeRangePickerRefresh: () => any; + isAppAnalytics: boolean; } export const Search = (props: any) => { @@ -59,6 +93,7 @@ export const Search = (props: any) => { query, tempQuery, handleQueryChange, + handleQuerySearch, handleTimePickerChange, dslService, startTime, @@ -92,16 +127,26 @@ export const Search = (props: any) => { curVisId, setSubType, setIsQueryRunning, + isAppAnalytics, + pplService, } = props; + const queryRedux = useSelector(selectQueries)[tabId]; + const queryAssistantSummarization = useSelector(selectQueryAssistantSummarization)[tabId]; const dispatch = useDispatch(); const appLogEvents = tabId.match(APP_ANALYTICS_TAB_ID_REGEX); const [isSavePanelOpen, setIsSavePanelOpen] = useState(false); const [isLanguagePopoverOpen, setLanguagePopoverOpen] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [queryLang, setQueryLang] = useState(QUERY_LANGUAGE.PPL); + const [timeRange, setTimeRange] = useState(['now-15m', 'now']); // default time range + const [needsUpdate, setNeedsUpdate] = useState(false); + const [fillRun, setFillRun] = useState(false); const sqlService = new SQLService(coreRefs.http); const { application } = coreRefs; + const [nlqInput, setNlqInput] = useState(''); + + const showQueryArea = !appLogEvents && coreRefs.queryAssistEnabled; const { data: pollingResult, @@ -118,6 +163,10 @@ export const Search = (props: any) => { pplService: new SQLService(coreRefs.http), requestParams, }); + const { getAvailableFields } = useFetchEvents({ + pplService, + requestParams, + }); const closeFlyout = () => { setIsFlyoutVisible(false); @@ -180,6 +229,11 @@ export const Search = (props: any) => { setLanguagePopoverOpen(false); }; + const languageOptions: Array> = [ + { value: QUERY_LANGUAGE.PPL, inputDisplay: PPL }, + { value: QUERY_LANGUAGE.DQL, inputDisplay: DQL }, + ]; + const languagePopOverItems = [ { } }, [pollingResult, pollingError]); + useEffect(() => { + // set index and olly query assistant question if changed elsewhere + if (!queryRedux.ollyQueryAssistant) return; + if (queryRedux.index.length > 0) { + const reduxIndex = [{ label: queryRedux.index }]; + setSelectedIndex(reduxIndex); + // sets the editor text and populates sidebar field for a particular index upon initialization + const indexQuery = `source = ${reduxIndex[0].label}`; + handleQueryChange(indexQuery); + getAvailableFields(indexQuery); + } + if (queryRedux.ollyQueryAssistant.length > 0) { + setNlqInput(queryRedux.ollyQueryAssistant); + // remove index and olly query assistant + dispatch( + changeData({ + tabId: props.tabId, + data: { + [INDEX]: '', + [OLLY_QUERY_ASSISTANT]: '', + }, + }) + ); + } + }, [queryRedux.index, queryRedux.ollyQueryAssistant]); + + const runChanges = () => { + batch(() => { + dispatch(reset({ tabId })); + dispatch(resetSummary({ tabId })); + dispatch(changeQuery({ tabId, query: { [RAW_QUERY]: tempQuery } })); + }); + onQuerySearch(queryLang); + handleTimePickerChange(timeRange); + setNeedsUpdate(false); + }; + + // STATE FOR LANG PICKER AND INDEX PICKER + const [selectedIndex, setSelectedIndex] = useState([ + { label: 'opensearch_dashboards_sample_data_logs' }, + ]); + const { data: indices, loading: indicesLoading } = useCatIndices(); + const { data: indexPatterns, loading: indexPatternsLoading } = useGetIndexPatterns(); + const indicesAndIndexPatterns = + indexPatterns && indices + ? [...indexPatterns, ...indices].filter( + (v1, index, array) => array.findIndex((v2) => v1.label === v2.label) === index + ) + : undefined; + const loading = indicesLoading || indexPatternsLoading; + return (
- - {appLogEvents && ( - - - - Base Query - - - - )} - {!appLogEvents && ( - - - - - - )} - - { - onQuerySearch(queryLang); - }} - dslService={dslService} - getSuggestions={getSuggestions} - onItemSelect={onItemSelect} - tabId={tabId} - /> - showFlyout()} - onClickAriaLabel={'pplLinkShowFlyout'} - > - PPL - - - - - {!isLiveTailOn && ( - handleTimePickerChange(timeRange)} - handleTimeRangePickerRefresh={() => { - onQuerySearch(queryLang); - }} - /> - )} - - {showSaveButton && !showSavePanelOptionsList && ( - - - - - - )} - {isLiveTailOn && ( - - - - )} - {showSaveButton && searchBarConfigs[selectedSubTabId]?.showSaveButton && ( - <> - - setIsSavePanelOpen(false)} + + + + {appLogEvents && ( + + + + Base Query + + + + )} + {!appLogEvents && ( + <> + + { + handleQueryLanguageChange(lang); + setQueryLang(lang); + }} + /> + + + showFlyout()} + color="#159D8D" + // onClickAriaLabel={'pplLinkShowFlyout'} + /> + + {coreRefs.queryAssistEnabled && ( + + Index} + singleSelection={true} + isLoading={loading} + options={indicesAndIndexPatterns} + selectedOptions={selectedIndex} + onChange={(index) => { + // clear previous state + batch(() => { + dispatch(reset({ tabId })); + dispatch(resetSummary({ tabId })); + }); + // change the query in the editor to be just source= + const indexQuery = `source = ${index[0].label}`; + handleQueryChange(indexQuery); + // get the fields into the sidebar + getAvailableFields(indexQuery); + setSelectedIndex(index); + }} + /> + + )} + + )} + {!showQueryArea && ( + - { - return ( - isEqual(curVisId, 'line') && - tempQuery && - tempQuery.match(PPL_SPAN_REGEX) !== null + { + onQuerySearch(queryLang); + }} + dslService={dslService} + getSuggestions={getSuggestions} + onItemSelect={onItemSelect} + tabId={tabId} + /> + showFlyout()} + onClickAriaLabel={'pplLinkShowFlyout'} + > + PPL + + + )} + + + {!isLiveTailOn && ( + { + // modifies run button to look like the update button, if there is a time change, disables timepicker setting update if timepicker is disabled + setNeedsUpdate( + !showQueryArea && // keeps statement false if using query assistant ui, timepicker shouldn't change run button + !(tRange[0] === startTime && tRange[1] === endTime) // checks to see if the time given is different from prev ); + // keeps the time range change local, to be used when update pressed + setTimeRange(tRange); + setStartTime(tRange[0]); + setEndTime(tRange[1]); }} + handleTimeRangePickerRefresh={() => { + onQuerySearch(queryLang); + }} + isAppAnalytics={isAppAnalytics} /> - - - - setIsSavePanelOpen(false)} - data-test-subj="eventExplorer__querySaveCancel" - > - Cancel - - - - { - handleSavingObject(); - setIsSavePanelOpen(false); - }} - data-test-subj="eventExplorer__querySaveConfirm" - > - Save - - - - - + )} + + + + + {needsUpdate ? 'Update' : 'Run'} + + + {!showQueryArea && showSaveButton && !showSavePanelOptionsList && ( + + + + + + )} + {!showQueryArea && isLiveTailOn && ( + + + + )} + {showSaveButton && searchBarConfigs[selectedSubTabId]?.showSaveButton && ( + <> + + setIsSavePanelOpen(false)} + > + + + + + setIsSavePanelOpen(false)} + data-test-subj="eventExplorer__querySaveCancel" + > + Cancel + + + + { + handleSavingObject(); + setIsSavePanelOpen(false); + }} + data-test-subj="eventExplorer__querySaveConfirm" + > + Save + + + + + + + + )} + + + {showQueryArea && ( + <> + + + + {(queryAssistantSummarization?.summary?.length > 0 || + queryAssistantSummarization?.summaryLoading) && ( + + + + )} )} diff --git a/public/components/datasources/icons/query-assistant-logo.svg b/public/components/datasources/icons/query-assistant-logo.svg new file mode 100644 index 0000000000..d21737d822 --- /dev/null +++ b/public/components/datasources/icons/query-assistant-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/components/event_analytics/__tests__/__snapshots__/no_results.test.tsx.snap b/public/components/event_analytics/__tests__/__snapshots__/no_results.test.tsx.snap index 9092d4be2c..0fe3612fb8 100644 --- a/public/components/event_analytics/__tests__/__snapshots__/no_results.test.tsx.snap +++ b/public/components/event_analytics/__tests__/__snapshots__/no_results.test.tsx.snap @@ -1,142 +1,156 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`No result component Renders No result component 1`] = ` - - + -
- -
- -
- - } +
-
+ } >
- - + + + + + + + + No results match your search criteria + + + +
+
+ +
+ + +
+ +
+ + +
+

- No results match your search criteria + Select a data source, expand your time range, or modify the query - +

+

+ + + After selection, check the time range, query filters, fields, and query + + +

-
- -
-
- -
- -
- - -
-

- - - Select a data source, expand your time range, or modify the query - - -

-

- - - After selection, check the time range, query filters, fields, and query - - -

-
-
-
- -
- -
- - + +
+ +
+ +
+ + + `; diff --git a/public/components/event_analytics/__tests__/no_results.test.tsx b/public/components/event_analytics/__tests__/no_results.test.tsx index 1bc6c9eb17..7d545ddf48 100644 --- a/public/components/event_analytics/__tests__/no_results.test.tsx +++ b/public/components/event_analytics/__tests__/no_results.test.tsx @@ -8,20 +8,28 @@ import Adapter from 'enzyme-adapter-react-16'; import React from 'react'; import { waitFor } from '@testing-library/react'; import { NoResults } from '../explorer/no_results'; +import { initialTabId } from '../../../framework/redux/store/shared_state'; +import { Provider } from 'react-redux'; +import { applyMiddleware, createStore } from '@reduxjs/toolkit'; +import { rootReducer } from '../../../framework/redux/reducers'; +import thunk from 'redux-thunk'; describe('No result component', () => { configure({ adapter: new Adapter() }); it('Renders No result component', async () => { - + const store = createStore(rootReducer, applyMiddleware(thunk)); + const wrapper = mount( - + + + ); - + wrapper.update(); await waitFor(() => { expect(wrapper).toMatchSnapshot(); }); }); -}); \ No newline at end of file +}); diff --git a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx index 3c6e34df1f..122e7cc7f8 100644 --- a/public/components/event_analytics/explorer/datasources/datasources_selection.tsx +++ b/public/components/event_analytics/explorer/datasources/datasources_selection.tsx @@ -20,9 +20,9 @@ import { reset as resetCountDistribution } from '../../redux/slices/count_distri import { reset as resetFields } from '../../redux/slices/field_slice'; import { reset as resetPatterns } from '../../redux/slices/patterns_slice'; import { reset as resetQueryResults } from '../../redux/slices/query_result_slice'; +import { changeData, reset as resetQuery } from '../../redux/slices/query_slice'; import { reset as resetVisualization } from '../../redux/slices/visualization_slice'; import { reset as resetVisConfig } from '../../redux/slices/viualization_config_slice'; -import { reset as resetQuery } from '../../redux/slices/query_slice'; import { DirectQueryRequest, SelectedDataSource } from '../../../../../common/types/explorer'; import { ObservabilityDefaultDataSource } from '../../../../framework/datasources/obs_opensearch_datasource'; import { @@ -34,6 +34,8 @@ import { DEFAULT_DATA_SOURCE_OBSERVABILITY_DISPLAY_NAME, DATA_SOURCE_TYPES, QUERY_LANGUAGE, + OLLY_QUESTION_URL_PARAM_KEY, + INDEX_URL_PARAM_KEY, } from '../../../../../common/constants/data_sources'; import { SQLService } from '../../../../services/requests/sql'; import { get as getObjValue } from '../../../../../common/utils/shared'; @@ -42,6 +44,11 @@ import { getAsyncSessionId, } from '../../../../../common/utils/query_session_utils'; import { DIRECT_DUMMY_QUERY } from '../../../../../common/constants/shared'; +import { + INDEX, + OLLY_QUERY_ASSISTANT, + SELECTED_TIMESTAMP, +} from '../../../../../common/constants/explorer'; const getDataSourceState = (selectedSourceState: SelectedDataSource[]) => { if (selectedSourceState.length === 0) return []; @@ -69,6 +76,9 @@ const removeDataSourceFromURLParams = (currURL: string) => { // Remove the data source redirection parameters hashParams.delete(DATA_SOURCE_NAME_URL_PARAM_KEY); hashParams.delete(DATA_SOURCE_TYPE_URL_PARAM_KEY); + hashParams.delete(OLLY_QUESTION_URL_PARAM_KEY); + hashParams.delete(INDEX_URL_PARAM_KEY); + hashParams.delete('timestamp'); // Reconstruct the hash currentURL.hash = hashParams.toString() ? `${hashBase}?${hashParams.toString()}` : hashBase; @@ -178,6 +188,10 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { useEffect(() => { const datasourceName = routerContext?.searchParams.get(DATA_SOURCE_NAME_URL_PARAM_KEY); const datasourceType = routerContext?.searchParams.get(DATA_SOURCE_TYPE_URL_PARAM_KEY); + const idxPattern = routerContext?.searchParams.get(INDEX_URL_PARAM_KEY); + const ollyQuestion = routerContext?.searchParams.get(OLLY_QUESTION_URL_PARAM_KEY) || ''; + const decodedOllyQ = decodeURIComponent(ollyQuestion); + const parsedTimeStamp = routerContext?.searchParams.get('timestamp') || ''; if (datasourceName && datasourceType) { // remove datasourceName and datasourceType from URL for a clean search state removeDataSourceFromURLParams(window.location.href); @@ -190,6 +204,18 @@ export const DataSourceSelection = ({ tabId }: { tabId: string }) => { }) ); }); + if (idxPattern && decodedOllyQ) { + dispatch( + changeData({ + tabId, + data: { + [INDEX]: idxPattern, + [OLLY_QUERY_ASSISTANT]: decodedOllyQ, + [SELECTED_TIMESTAMP]: parsedTimeStamp, + }, + }) + ); + } } }, []); diff --git a/public/components/event_analytics/explorer/events_views/data_grid.tsx b/public/components/event_analytics/explorer/events_views/data_grid.tsx index 56cac6abb5..25b94eff56 100644 --- a/public/components/event_analytics/explorer/events_views/data_grid.tsx +++ b/public/components/event_analytics/explorer/events_views/data_grid.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo, useState, useRef, Fragment, useCallback } from 'react'; +import React, { useMemo, useState, useRef, Fragment, useCallback, useEffect } from 'react'; import { EuiDataGrid, EuiDescriptionList, @@ -72,6 +72,10 @@ export function DataGrid(props: DataGridProps) { const [data, setData] = useState(rows); + useEffect(() => { + setData(rows); + }, [rows]); + // setSort and setPage are used to change the query and send a direct request to get data const setSort = (sort: EuiDataGridSorting['columns']) => { sortingFields.current = sort; @@ -103,7 +107,7 @@ export function DataGrid(props: DataGridProps) { }; // creates the header for each column listing what that column is - const dataGridColumns = useMemo(() => { + const dataGridColumns = () => { const columns: EuiDataGridColumn[] = []; selectedColumns.map(({ name, type }) => { if (name === 'timestamp') { @@ -119,10 +123,10 @@ export function DataGrid(props: DataGridProps) { } }); return columns; - }, [explorerFields, totalHits]); + }; // used for which columns are visible and their order - const dataGridColumnVisibility = useMemo(() => { + const dataGridColumnVisibility = () => { if (selectedColumns.length > 0) { const columns: string[] = []; selectedColumns.map(({ name }) => { @@ -137,10 +141,10 @@ export function DataGrid(props: DataGridProps) { } // default shown fields throw new Error('explorer data grid stored columns empty'); - }, [explorerFields, totalHits]); + }; // sets the very first column, which is the button used for the flyout of each row - const dataGridLeadingColumns = useMemo(() => { + const dataGridLeadingColumns = () => { return [ { id: 'inspectCollapseColumn', @@ -171,70 +175,58 @@ export function DataGrid(props: DataGridProps) { width: 40, }, ]; - }, [rows, http, explorerFields, pplService, rawQuery, timeStampField, totalHits]); + }; // renders what is shown in each cell, i.e. the content of each row - const dataGridCellRender = useCallback( - ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { - const trueIndex = rowIndex % pageFields.current[1]; - if (trueIndex < data.length) { - if (columnId === '_source') { - return ( - - {Object.keys(data[trueIndex]).map((key) => ( - - - {key} - - - {data[trueIndex][key]} - - - ))} - - ); - } - if (columnId === 'timestamp') { - return `${moment(data[trueIndex][columnId]).format(DATE_DISPLAY_FORMAT)}`; - } - return `${data[trueIndex][columnId]}`; + const dataGridCellRender = ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { + const trueIndex = rowIndex % pageFields.current[1]; + if (trueIndex < data.length) { + if (columnId === '_source') { + return ( + + {Object.keys(data[trueIndex]).map((key) => ( + + + {key} + + + {data[trueIndex][key]} + + + ))} + + ); } - return null; - }, - [data, rows, pageFields, explorerFields, totalHits] - ); + if (columnId === 'timestamp') { + return `${moment(data[trueIndex][columnId]).format(DATE_DISPLAY_FORMAT)}`; + } + return `${data[trueIndex][columnId]}`; + } + return null; + }; // ** Pagination config const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 100 }); // changing the number of items per page, reset index and modify page size - const onChangeItemsPerPage = useCallback( - (pageSize) => - setPagination(() => { - setPage([0, pageSize]); - return { pageIndex: 0, pageSize }; - }), - [setPagination, setPage, totalHits] - ); + const onChangeItemsPerPage = (pageSize) => + setPagination(() => { + setPage([0, pageSize]); + return { pageIndex: 0, pageSize }; + }); // changing the page index, keep page size constant - const onChangePage = useCallback( - (pageIndex) => { - setPagination(({ pageSize }) => { - setPage([pageIndex, pageSize]); - return { pageSize, pageIndex }; - }); - }, - [setPagination, setPage, totalHits] - ); + const onChangePage = (pageIndex) => { + setPagination(({ pageSize }) => { + setPage([pageIndex, pageSize]); + return { pageSize, pageIndex }; + }); + }; - const rowHeightsOptions = useMemo( - () => ({ - defaultHeight: { - // if source is listed as a column, add extra space - lineCount: selectedColumns.some((obj) => obj.name === '_source') ? 3 : 1, - }, - }), - [explorerFields, totalHits] - ); + const rowHeightsOptions = () => ({ + defaultHeight: { + // if source is listed as a column, add extra space + lineCount: selectedColumns.some((obj) => obj.name === '_source') ? 3 : 1, + }, + }); // TODO: memoize the expensive table below @@ -244,9 +236,9 @@ export function DataGrid(props: DataGridProps) { diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index fd88697b32..5ac6b0591a 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -33,6 +33,10 @@ import React, { } from 'react'; import { batch, useDispatch, useSelector } from 'react-redux'; import { LogExplorerRouterContext } from '..'; +import { + DEFAULT_DATA_SOURCE_TYPE, + QUERY_LANGUAGE, +} from '../../../../common/constants/data_sources'; import { CREATE_TAB_PARAM, CREATE_TAB_PARAM_KEY, @@ -74,11 +78,13 @@ import { getSavingCommonParams, uiSettingsService, } from '../../../../common/utils'; +import { initialTabId } from '../../../framework/redux/store/shared_state'; import { PPLDataFetcher } from '../../../services/data_fetchers/ppl/ppl_data_fetcher'; import { getSavedObjectsClient } from '../../../services/saved_objects/saved_object_client/client_factory'; -import { OSDSavedVisualizationClient } from '../../../services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization'; import { OSDSavedSearchClient } from '../../../services/saved_objects/saved_object_client/osd_saved_objects/saved_searches'; +import { OSDSavedVisualizationClient } from '../../../services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization'; import { PanelSavedObjectClient } from '../../../services/saved_objects/saved_object_client/ppl'; +import { ExplorerSavedObjectLoader } from '../../../services/saved_objects/saved_object_loaders/explorer_saved_object_loader'; import { SaveAsCurrentQuery, SaveAsCurrentVisualization, @@ -86,47 +92,41 @@ import { } from '../../../services/saved_objects/saved_object_savers'; import { SaveAsNewQuery } from '../../../services/saved_objects/saved_object_savers/ppl/save_as_new_query'; import { sleep } from '../../common/live_tail/live_tail_button'; +import { findMinInterval } from '../../common/query_utils'; import { onItemSelect, parseGetSuggestions } from '../../common/search/autocomplete_logic'; import { Search } from '../../common/search/search'; +import { processMetricsData } from '../../custom_panels/helpers/utils'; import { selectSearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { getVizContainerProps } from '../../visualizations/charts/helpers'; import { TabContext, useFetchEvents, useFetchPatterns, useFetchVisualizations } from '../hooks'; import { - render as updateCountDistribution, selectCountDistribution, + render as updateCountDistribution, } from '../redux/slices/count_distribution_slice'; import { selectFields, updateFields } from '../redux/slices/field_slice'; import { selectQueryResult } from '../redux/slices/query_result_slice'; -import { changeDateRange, changeQuery, selectQueries } from '../redux/slices/query_slice'; +import { changeData, changeQuery, selectQueries } from '../redux/slices/query_slice'; import { updateTabName } from '../redux/slices/query_tab_slice'; import { selectExplorerVisualization } from '../redux/slices/visualization_slice'; import { change as changeVisualizationConfig, change as changeVizConfig, - change as updateVizConfig, selectVisualizationConfig, + change as updateVizConfig, } from '../redux/slices/viualization_config_slice'; -import { formatError, getDefaultVisConfig } from '../utils'; +import { getDefaultVisConfig } from '../utils'; import { getContentTabTitle, getDateRange } from '../utils/utils'; +import { DataSourceSelection } from './datasources/datasources_selection'; import { DirectQueryRunning } from './direct_query_running'; import { DataGrid } from './events_views/data_grid'; import { HitsCounter } from './hits_counter/hits_counter'; import { LogPatterns } from './log_patterns/log_patterns'; import { NoResults } from './no_results'; +import { ObservabilitySideBar } from './sidebar/observability_sidebar'; import { TimechartHeader } from './timechart_header'; import { ExplorerVisualizations } from './visualizations'; import { CountDistribution } from './visualizations/count_distribution'; import { DirectQueryVisualization } from './visualizations/direct_query_vis'; -import { DataSourceSelection } from './datasources/datasources_selection'; -import { initialTabId } from '../../../framework/redux/store/shared_state'; -import { ObservabilitySideBar } from './sidebar/observability_sidebar'; -import { ExplorerSavedObjectLoader } from '../../../services/saved_objects/saved_object_loaders/explorer_saved_object_loader'; -import { processMetricsData } from '../../custom_panels/helpers/utils'; -import { - DEFAULT_DATA_SOURCE_TYPE, - QUERY_LANGUAGE, -} from '../../../../common/constants/data_sources'; -import { findMinInterval } from '../../common/query_utils'; export const Explorer = ({ pplService, @@ -144,10 +144,6 @@ export const Explorer = ({ appId = '', appBaseQuery = '', addVisualizationToPanel, - startTime, - endTime, - setStartTime, - setEndTime, callback, callbackInApp, queryManager = new QueryManager(), @@ -230,12 +226,18 @@ export const Explorer = ({ const liveTailNameRef = useRef('Live'); const savedObjectLoader = useRef(undefined); const isObjectIdUpdatedFromSave = useRef(false); // Flag to prevent reload when the current search's objectId changes due to a save operation. + const tempQueryRef = useRef(''); queryRef.current = query; selectedPanelNameRef.current = selectedPanelName; explorerFieldsRef.current = explorerFields; isLiveTailOnRef.current = isLiveTailOn; liveTailTabIdRef.current = liveTailTabId; liveTailNameRef.current = liveTailName; + tempQueryRef.current = tempQuery; + + const dateRange = getDateRange(undefined, undefined, query); + const [startTime, setStartTime] = useState(dateRange[0]); + const [endTime, setEndTime] = useState(dateRange[1]); const findAutoInterval = (start: string = '', end: string = '') => { const minInterval = findMinInterval(start, end); @@ -244,11 +246,16 @@ export const Explorer = ({ { text: 'Auto', value: 'auto_' + minInterval }, ...TIME_INTERVAL_OPTIONS, ]); - selectedIntervalRef.current = { text: 'Auto', value: 'auto_' + minInterval }; + selectedIntervalRef.current = { + text: 'Auto', + value: 'auto_' + minInterval, + }; dispatch( updateCountDistribution({ tabId, - data: { selectedInterval: selectedIntervalRef.current.value.replace(/^auto_/, '') }, + data: { + selectedInterval: selectedIntervalRef.current.value.replace(/^auto_/, ''), + }, }) ); }; @@ -266,14 +273,18 @@ export const Explorer = ({ const getErrorHandler = (title: string) => { return (error: any) => { - const formattedError = formatError(error.name, error.message, error.body.message); - notifications.toasts.addError(formattedError, { - title, - }); + // const formattedError = formatError(error.name, error.message, error.body.message); + // notifications.toasts.addError(formattedError, { + // title, + // }); }; }; - const fetchData = async (startingTime?: string, endingTime?: string) => { + const fetchData = async ( + startingTime?: string, + endingTime?: string, + setSummaryStatus?: boolean + ) => { const curQuery: IQuery = queryRef.current!; new PPLDataFetcher( { ...curQuery }, @@ -293,6 +304,7 @@ export const Explorer = ({ queryManager, getDefaultVisConfig, getAvailableFields, + setSummaryStatus, }, { appBaseQuery, @@ -398,9 +410,9 @@ export const Explorer = ({ setEndTime(timeRange[1]); } await dispatch( - changeDateRange({ + changeData({ tabId: requestParams.tabId, - data: { [RAW_QUERY]: queryRef.current![RAW_QUERY], [SELECTED_DATE_RANGE]: timeRange }, + data: { [SELECTED_DATE_RANGE]: timeRange }, }) ); }; @@ -415,8 +427,11 @@ export const Explorer = ({ ); }; - const handleTimeRangePickerRefresh = async (availability?: boolean) => { - handleQuerySearch(availability); + const handleTimeRangePickerRefresh = async ( + availability?: boolean, + setSummaryStatus?: boolean + ) => { + handleQuerySearch(availability, setSummaryStatus); if (availability !== true && query.rawQuery.match(PATTERNS_REGEX)) { let currQuery = query.rawQuery; const currPattern = currQuery.match(PATTERNS_EXTRACTOR_REGEX)!.groups!.pattern; @@ -438,7 +453,12 @@ export const Explorer = ({ const handleOverrideTimestamp = async (timestamp: IField) => { setIsOverridingTimestamp(true); - await dispatch(changeQuery({ tabId, query: { [SELECTED_TIMESTAMP]: timestamp?.name || '' } })); + await dispatch( + changeQuery({ + tabId, + query: { [SELECTED_TIMESTAMP]: timestamp?.name || '' }, + }) + ); setIsOverridingTimestamp(false); handleQuerySearch(); }; @@ -458,55 +478,59 @@ export const Explorer = ({ return 0; }, [countDistribution?.data]); - const dateRange = getDateRange(startTime, endTime, query); const mainContent = useMemo(() => { return (
{explorerData && !isEmpty(explorerData.jsonData) ? ( {(isDefaultDataSourceType || appLogEvents) && ( - - - {countDistribution?.data && !isLiveTailOnRef.current && ( - - {}} - /> - { - const intervalOptionsIndex = timeIntervalOptions.findIndex( - (item) => item.value === selectedIntrv - ); - const intrv = selectedIntrv.replace(/^auto_/, ''); - dispatch( - updateCountDistribution({ tabId, data: { selectedInterval: intrv } }) - ); - getCountVisualizations(intrv); - selectedIntervalRef.current = timeIntervalOptions[intervalOptionsIndex]; - getPatterns(intrv, getErrorHandler('Error fetching patterns')); - }} - stateInterval={ - countDistribution.selectedInterval || selectedIntervalRef.current?.value - } - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} - /> - - - - )} - - + <> + + + {countDistribution?.data && !isLiveTailOnRef.current && ( + + {}} + /> + { + const intervalOptionsIndex = timeIntervalOptions.findIndex( + (item) => item.value === selectedIntrv + ); + const intrv = selectedIntrv.replace(/^auto_/, ''); + dispatch( + updateCountDistribution({ + tabId, + data: { selectedInterval: intrv }, + }) + ); + getCountVisualizations(intrv); + selectedIntervalRef.current = timeIntervalOptions[intervalOptionsIndex]; + getPatterns(intrv, getErrorHandler('Error fetching patterns')); + }} + stateInterval={ + countDistribution.selectedInterval || selectedIntervalRef.current?.value + } + startTime={startTime} + endTime={endTime} + /> + + + + )} + + + )} {(isDefaultDataSourceType || appLogEvents) && ( @@ -566,8 +590,8 @@ export const Explorer = ({ : explorerData.datarows.length } requestParams={requestParams} - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} + startTime={startTime} + endTime={endTime} /> )} @@ -579,7 +603,7 @@ export const Explorer = ({ ) : ( - + )}
); @@ -671,24 +695,35 @@ export const Explorer = ({ const updateQueryInStore = async (updateQuery: string) => { await dispatch( - changeQuery({ tabId, query: { [RAW_QUERY]: updateQuery.replaceAll(PPL_NEWLINE_REGEX, '') } }) + changeQuery({ + tabId, + query: { [RAW_QUERY]: updateQuery.replaceAll(PPL_NEWLINE_REGEX, '') }, + }) ); }; - const handleQuerySearch = useCallback( - async (availability?: boolean) => { - // clear previous selected timestamp when index pattern changes - if (isIndexPatternChanged(tempQuery, query[RAW_QUERY])) { - await dispatch(changeQuery({ tabId, query: { [SELECTED_TIMESTAMP]: '' } })); - await setDefaultPatternsField('', ''); - } - if (availability !== true) { - await updateQueryInStore(tempQuery); - } - await fetchData(startTime, endTime); - }, - [tempQuery, query] - ); + const handleQuerySearch = async (availability?: boolean, setSummaryStatus?: boolean) => { + // clear previous selected timestamp when index pattern changes + const searchedQuery = tempQueryRef.current; + if ( + isIndexPatternChanged(searchedQuery, query[RAW_QUERY]) && + query[SELECTED_TIMESTAMP] !== '' + ) { + await dispatch( + changeQuery({ + tabId, + query: { + [SELECTED_TIMESTAMP]: '', + }, + }) + ); + await setDefaultPatternsField('', ''); + } + if (availability !== true) { + await updateQueryInStore(searchedQuery); + } + await fetchData(undefined, undefined, setSummaryStatus); + }; const handleQueryChange = async (newQuery: string) => setTempQuery(newQuery); @@ -918,8 +953,10 @@ export const Explorer = ({ handleQueryChange={handleQueryChange} handleQuerySearch={handleQuerySearch} dslService={dslService} - startTime={appLogEvents ? startTime : dateRange[0]} - endTime={appLogEvents ? endTime : dateRange[1]} + startTime={startTime} + endTime={endTime} + setStartTime={setStartTime} + setEndTime={setEndTime} handleTimePickerChange={(timeRange: string[]) => handleTimePickerChange(timeRange) } @@ -949,6 +986,8 @@ export const Explorer = ({ setSubType={setSubType} http={http} setIsQueryRunning={setIsQueryRunning} + isAppAnalytics={appLogEvents} + pplService={pplService} /> {explorerSearchMeta.isPolling ? ( diff --git a/public/components/event_analytics/explorer/no_results.tsx b/public/components/event_analytics/explorer/no_results.tsx index d6c96af865..489a72ace2 100644 --- a/public/components/event_analytics/explorer/no_results.tsx +++ b/public/components/event_analytics/explorer/no_results.tsx @@ -5,43 +5,104 @@ import React from 'react'; import { FormattedMessage } from '@osd/i18n/react'; -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPage, EuiSpacer, EuiText } from '@elastic/eui'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiSpacer, + EuiText, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { coreRefs } from '../../../framework/core_refs'; +import { useSelector } from 'react-redux'; +import { selectQueries } from '../redux/slices/query_slice'; + +export const NoResults = ({ tabId }: any) => { + // get the queries isLoaded, if it exists AND is true = show no res + const queryInfo = useSelector(selectQueries)[tabId]; -export const NoResults = () => { return ( - - - - } - color="warning" - iconType="help" - data-test-subj="observabilityNoResultsCallout" - /> - - - - -

- -

-

- -

-
-
-
+ {coreRefs.queryAssistEnabled ? ( + <> + {/* check to see if the rawQuery is empty or not */} + {queryInfo?.rawQuery ? ( + + + + } + color="warning" + iconType="help" + data-test-subj="observabilityNoResultsCallout" + /> + + + No results} + body={ +

+ Try selecting a different data source, expanding your time range or modifying + the query & filters. You may also use the Query Assistant to fine-tune your + query using simple conversational prompts. +

+ } + /> +
+
+ ) : ( + Get started} + body={ +

+ Run a query to view results, or use the Query Assistant to automatically generate + complex queries using simple conversational prompts. +

+ } + /> + )} + + ) : ( + + + + } + color="warning" + iconType="help" + data-test-subj="observabilityNoResultsCallout" + /> + + + + +

+ +

+

+ +

+
+
+
+ )}
); }; diff --git a/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts b/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts new file mode 100644 index 0000000000..45e910d027 --- /dev/null +++ b/public/components/event_analytics/explorer/query_assist/__tests__/hooks.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { SavedObjectsFindResponsePublic } from '../../../../../../../../src/core/public'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import * as coreServices from '../../../../../../common/utils/core_services'; +import { coreRefs } from '../../../../../framework/core_refs'; +import { genericReducer, useCatIndices, useGetIndexPatterns } from '../hooks'; + +const coreStartMock = coreMock.createStart(); + +describe('useCatIndices', () => { + const httpMock = coreStartMock.http; + + beforeEach(() => { + jest.spyOn(coreServices, 'getOSDHttp').mockReturnValue(httpMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return indices', async () => { + httpMock.get.mockResolvedValueOnce([{ index: 'test1' }, { index: 'test2' }]); + + const { result, waitForNextUpdate } = renderHook(() => useCatIndices()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual([{ label: 'test1' }, { label: 'test2' }]); + }); + + it('should handle errors', async () => { + httpMock.get.mockRejectedValueOnce('API failed'); + + const { result, waitForNextUpdate } = renderHook(() => useCatIndices()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toEqual('API failed'); + }); +}); + +describe('useGetIndexPatterns', () => { + const savedObjectsClientMock = coreStartMock.savedObjects.client as jest.Mocked< + typeof coreStartMock.savedObjects.client + >; + + beforeAll(() => { + coreRefs.savedObjectsClient = savedObjectsClientMock; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return index patterns', async () => { + savedObjectsClientMock.find.mockResolvedValueOnce({ + savedObjects: [{ attributes: { title: 'test1' } }, { attributes: { title: 'test2' } }], + } as SavedObjectsFindResponsePublic); + + const { result, waitForNextUpdate } = renderHook(() => useGetIndexPatterns()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual([{ label: 'test1' }, { label: 'test2' }]); + }); + + it('should handle errors', async () => { + savedObjectsClientMock.find.mockRejectedValueOnce('API failed'); + + const { result, waitForNextUpdate } = renderHook(() => useGetIndexPatterns()); + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toBe(undefined); + expect(result.current.error).toEqual('API failed'); + }); +}); + +describe('genericReducer', () => { + it('should return original state', () => { + expect( + genericReducer( + { data: { foo: 'bar' }, loading: false }, + // mock not supported type + { type: ('not-supported-type' as unknown) as 'request' } + ) + ).toEqual({ + data: { foo: 'bar' }, + loading: false, + }); + }); + + it('should return state follow request action', () => { + expect(genericReducer({ data: { foo: 'bar' }, loading: false }, { type: 'request' })).toEqual({ + data: { foo: 'bar' }, + loading: true, + }); + }); + + it('should return state follow success action', () => { + expect( + genericReducer( + { data: { foo: 'bar' }, loading: false }, + { type: 'success', payload: { foo: 'baz' } } + ) + ).toEqual({ + data: { foo: 'baz' }, + loading: false, + }); + }); + + it('should return state follow failure action', () => { + const error = new Error(); + expect( + genericReducer({ data: { foo: 'bar' }, loading: false }, { type: 'failure', error }) + ).toEqual({ + error, + loading: false, + }); + }); +}); diff --git a/public/components/event_analytics/explorer/query_assist/__tests__/input.test.tsx b/public/components/event_analytics/explorer/query_assist/__tests__/input.test.tsx new file mode 100644 index 0000000000..f6a9dd02de --- /dev/null +++ b/public/components/event_analytics/explorer/query_assist/__tests__/input.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configureStore } from '@reduxjs/toolkit'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { Provider } from 'react-redux'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { QUERY_ASSIST_API } from '../../../../../../common/constants/query_assist'; +import * as coreServices from '../../../../../../common/utils/core_services'; +import { coreRefs } from '../../../../../framework/core_refs'; +import { rootReducer } from '../../../../../framework/redux/reducers'; +import { initialTabId } from '../../../../../framework/redux/store/shared_state'; +import { QueryAssistInput } from '../input'; + +const renderQueryAssistInput = ( + overrideProps: Partial> = {} +) => { + const preloadedState = {}; + const store = configureStore({ reducer: rootReducer, preloadedState }); + const props: ComponentProps = Object.assign( + { + handleQueryChange: jest.fn(), + handleTimeRangePickerRefresh: jest.fn(), + tabId: initialTabId, + setNeedsUpdate: jest.fn(), + selectedIndex: [{ label: 'selected-test-index' }], + nlqInput: 'test-input', + setNlqInput: jest.fn(), + }, + overrideProps + ); + const component = render( + + + + ); + return { component, props, store }; +}; + +describe(' spec', () => { + const coreStartMock = coreMock.createStart(); + coreRefs.toasts = coreStartMock.notifications.toasts; + const httpMock = coreStartMock.http; + + beforeEach(() => { + jest.spyOn(coreServices, 'getOSDHttp').mockReturnValue(httpMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call generate ppl based on nlq input value', async () => { + httpMock.post.mockResolvedValueOnce('source = index'); + + const { component, props } = renderQueryAssistInput(); + + await waitFor(() => { + fireEvent.click(component.getByTestId('query-assist-generate-and-run-button')); + }); + + expect(httpMock.post).toBeCalledWith(QUERY_ASSIST_API.GENERATE_PPL, { + body: '{"question":"test-input","index":"selected-test-index"}', + }); + expect(props.handleQueryChange).toBeCalledWith('source = index'); + }); + + it('should display toast for generate errors', async () => { + httpMock.post.mockRejectedValueOnce({ body: { statusCode: 429 } }); + + const { component } = renderQueryAssistInput(); + await waitFor(() => { + fireEvent.click(component.getByTestId('query-assist-generate-button')); + }); + + expect(coreRefs.toasts!.addError).toBeCalledWith( + { + message: 'Request is throttled. Try again later or contact your administrator', + statusCode: 429, + }, + { title: 'Failed to generate results' } + ); + }); + + it('should call summarize for generate and run errors', async () => { + httpMock.post.mockRejectedValueOnce({ body: { statusCode: 429 } }).mockResolvedValueOnce({ + summary: 'too many requests', + suggestedQuestions: ['1', '2'], + }); + + const { component } = renderQueryAssistInput(); + await waitFor(() => { + fireEvent.click(component.getByTestId('query-assist-generate-and-run-button')); + }); + + expect(httpMock.post).toBeCalledWith(QUERY_ASSIST_API.GENERATE_PPL, { + body: '{"question":"test-input","index":"selected-test-index"}', + }); + expect(httpMock.post).toBeCalledWith(QUERY_ASSIST_API.SUMMARIZE, { + body: + '{"question":"test-input","index":"selected-test-index","isError":true,"query":"","response":"{\\"statusCode\\":429}"}', + }); + }); +}); diff --git a/public/components/event_analytics/explorer/query_assist/hooks.ts b/public/components/event_analytics/explorer/query_assist/hooks.ts new file mode 100644 index 0000000000..281f659bac --- /dev/null +++ b/public/components/event_analytics/explorer/query_assist/hooks.ts @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { CatIndicesResponse } from '@opensearch-project/opensearch/api/types'; +import { Reducer, useReducer, useState, useEffect } from 'react'; +import { IndexPatternAttributes } from '../../../../../../../src/plugins/data/common'; +import { DSL_BASE, DSL_CAT } from '../../../../../common/constants/shared'; +import { getOSDHttp } from '../../../../../common/utils'; +import { coreRefs } from '../../../../framework/core_refs'; + +interface State { + data?: T; + loading: boolean; + error?: Error; +} + +type Action = + | { type: 'request' } + | { type: 'success'; payload: State['data'] } + | { type: 'failure'; error: NonNullable['error']> }; + +// TODO use instantiation expressions when typescript is upgraded to >= 4.7 +type GenericReducer = Reducer, Action>; +export const genericReducer: GenericReducer = (state, action) => { + switch (action.type) { + case 'request': + return { data: state.data, loading: true }; + case 'success': + return { loading: false, data: action.payload }; + case 'failure': + return { loading: false, error: action.error }; + default: + return state; + } +}; + +export const useCatIndices = () => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + + useEffect(() => { + const abortController = new AbortController(); + dispatch({ type: 'request' }); + getOSDHttp() + .get(`${DSL_BASE}${DSL_CAT}`, { query: { format: 'json' }, signal: abortController.signal }) + .then((payload: CatIndicesResponse) => + dispatch({ type: 'success', payload: payload.map((meta) => ({ label: meta.index! })) }) + ) + .catch((error) => dispatch({ type: 'failure', error })); + + return () => abortController.abort(); + }, [refresh]); + + return { ...state, refresh: () => setRefresh({}) }; +}; + +export const useGetIndexPatterns = () => { + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + const [refresh, setRefresh] = useState({}); + + useEffect(() => { + let abort = false; + dispatch({ type: 'request' }); + + coreRefs + .savedObjectsClient!.find({ type: 'index-pattern', perPage: 10000 }) + .then((payload) => { + if (!abort) + dispatch({ + type: 'success', + payload: payload.savedObjects.map((meta) => ({ label: meta.attributes.title })), + }); + }) + .catch((error) => { + if (!abort) dispatch({ type: 'failure', error }); + }); + + return () => { + abort = true; + }; + }, [refresh]); + + return { ...state, refresh: () => setRefresh({}) }; +}; diff --git a/public/components/event_analytics/explorer/query_assist/input.tsx b/public/components/event_analytics/explorer/query_assist/input.tsx new file mode 100644 index 0000000000..58008819d7 --- /dev/null +++ b/public/components/event_analytics/explorer/query_assist/input.tsx @@ -0,0 +1,367 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBadge, + EuiButton, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiIcon, + EuiInputPopover, + EuiLink, + EuiListGroup, + EuiListGroupItem, + EuiPanel, + EuiText, +} from '@elastic/eui'; +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RAW_QUERY } from '../../../../../common/constants/explorer'; +import { getOSDHttp } from '../../../../../common/utils'; +import { coreRefs } from '../../../../framework/core_refs'; +import chatLogo from '../../../datasources/icons/query-assistant-logo.svg'; +import { + changeSummary, + resetSummary, + selectQueryAssistantSummarization, + setResponseForSummaryStatus, +} from '../../redux/slices/query_assistant_summarization_slice'; +import { reset, selectQueryResult } from '../../redux/slices/query_result_slice'; +import { changeQuery, selectQueries } from '../../redux/slices/query_slice'; +import { QUERY_ASSIST_API } from '../../../../../common/constants/query_assist'; + +interface SummarizationContext { + question: string; + query?: string; + response: string; + index: string; + isError: boolean; +} + +interface Props { + handleQueryChange: (query: string) => void; + handleTimeRangePickerRefresh: (availability?: boolean, setSummaryStatus?: boolean) => void; + tabId: string; + setNeedsUpdate: any; + selectedIndex: Array>; + nlqInput: string; + setNlqInput: React.Dispatch>; +} + +const HARDCODED_SUGGESTIONS: Record = { + opensearch_dashboards_sample_data_ecommerce: [ + 'How many unique customers placed orders this week?', + 'Count the number of orders grouped by manufacturer and category', + 'find customers with first names like Eddie', + ], + opensearch_dashboards_sample_data_logs: [ + 'Are there any errors in my logs?', + 'How many requests were there grouped by response code last week?', + "What's the average request size by week?", + ], + opensearch_dashboards_sample_data_flights: [ + 'how many flights were there this week grouped by destination country?', + 'what were the longest flight delays this week?', + 'what carriers have the furthest flights?', + ], + 'sso_logs-*.*': [ + 'show me the most recent 10 logs', + 'how many requests were there grouped by status code', + 'how many request failures were there by week?', + ], +}; + +export const QueryAssistInput: React.FC = (props) => { + // @ts-ignore + const queryRedux = useSelector(selectQueries)[props.tabId]; + // @ts-ignore + const explorerData = useSelector(selectQueryResult)[props.tabId]; + // @ts-ignore + const summaryData = useSelector(selectQueryAssistantSummarization)[props.tabId]; + + useEffect(() => { + if ( + summaryData.responseForSummaryStatus === 'success' || + summaryData.responseForSummaryStatus === 'failure' + ) { + void (async () => { + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: false, + }, + }) + ); + if (explorerData.total > 0) generateSummary(); + })(); + } + }, [summaryData.responseForSummaryStatus]); + + const [barSelected, setBarSelected] = useState(false); + + const dispatch = useDispatch(); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [generating, setGenerating] = useState(false); + const [generatingOrRunning, setGeneratingOrRunning] = useState(false); + // below is only used for url redirection + const [autoRun, setAutoRun] = useState(false); + + useEffect(() => { + if (autoRun) { + setAutoRun(false); + runAndSummarize(); + } else if (queryRedux.ollyQueryAssistant.length > 0) { + setAutoRun(true); + } + }, [queryRedux.ollyQueryAssistant]); + + // hide if not in a tab + if (props.tabId === '') return <>{props.children}; + + // generic method for generating ppl from natural language + const request = async () => { + const generatedPPL = await getOSDHttp().post(QUERY_ASSIST_API.GENERATE_PPL, { + body: JSON.stringify({ + question: props.nlqInput, + index: props.selectedIndex[0].label, + }), + }); + await props.handleQueryChange(generatedPPL); + await dispatch( + changeQuery({ + tabId: props.tabId, + query: { + [RAW_QUERY]: generatedPPL, + }, + }) + ); + return generatedPPL; + }; + const formatError = (error: ResponseError): Error => { + if (error.body) { + if (error.body.statusCode === 429) + return { + ...error.body, + message: 'Request is throttled. Try again later or contact your administrator', + } as Error; + return error.body as Error; + } + return error; + }; + // used by generate query button + const generatePPL = async () => { + dispatch(reset({ tabId: props.tabId })); + dispatch(resetSummary({ tabId: props.tabId })); + if (!props.selectedIndex.length) return; + try { + setGenerating(true); + await request(); + } catch (error) { + coreRefs.toasts?.addError(formatError(error as ResponseError), { + title: 'Failed to generate results', + }); + } finally { + setGenerating(false); + } + }; + const generateSummary = async (context?: Partial) => { + try { + const isError = summaryData.responseForSummaryStatus === 'failure'; + const summarizationContext: SummarizationContext = { + question: props.nlqInput, + index: props.selectedIndex[0].label, + isError, + query: queryRedux.rawQuery, + response: isError + ? String(JSON.parse(explorerData.error.body.message).error.details) + : JSON.stringify({ + datarows: explorerData.datarows, + schema: explorerData.schema, + size: explorerData.size, + total: explorerData.total, + }).slice(0, 7000), + ...context, + }; + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: true, + isPPLError: isError, + }, + }) + ); + const summary = await getOSDHttp().post<{ + summary: string; + suggestedQuestions: string[]; + }>(QUERY_ASSIST_API.SUMMARIZE, { + body: JSON.stringify(summarizationContext), + }); + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summary: summary.summary, + suggestedQuestions: summary.suggestedQuestions, + }, + }) + ); + } catch (error) { + coreRefs.toasts?.addError(formatError(error as ResponseError), { + title: 'Failed to summarize results', + }); + } finally { + await dispatch( + changeSummary({ + tabId: props.tabId, + data: { + summaryLoading: false, + }, + }) + ); + dispatch( + setResponseForSummaryStatus({ + tabId: props.tabId, + responseForSummaryStatus: 'false', + }) + ); + } + }; + // used by generate and run button + const runAndSummarize = async () => { + dispatch(reset({ tabId: props.tabId })); + dispatch(resetSummary({ tabId: props.tabId })); + if (!props.selectedIndex.length) return; + try { + setGeneratingOrRunning(true); + await request(); + await props.handleTimeRangePickerRefresh(undefined, true); + } catch (error) { + generateSummary({ isError: true, response: JSON.stringify((error as ResponseError).body) }); + } finally { + setGeneratingOrRunning(false); + } + }; + + return ( + <> + + { + e.preventDefault(); + request(); + }} + > + + + + + + + + Query Assistant + + + New! + + + props.setNlqInput(e.target.value)} + fullWidth + onFocus={() => { + setBarSelected(true); + props.setNeedsUpdate(false); + if (props.nlqInput.length === 0) setIsPopoverOpen(true); + }} + onBlur={() => setBarSelected(false)} + /> + } + disableFocusTrap + fullWidth={true} + isOpen={isPopoverOpen} + closePopover={() => { + setIsPopoverOpen(false); + }} + > + + {HARDCODED_SUGGESTIONS[props.selectedIndex[0]?.label]?.map((question) => ( + { + props.setNlqInput(question); + setIsPopoverOpen(false); + }} + label={question} + /> + ))} + + + + + + + + + + + Share feedback via{' '} + + Forum + {' '} + or{' '} + + Slack + + + + + + + Generate query + + + + + Generate and run + + + + + + + + + ); +}; diff --git a/public/components/event_analytics/explorer/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap b/public/components/event_analytics/explorer/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap index 59d4966c25..aff341c85c 100644 --- a/public/components/event_analytics/explorer/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap +++ b/public/components/event_analytics/explorer/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap @@ -22,10 +22,15 @@ exports[`Siderbar component Renders empty sidebar component 1`] = ` "unselectedFields": Array [], } } + handleOverridePattern={[Function]} handleOverrideTimestamp={[MockFunction]} isFieldToggleButtonDisabled={false} + isOverridingPattern={false} isOverridingTimestamp={false} + query="" + selectedPattern="" selectedTimestamp="timestamp" + tabId="OBSERVABILITY_DEFAULT_TAB" > @@ -1323,14 +1222,14 @@ exports[`Siderbar component Renders sidebar component 1`] = ` aria-label="inspect" className="dscSidebarField__actionButton" iconType="inspect" - isDisabled={true} + isDisabled={false} onClick={[Function]} size="xs" >