diff --git a/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md b/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md index 6b145841bbf..d43e0ec2978 100644 --- a/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md +++ b/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md @@ -174,6 +174,8 @@ You can also override this setting in a dashboard panel under its data source op Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14. {{< /admonition >}} +- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`. + ### Logs In this section you can configure which fields the data source uses for log messages and log levels. diff --git a/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts b/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts index 22ed3d8d68e..3d36ee9a806 100644 --- a/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts +++ b/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts @@ -18,12 +18,12 @@ import { } from './dataquery.gen'; import { defaultBucketAgg, - defaultMetricAgg, findMetricById, highlightTags, defaultGeoHashPrecisionString, + queryTypeToMetricType, } from './queryDef'; -import { TermsQuery } from './types'; +import { QueryType, TermsQuery } from './types'; import { convertOrderByToMetricId, getScriptValue } from './utils'; // Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval @@ -31,9 +31,11 @@ export const calendarIntervals: string[] = ['1w', '1M', '1q', '1y']; export class ElasticQueryBuilder { timeField: string; + defaultQueryMode?: QueryType; - constructor(options: { timeField: string }) { + constructor(options: { timeField: string; defaultQueryMode?: QueryType }) { this.timeField = options.timeField; + this.defaultQueryMode = options.defaultQueryMode; } getRangeFilter() { @@ -174,7 +176,10 @@ export class ElasticQueryBuilder { build(target: ElasticsearchDataQuery) { // make sure query has defaults; - target.metrics = target.metrics || [defaultMetricAgg()]; + if (!target.metrics || target.metrics.length === 0) { + const metricType = queryTypeToMetricType(this.defaultQueryMode); + target.metrics = [{ type: metricType, id: '1' }]; + } target.bucketAggs = target.bucketAggs || [defaultBucketAgg()]; target.timeField = this.timeField; let metric: MetricAggregation; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx index 57092ae9477..22102290fe1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx @@ -62,10 +62,10 @@ export const ElasticsearchProvider = ({ // useStatelessReducer will then call `onChange` with the newly generated query useEffect(() => { if (shouldRunInit && isUninitialized) { - dispatch(initQuery()); + dispatch(initQuery(datasource.defaultQueryMode)); setShouldRunInit(false); } - }, [shouldRunInit, dispatch, isUninitialized]); + }, [shouldRunInit, dispatch, isUninitialized, datasource.defaultQueryMode]); if (isUninitialized) { return null; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts index d44a57569f7..966bd71d6c8 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit'; import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; -import { defaultMetricAgg } from '../../../../queryDef'; +import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { initQuery } from '../../state'; import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations'; @@ -162,7 +162,8 @@ export const reducer = ( if (state && state.length > 0) { return state; } - return [defaultMetricAgg('1')]; + const metricType = queryTypeToMetricType(action.payload); + return [{ type: metricType, id: '1' }]; } return state; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.test.tsx new file mode 100644 index 00000000000..915d35a3bf8 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.test.tsx @@ -0,0 +1,117 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ElasticsearchDataQuery } from '../../dataquery.gen'; +import { useDispatch } from '../../hooks/useStatelessReducer'; +import { renderWithESProvider } from '../../test-helpers/render'; + +import { changeMetricType } from './MetricAggregationsEditor/state/actions'; +import { QueryTypeSelector } from './QueryTypeSelector'; + +jest.mock('../../hooks/useStatelessReducer'); + +describe('QueryTypeSelector', () => { + let dispatch: jest.Mock; + + beforeEach(() => { + dispatch = jest.fn(); + jest.mocked(useDispatch).mockReturnValue(dispatch); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render radio buttons with correct options', () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + expect(screen.getByRole('radio', { name: 'Metrics' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Logs' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Raw Data' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Raw Document' })).toBeInTheDocument(); + }); + + it('should dispatch changeMetricType action when radio button is changed', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const logsRadio = screen.getByRole('radio', { name: 'Logs' }); + await userEvent.click(logsRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'logs' })); + }); + + it('should convert query type to metric type correctly for raw_data', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const rawDataRadio = screen.getByRole('radio', { name: 'Raw Data' }); + await userEvent.click(rawDataRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_data' })); + }); + + it('should convert query type to metric type correctly for raw_document', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const rawDocumentRadio = screen.getByRole('radio', { name: 'Raw Document' }); + await userEvent.click(rawDocumentRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_document' })); + }); + + it('should convert metrics query type to count metric type', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'logs' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const metricsRadio = screen.getByRole('radio', { name: 'Metrics' }); + await userEvent.click(metricsRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'count' })); + }); + + it('should return null when query has no metrics', () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + const { container } = renderWithESProvider(, { providerProps: { query } }); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx index bdfd1336d8b..207646a9514 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx @@ -1,35 +1,14 @@ -import { SelectableValue } from '@grafana/data'; import { RadioButtonGroup } from '@grafana/ui'; -import { MetricAggregation } from '../../dataquery.gen'; +import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils'; import { useDispatch } from '../../hooks/useStatelessReducer'; +import { queryTypeToMetricType } from '../../queryDef'; import { QueryType } from '../../types'; import { useQuery } from './ElasticsearchQueryContext'; import { changeMetricType } from './MetricAggregationsEditor/state/actions'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; -const OPTIONS: Array> = [ - { value: 'metrics', label: 'Metrics' }, - { value: 'logs', label: 'Logs' }, - { value: 'raw_data', label: 'Raw Data' }, - { value: 'raw_document', label: 'Raw Document' }, -]; - -function queryTypeToMetricType(type: QueryType): MetricAggregation['type'] { - switch (type) { - case 'logs': - case 'raw_data': - case 'raw_document': - return type; - case 'metrics': - return 'count'; - default: - // should never happen - throw new Error(`invalid query type: ${type}`); - } -} - export const QueryTypeSelector = () => { const query = useQuery(); const dispatch = useDispatch(); @@ -47,5 +26,12 @@ export const QueryTypeSelector = () => { dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) })); }; - return fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />; + return ( + + fullWidth={false} + options={QUERY_TYPE_SELECTOR_OPTIONS} + value={queryType} + onChange={onChange} + /> + ); }; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts index 4785e371642..a0ac6504049 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -1,12 +1,13 @@ import { Action, createAction } from '@reduxjs/toolkit'; import { ElasticsearchDataQuery } from '../../dataquery.gen'; +import { QueryType } from '../../types'; /** * When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present. * This means it won't override any existing value in place, but just ensure the query is in a "runnable" state. */ -export const initQuery = createAction('init'); +export const initQuery = createAction('init'); export const changeQuery = createAction('change_query'); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx index a0df1278d9f..1b4c284303a 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx @@ -41,4 +41,16 @@ describe('ElasticDetails', () => { }) ); }); + + it('should change default query mode when selected', async () => { + const onChangeMock = jest.fn(); + render(); + const selectEl = screen.getByLabelText('Default query mode'); + + await selectEvent.select(selectEl, 'Logs', { container: document.body }); + + expect(onChangeMock).toHaveBeenLastCalledWith( + expect.objectContaining({ jsonData: expect.objectContaining({ defaultQueryMode: 'logs' }) }) + ); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx index 232276d0124..fee941b3fc3 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { DataSourceSettings, SelectableValue } from '@grafana/data'; +import type { DataSourceSettings, SelectableValue } from '@grafana/data'; import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui'; import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui'; -import { ElasticsearchOptions, Interval } from '../types'; +import type { ElasticsearchOptions, Interval, QueryType } from '../types'; + +import { QUERY_TYPE_SELECTOR_OPTIONS } from './utils'; const indexPatternTypes: Array> = [ { label: 'No pattern', value: 'none' }, @@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => { onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)} /> + + +