diff --git a/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx b/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx index e17cd4613ff..fe3f963412e 100644 --- a/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx +++ b/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx @@ -14,7 +14,6 @@ export const useExpandableLabel = ( const Label: React.FC = ({ Component, onClick }) => (
{ setExpanded(true); diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts b/public/app/plugins/datasource/elasticsearch/bucket_agg.ts deleted file mode 100644 index e2a754ae859..00000000000 --- a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts +++ /dev/null @@ -1,231 +0,0 @@ -import coreModule from 'app/core/core_module'; -import _ from 'lodash'; -import * as queryDef from './query_def'; -import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; -import { CoreEvents } from 'app/types'; - -export class ElasticBucketAggCtrl { - /** @ngInject */ - constructor($scope: any, uiSegmentSrv: any, $rootScope: GrafanaRootScope) { - const bucketAggs = $scope.target.bucketAggs; - - $scope.orderByOptions = []; - - $scope.getBucketAggTypes = () => { - return queryDef.bucketAggTypes; - }; - - $scope.getOrderOptions = () => { - return queryDef.orderOptions; - }; - - $scope.getSizeOptions = () => { - return queryDef.sizeOptions; - }; - - $rootScope.onAppEvent( - CoreEvents.elasticQueryUpdated, - () => { - $scope.validateModel(); - }, - $scope - ); - - $scope.init = () => { - $scope.agg = bucketAggs[$scope.index] || {}; - $scope.validateModel(); - }; - - $scope.onChangeInternal = () => { - $scope.onChange(); - }; - - $scope.onTypeChanged = () => { - $scope.agg.settings = {}; - $scope.showOptions = false; - - switch ($scope.agg.type) { - case 'date_histogram': - case 'histogram': - case 'terms': { - delete $scope.agg.query; - $scope.agg.field = 'select field'; - break; - } - case 'filters': { - delete $scope.agg.field; - $scope.agg.query = '*'; - break; - } - case 'geohash_grid': { - $scope.agg.settings.precision = 3; - break; - } - } - - $scope.validateModel(); - $scope.onChange(); - }; - - $scope.validateModel = () => { - $scope.index = _.indexOf(bucketAggs, $scope.agg); - $scope.isFirst = $scope.index === 0; - $scope.bucketAggCount = bucketAggs.length; - - let settingsLinkText = ''; - const settings = $scope.agg.settings || {}; - - switch ($scope.agg.type) { - case 'terms': { - settings.order = settings.order || 'desc'; - settings.size = settings.size || '10'; - settings.min_doc_count = settings.min_doc_count || 0; - settings.orderBy = settings.orderBy || '_term'; - - if (settings.size !== '0') { - settingsLinkText = queryDef.describeOrder(settings.order) + ' ' + settings.size + ', '; - } - - if (settings.min_doc_count > 0) { - settingsLinkText += 'Min Doc Count: ' + settings.min_doc_count + ', '; - } - - settingsLinkText += 'Order by: ' + queryDef.describeOrderBy(settings.orderBy, $scope.target); - - if (settings.size === '0') { - settingsLinkText += ' (' + settings.order + ')'; - } - - break; - } - case 'filters': { - settings.filters = settings.filters || [{ query: '*' }]; - settingsLinkText = _.reduce( - settings.filters, - (memo, value, index) => { - memo += 'Q' + (index + 1) + ' = ' + value.query + ' '; - return memo; - }, - '' - ); - if (settingsLinkText.length > 50) { - settingsLinkText = settingsLinkText.substr(0, 50) + '...'; - } - settingsLinkText = 'Filter Queries (' + settings.filters.length + ')'; - break; - } - case 'date_histogram': { - settings.interval = settings.interval || 'auto'; - settings.min_doc_count = settings.min_doc_count || 0; - $scope.agg.field = $scope.target.timeField; - settingsLinkText = 'Interval: ' + settings.interval; - - if (settings.min_doc_count > 0) { - settingsLinkText += ', Min Doc Count: ' + settings.min_doc_count; - } - - if (settings.trimEdges === undefined || settings.trimEdges < 0) { - settings.trimEdges = 0; - } - - if (settings.trimEdges && settings.trimEdges > 0) { - settingsLinkText += ', Trim edges: ' + settings.trimEdges; - } - break; - } - case 'histogram': { - settings.interval = settings.interval || 1000; - settings.min_doc_count = _.defaultTo(settings.min_doc_count, 1); - settingsLinkText = 'Interval: ' + settings.interval; - - if (settings.min_doc_count > 0) { - settingsLinkText += ', Min Doc Count: ' + settings.min_doc_count; - } - break; - } - case 'geohash_grid': { - // limit precision to 12 - settings.precision = Math.max(Math.min(settings.precision, 12), 1); - settingsLinkText = 'Precision: ' + settings.precision; - break; - } - } - - $scope.settingsLinkText = settingsLinkText; - $scope.agg.settings = settings; - return true; - }; - - $scope.addFiltersQuery = () => { - $scope.agg.settings.filters.push({ query: '*' }); - }; - - $scope.removeFiltersQuery = (filter: any) => { - $scope.agg.settings.filters = _.without($scope.agg.settings.filters, filter); - }; - - $scope.toggleOptions = () => { - $scope.showOptions = !$scope.showOptions; - }; - - $scope.getOrderByOptions = () => { - return queryDef.getOrderByOptions($scope.target); - }; - - $scope.getFieldsInternal = () => { - if ($scope.agg.type === 'date_histogram') { - return $scope.getFields({ $fieldType: 'date' }); - } else { - return $scope.getFields(); - } - }; - - $scope.getIntervalOptions = () => { - return Promise.resolve(uiSegmentSrv.transformToSegments(true, 'interval')(queryDef.intervalOptions)); - }; - - $scope.addBucketAgg = () => { - // if last is date histogram add it before - const lastBucket = bucketAggs[bucketAggs.length - 1]; - let addIndex = bucketAggs.length - 1; - - if (lastBucket && lastBucket.type === 'date_histogram') { - addIndex -= 1; - } - - const id = _.reduce( - $scope.target.bucketAggs.concat($scope.target.metrics), - (max, val) => { - return parseInt(val.id, 10) > max ? parseInt(val.id, 10) : max; - }, - 0 - ); - - bucketAggs.splice(addIndex, 0, { type: 'terms', field: 'select field', id: (id + 1).toString(), fake: true }); - $scope.onChange(); - }; - - $scope.removeBucketAgg = () => { - bucketAggs.splice($scope.index, 1); - $scope.onChange(); - }; - - $scope.init(); - } -} - -export function elasticBucketAgg() { - return { - templateUrl: 'public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html', - controller: ElasticBucketAggCtrl, - restrict: 'E', - scope: { - target: '=', - index: '=', - onChange: '&', - getFields: '&', - }, - }; -} - -coreModule.directive('elasticBucketAgg', elasticBucketAgg); diff --git a/public/app/plugins/datasource/elasticsearch/components/AddRemove.test.tsx b/public/app/plugins/datasource/elasticsearch/components/AddRemove.test.tsx new file mode 100644 index 00000000000..7b7dd7dfec9 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/AddRemove.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AddRemove } from './AddRemove'; + +const noop = () => {}; + +const TestComponent = ({ items }: { items: any[] }) => ( + <> + {items.map((_, index) => ( + + ))} + +); + +describe('AddRemove Button', () => { + describe("When There's only one element in the list", () => { + it('Should only show the add button', () => { + render(); + + expect(screen.getByText('add')).toBeInTheDocument(); + expect(screen.queryByText('remove')).not.toBeInTheDocument(); + }); + }); + + describe("When There's more than one element in the list", () => { + it('Should show the remove button on every element', () => { + const items = ['something', 'something else']; + + render(); + + expect(screen.getAllByText('remove')).toHaveLength(items.length); + }); + + it('Should show the add button only once', () => { + const items = ['something', 'something else']; + + render(); + + expect(screen.getAllByText('add')).toHaveLength(1); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/AddRemove.tsx b/public/app/plugins/datasource/elasticsearch/components/AddRemove.tsx new file mode 100644 index 00000000000..316f87a2fc7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/AddRemove.tsx @@ -0,0 +1,28 @@ +import { css } from 'emotion'; +import React, { FunctionComponent } from 'react'; +import { IconButton } from './IconButton'; + +interface Props { + index: number; + elements: any[]; + onAdd: () => void; + onRemove: () => void; +} + +/** + * A component used to show add & remove buttons for mutable lists of values. Wether to show or not the add or the remove buttons + * depends on the `index` and `elements` props. This enforces a consistent experience whenever this pattern is used. + */ +export const AddRemove: FunctionComponent = ({ index, onAdd, onRemove, elements }) => { + return ( +
+ {index === 0 && } + + {elements.length >= 2 && } +
+ ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx b/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx deleted file mode 100644 index 7e91393cfeb..00000000000 --- a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import _ from 'lodash'; -import React from 'react'; - -import { QueryField, SlatePrism } from '@grafana/ui'; -import { ExploreQueryFieldProps } from '@grafana/data'; -import { ElasticDatasource } from '../datasource'; -import { ElasticsearchOptions, ElasticsearchQuery } from '../types'; - -interface Props extends ExploreQueryFieldProps {} - -interface State { - syntaxLoaded: boolean; -} - -class ElasticsearchQueryField extends React.PureComponent { - plugins: any[]; - - constructor(props: Props, context: React.Context) { - super(props, context); - - this.plugins = [ - SlatePrism({ - onlyIn: (node: any) => node.type === 'code_block', - getSyntax: (node: any) => 'lucene', - }), - ]; - - this.state = { - syntaxLoaded: false, - }; - } - - componentDidMount() { - if (!this.props.query.isLogsQuery) { - this.onChangeQuery('', true); - } - } - - componentWillUnmount() {} - - componentDidUpdate(prevProps: Props) { - // if query changed from the outside (i.e. cleared via explore toolbar) - if (!this.props.query.isLogsQuery) { - this.onChangeQuery('', true); - } - } - - onChangeQuery = (value: string, override?: boolean) => { - // Send text change to parent - const { query, onChange, onRunQuery } = this.props; - if (onChange) { - const nextQuery: ElasticsearchQuery = { ...query, query: value, isLogsQuery: true }; - onChange(nextQuery); - - if (override && onRunQuery) { - onRunQuery(); - } - } - }; - - render() { - const { query } = this.props; - const { syntaxLoaded } = this.state; - - return ( - <> -
-
- -
-
- - ); - } -} - -export default ElasticsearchQueryField; diff --git a/public/app/plugins/datasource/elasticsearch/components/IconButton.tsx b/public/app/plugins/datasource/elasticsearch/components/IconButton.tsx new file mode 100644 index 00000000000..b11a97d9bec --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/IconButton.tsx @@ -0,0 +1,33 @@ +import { Icon } from '@grafana/ui'; +import { cx, css } from 'emotion'; +import React, { FunctionComponent, ComponentProps, ButtonHTMLAttributes } from 'react'; + +const SROnly = css` + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +`; + +interface Props { + iconName: ComponentProps['name']; + onClick: () => void; + className?: string; + label: string; +} + +export const IconButton: FunctionComponent> = ({ + iconName, + onClick, + className, + label, + ...buttonProps +}) => ( + +); diff --git a/public/app/plugins/datasource/elasticsearch/components/MetricPicker.tsx b/public/app/plugins/datasource/elasticsearch/components/MetricPicker.tsx new file mode 100644 index 00000000000..2870cacfd6f --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/MetricPicker.tsx @@ -0,0 +1,34 @@ +import React, { FunctionComponent } from 'react'; +import { css, cx } from 'emotion'; +import { SelectableValue } from '@grafana/data'; +import { Segment } from '@grafana/ui'; +import { describeMetric } from '../utils'; +import { MetricAggregation } from './QueryEditor/MetricAggregationsEditor/aggregations'; + +const noWrap = css` + white-space: nowrap; +`; + +const toOption = (metric: MetricAggregation) => ({ + label: describeMetric(metric), + value: metric, +}); + +const toOptions = (metrics: MetricAggregation[]): Array> => metrics.map(toOption); + +interface Props { + options: MetricAggregation[]; + onChange: (e: SelectableValue) => void; + className?: string; + value?: string; +} + +export const MetricPicker: FunctionComponent = ({ options, onChange, className, value }) => ( + option.id === value)!) : null} + /> +); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/BucketAggregationEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/BucketAggregationEditor.tsx new file mode 100644 index 00000000000..2976467f396 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/BucketAggregationEditor.tsx @@ -0,0 +1,76 @@ +import { MetricFindValue, SelectableValue } from '@grafana/data'; +import { Segment, SegmentAsync } from '@grafana/ui'; +import React, { FunctionComponent } from 'react'; +import { useDispatch } from '../../../hooks/useStatelessReducer'; +import { useDatasource } from '../ElasticsearchQueryContext'; +import { segmentStyles } from '../styles'; +import { BucketAggregation, BucketAggregationType, isBucketAggregationWithField } from './aggregations'; +import { SettingsEditor } from './SettingsEditor'; +import { changeBucketAggregationField, changeBucketAggregationType } from './state/actions'; +import { BucketAggregationAction } from './state/types'; +import { bucketAggregationConfig } from './utils'; + +const bucketAggOptions: Array> = Object.entries(bucketAggregationConfig).map( + ([key, { label }]) => ({ + label, + value: key as BucketAggregationType, + }) +); + +const toSelectableValue = ({ value, text }: MetricFindValue): SelectableValue => ({ + label: text, + value: `${value || text}`, +}); + +const toOption = (bucketAgg: BucketAggregation) => ({ + label: bucketAggregationConfig[bucketAgg.type].label, + value: bucketAgg.type, +}); + +interface QueryMetricEditorProps { + value: BucketAggregation; +} + +export const BucketAggregationEditor: FunctionComponent = ({ value }) => { + const datasource = useDatasource(); + const dispatch = useDispatch(); + + // TODO: Move this in a separate hook (and simplify) + const getFields = async () => { + const get = () => { + switch (value.type) { + case 'date_histogram': + return datasource.getFields('date'); + case 'geohash_grid': + return datasource.getFields('geo_point'); + default: + return datasource.getFields(); + } + }; + + return (await get()).map(toSelectableValue); + }; + + return ( + <> + dispatch(changeBucketAggregationType(value.id, e.value!))} + value={toOption(value)} + /> + + {isBucketAggregationWithField(value) && ( + dispatch(changeBucketAggregationField(value.id, e.value))} + placeholder="Select Field" + value={value.field} + /> + )} + + + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx new file mode 100644 index 00000000000..843481e120d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx @@ -0,0 +1,81 @@ +import { InlineField, Input, QueryField } from '@grafana/ui'; +import { css } from 'emotion'; +import React, { FunctionComponent, useEffect } from 'react'; +import { AddRemove } from '../../../../AddRemove'; +import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer'; +import { Filters } from '../../aggregations'; +import { changeBucketAggregationSetting } from '../../state/actions'; +import { BucketAggregationAction } from '../../state/types'; +import { addFilter, changeFilter, removeFilter } from './state/actions'; +import { reducer as filtersReducer } from './state/reducer'; + +interface Props { + value: Filters; +} + +export const FiltersSettingsEditor: FunctionComponent = ({ value }) => { + const upperStateDispatch = useDispatch>(); + + const dispatch = useStatelessReducer( + newState => upperStateDispatch(changeBucketAggregationSetting(value, 'filters', newState)), + value.settings?.filters, + filtersReducer + ); + + // The model might not have filters (or an empty array of filters) in it because of the way it was built in previous versions of the datasource. + // If this is the case we add a default one. + useEffect(() => { + if (!value.settings?.filters?.length) { + dispatch(addFilter()); + } + }, []); + + return ( + <> +
+ {value.settings?.filters!.map((filter, index) => ( +
+
+ + {}} + onChange={query => dispatch(changeFilter(index, { ...filter, query }))} + query={filter.query} + /> + +
+ + dispatch(changeFilter(index, { ...filter, label: e.target.value }))} + defaultValue={filter.label} + /> + + dispatch(addFilter())} + onRemove={() => dispatch(removeFilter(index))} + /> +
+ ))} +
+ + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts new file mode 100644 index 00000000000..61da5fbc131 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts @@ -0,0 +1,16 @@ +import { Filter } from '../../../aggregations'; +import { FilterAction, ADD_FILTER, REMOVE_FILTER, CHANGE_FILTER } from './types'; + +export const addFilter = (): FilterAction => ({ + type: ADD_FILTER, +}); + +export const removeFilter = (index: number): FilterAction => ({ + type: REMOVE_FILTER, + payload: { index }, +}); + +export const changeFilter = (index: number, filter: Filter): FilterAction => ({ + type: CHANGE_FILTER, + payload: { index, filter }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts new file mode 100644 index 00000000000..0d68450812d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts @@ -0,0 +1,52 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { Filter } from '../../../aggregations'; +import { addFilter, changeFilter, removeFilter } from './actions'; +import { reducer } from './reducer'; + +describe('Filters Bucket Aggregation Settings Reducer', () => { + it('Should correctly add new filter', () => { + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addFilter()) + .thenStatePredicateShouldEqual((state: Filter[]) => state.length === 1); + }); + + it('Should correctly remove filters', () => { + const firstFilter: Filter = { + label: 'First', + query: '*', + }; + + const secondFilter: Filter = { + label: 'Second', + query: '*', + }; + + reducerTester() + .givenReducer(reducer, [firstFilter, secondFilter]) + .whenActionIsDispatched(removeFilter(0)) + .thenStateShouldEqual([secondFilter]); + }); + + it("Should correctly change filter's attributes", () => { + const firstFilter: Filter = { + label: 'First', + query: '*', + }; + + const secondFilter: Filter = { + label: 'Second', + query: '*', + }; + + const expectedSecondFilter: Filter = { + label: 'Changed label', + query: 'Changed query', + }; + + reducerTester() + .givenReducer(reducer, [firstFilter, secondFilter]) + .whenActionIsDispatched(changeFilter(1, expectedSecondFilter)) + .thenStateShouldEqual([firstFilter, expectedSecondFilter]); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts new file mode 100644 index 00000000000..5a4dcde2e40 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts @@ -0,0 +1,21 @@ +import { Filter } from '../../../aggregations'; +import { defaultFilter } from '../utils'; +import { ADD_FILTER, CHANGE_FILTER, FilterAction, REMOVE_FILTER } from './types'; + +export const reducer = (state: Filter[] = [], action: FilterAction) => { + switch (action.type) { + case ADD_FILTER: + return [...state, defaultFilter()]; + case REMOVE_FILTER: + return state.slice(0, action.payload.index).concat(state.slice(action.payload.index + 1)); + + case CHANGE_FILTER: + return state.map((filter, index) => { + if (index !== action.payload.index) { + return filter; + } + + return action.payload.filter; + }); + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts new file mode 100644 index 00000000000..b9f5ff3ebb2 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts @@ -0,0 +1,22 @@ +import { Action } from '../../../../../../hooks/useStatelessReducer'; +import { Filter } from '../../../aggregations'; + +export const ADD_FILTER = '@bucketAggregations/filter/add'; +export const REMOVE_FILTER = '@bucketAggregations/filter/remove'; +export const CHANGE_FILTER = '@bucketAggregations/filter/change'; + +export type AddFilterAction = Action; + +export interface RemoveFilterAction extends Action { + payload: { + index: number; + }; +} + +export interface ChangeFilterAction extends Action { + payload: { + index: number; + filter: Filter; + }; +} +export type FilterAction = AddFilterAction | RemoveFilterAction | ChangeFilterAction; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts new file mode 100644 index 00000000000..ab35667b26b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts @@ -0,0 +1,3 @@ +import { Filter } from '../../aggregations'; + +export const defaultFilter = (): Filter => ({ label: '', query: '*' }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx new file mode 100644 index 00000000000..c9623b3600d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx @@ -0,0 +1,160 @@ +import { InlineField, Input, Select } from '@grafana/ui'; +import React, { ComponentProps, FunctionComponent } from 'react'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { SettingsEditorContainer } from '../../SettingsEditorContainer'; +import { changeBucketAggregationSetting } from '../state/actions'; +import { BucketAggregation } from '../aggregations'; +import { bucketAggregationConfig, intervalOptions, orderByOptions, orderOptions, sizeOptions } from '../utils'; +import { FiltersSettingsEditor } from './FiltersSettingsEditor'; +import { useDescription } from './useDescription'; +import { useQuery } from '../../ElasticsearchQueryContext'; +import { describeMetric } from '../../../../utils'; + +const inlineFieldProps: Partial> = { + labelWidth: 16, +}; + +interface Props { + bucketAgg: BucketAggregation; +} + +export const SettingsEditor: FunctionComponent = ({ bucketAgg }) => { + const dispatch = useDispatch(); + const { metrics } = useQuery(); + const settingsDescription = useDescription(bucketAgg); + + const orderBy = [...orderByOptions, ...(metrics || []).map(m => ({ label: describeMetric(m), value: m.id }))]; + + return ( + + {bucketAgg.type === 'terms' && ( + <> + + dispatch(changeBucketAggregationSetting(bucketAgg, 'size', e.value!))} + options={sizeOptions} + value={bucketAgg.settings?.size || bucketAggregationConfig[bucketAgg.type].defaultSettings?.size} + allowCustomValue + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))} + defaultValue={ + bucketAgg.settings?.min_doc_count || + bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'missing', e.target.value!))} + defaultValue={ + bucketAgg.settings?.missing || bucketAggregationConfig[bucketAgg.type].defaultSettings?.missing + } + /> + + + )} + + {bucketAgg.type === 'geohash_grid' && ( + + dispatch(changeBucketAggregationSetting(bucketAgg, 'precision', e.target.value!))} + defaultValue={ + bucketAgg.settings?.precision || bucketAggregationConfig[bucketAgg.type].defaultSettings?.precision + } + /> + + )} + + {bucketAgg.type === 'date_histogram' && ( + <> + + dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))} + defaultValue={ + bucketAgg.settings?.min_doc_count || + bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'trimEdges', e.target.value!))} + defaultValue={ + bucketAgg.settings?.trimEdges || bucketAggregationConfig[bucketAgg.type].defaultSettings?.trimEdges + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'offset', e.target.value!))} + defaultValue={ + bucketAgg.settings?.offset || bucketAggregationConfig[bucketAgg.type].defaultSettings?.offset + } + /> + + + )} + + {bucketAgg.type === 'histogram' && ( + <> + + dispatch(changeBucketAggregationSetting(bucketAgg, 'interval', e.target.value!))} + defaultValue={ + bucketAgg.settings?.interval || bucketAggregationConfig[bucketAgg.type].defaultSettings?.interval + } + /> + + + + dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))} + defaultValue={ + bucketAgg.settings?.min_doc_count || + bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count + } + /> + + + )} + + {bucketAgg.type === 'filters' && } + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts new file mode 100644 index 00000000000..b6244a3d7eb --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts @@ -0,0 +1,89 @@ +import { describeMetric } from '../../../../utils'; +import { useQuery } from '../../ElasticsearchQueryContext'; +import { BucketAggregation } from '../aggregations'; +import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils'; + +const hasValue = (value: string) => (object: { value: string }) => object.value === value; + +// FIXME: We should apply the same defaults we have in bucketAggregationsConfig here instead of "custom" values +// as they might get out of sync. +// The reason we need them is that even though after the refactoring each setting is created with its default value, +// queries created with the old version might not have them. +export const useDescription = (bucketAgg: BucketAggregation): string => { + const { metrics } = useQuery(); + + switch (bucketAgg.type) { + case 'terms': { + const order = bucketAgg.settings?.order || 'desc'; + const size = bucketAgg.settings?.size || '10'; + const minDocCount = parseInt(bucketAgg.settings?.min_doc_count || '0', 10); + const orderBy = bucketAgg.settings?.orderBy || '_term'; + let description = ''; + + if (size !== '0') { + const orderLabel = orderOptions.find(hasValue(order))?.label!; + description = `${orderLabel} ${size}, `; + } + + if (minDocCount > 0) { + description += `Min Doc Count: ${minDocCount}, `; + } + + description += 'Order by: '; + const orderByOption = orderByOptions.find(hasValue(orderBy)); + if (orderByOption) { + description += orderByOption.label; + } else { + const metric = metrics?.find(m => m.id === orderBy); + if (metric) { + description += describeMetric(metric); + } else { + description += 'metric not found'; + } + } + + if (size === '0') { + description += ` (${order})`; + } + return description; + } + + case 'histogram': { + const interval = bucketAgg.settings?.interval || 1000; + const minDocCount = bucketAgg.settings?.min_doc_count || 1; + + return `Interval: ${interval}${minDocCount > 0 ? `, Min Doc Count: ${minDocCount}` : ''}`; + } + + case 'filters': { + const filters = bucketAgg.settings?.filters || bucketAggregationConfig['filters'].defaultSettings?.filters; + return `Filter Queries (${filters!.length})`; + } + + case 'geohash_grid': { + const precision = Math.max(Math.min(parseInt(bucketAgg.settings?.precision || '5', 10), 12), 1); + return `Precision: ${precision}`; + } + + case 'date_histogram': { + const interval = bucketAgg.settings?.interval || 'auto'; + const minDocCount = bucketAgg.settings?.min_doc_count || 0; + const trimEdges = bucketAgg.settings?.trimEdges || 0; + + let description = `Interval: ${interval}`; + + if (minDocCount > 0) { + description += `, Min Doc Count: ${minDocCount}`; + } + + if (trimEdges > 0) { + description += `, Trim edges: ${trimEdges}`; + } + + return description; + } + + default: + return 'Settings'; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts new file mode 100644 index 00000000000..36f8e592c3c --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts @@ -0,0 +1,68 @@ +import { bucketAggregationConfig } from './utils'; + +export type BucketAggregationType = 'terms' | 'filters' | 'geohash_grid' | 'date_histogram' | 'histogram'; + +interface BaseBucketAggregation { + id: string; + type: BucketAggregationType; + settings?: Record; +} + +export interface BucketAggregationWithField extends BaseBucketAggregation { + field?: string; +} + +export interface DateHistogram extends BucketAggregationWithField { + type: 'date_histogram'; + settings?: { + interval?: string; + min_doc_count?: string; + trimEdges?: string; + offset?: string; + }; +} + +export interface Histogram extends BucketAggregationWithField { + type: 'histogram'; + settings?: { + interval?: string; + min_doc_count?: string; + }; +} + +type TermsOrder = 'desc' | 'asc'; + +export interface Terms extends BucketAggregationWithField { + type: 'terms'; + settings?: { + order?: TermsOrder; + size?: string; + min_doc_count?: string; + orderBy?: string; + missing?: string; + }; +} + +export type Filter = { + query: string; + label: string; +}; +export interface Filters extends BaseBucketAggregation { + type: 'filters'; + settings?: { + filters?: Filter[]; + }; +} + +interface GeoHashGrid extends BucketAggregationWithField { + type: 'geohash_grid'; + settings?: { + precision?: string; + }; +} + +export type BucketAggregation = DateHistogram | Histogram | Terms | Filters | GeoHashGrid; + +export const isBucketAggregationWithField = ( + bucketAgg: BucketAggregation | BucketAggregationWithField +): bucketAgg is BucketAggregationWithField => bucketAggregationConfig[bucketAgg.type].requiresField; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx new file mode 100644 index 00000000000..841d12e634a --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx @@ -0,0 +1,37 @@ +import React, { FunctionComponent } from 'react'; +import { BucketAggregationEditor } from './BucketAggregationEditor'; +import { useDispatch } from '../../../hooks/useStatelessReducer'; +import { addBucketAggregation, removeBucketAggregation } from './state/actions'; +import { BucketAggregationAction } from './state/types'; +import { BucketAggregation } from './aggregations'; +import { useQuery } from '../ElasticsearchQueryContext'; +import { QueryEditorRow } from '../QueryEditorRow'; +import { IconButton } from '../../IconButton'; + +interface Props { + nextId: BucketAggregation['id']; +} + +export const BucketAggregationsEditor: FunctionComponent = ({ nextId }) => { + const dispatch = useDispatch(); + const { bucketAggs } = useQuery(); + const totalBucketAggs = bucketAggs?.length || 0; + + return ( + <> + {bucketAggs!.map((bucketAgg, index) => ( + 1 && (() => dispatch(removeBucketAggregation(bucketAgg.id)))} + > + + + {index === 0 && ( + dispatch(addBucketAggregation(nextId))} label="add" /> + )} + + ))} + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts new file mode 100644 index 00000000000..31203eea890 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts @@ -0,0 +1,61 @@ +import { SettingKeyOf } from '../../../types'; +import { BucketAggregation, BucketAggregationWithField } from '../aggregations'; +import { + ADD_BUCKET_AGG, + BucketAggregationAction, + REMOVE_BUCKET_AGG, + CHANGE_BUCKET_AGG_TYPE, + CHANGE_BUCKET_AGG_FIELD, + CHANGE_BUCKET_AGG_SETTING, + ChangeBucketAggregationSettingAction, +} from './types'; + +export const addBucketAggregation = (id: string): BucketAggregationAction => ({ + type: ADD_BUCKET_AGG, + payload: { + id, + }, +}); + +export const removeBucketAggregation = (id: BucketAggregation['id']): BucketAggregationAction => ({ + type: REMOVE_BUCKET_AGG, + payload: { + id, + }, +}); + +export const changeBucketAggregationType = ( + id: BucketAggregation['id'], + newType: BucketAggregation['type'] +): BucketAggregationAction => ({ + type: CHANGE_BUCKET_AGG_TYPE, + payload: { + id, + newType, + }, +}); + +export const changeBucketAggregationField = ( + id: BucketAggregationWithField['id'], + newField: BucketAggregationWithField['field'] +): BucketAggregationAction => ({ + type: CHANGE_BUCKET_AGG_FIELD, + payload: { + id, + newField, + }, +}); + +export const changeBucketAggregationSetting = >( + bucketAgg: T, + settingName: K, + // This could be inferred from T, but it's causing some troubles + newValue: string | string[] | any +): ChangeBucketAggregationSettingAction => ({ + type: CHANGE_BUCKET_AGG_SETTING, + payload: { + bucketAgg, + settingName, + newValue, + }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts new file mode 100644 index 00000000000..5aa07b67f62 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -0,0 +1,143 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; +import { BucketAggregation, DateHistogram } from '../aggregations'; +import { bucketAggregationConfig } from '../utils'; +import { + addBucketAggregation, + changeBucketAggregationField, + changeBucketAggregationSetting, + changeBucketAggregationType, + removeBucketAggregation, +} from './actions'; +import { reducer } from './reducer'; + +describe('Bucket Aggregations Reducer', () => { + it('Should correctly add new aggregations', () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'terms', + settings: bucketAggregationConfig['terms'].defaultSettings, + }; + + const secondAggregation: BucketAggregation = { + id: '1', + type: 'terms', + settings: bucketAggregationConfig['terms'].defaultSettings, + }; + + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addBucketAggregation(firstAggregation.id)) + .thenStateShouldEqual([firstAggregation]) + .whenActionIsDispatched(addBucketAggregation(secondAggregation.id)) + .thenStateShouldEqual([firstAggregation, secondAggregation]); + }); + + it('Should correctly remove aggregations', () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'date_histogram', + }; + + const secondAggregation: BucketAggregation = { + id: '2', + type: 'date_histogram', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(removeBucketAggregation(firstAggregation.id)) + .thenStateShouldEqual([secondAggregation]); + }); + + it("Should correctly change aggregation's type", () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'date_histogram', + }; + const secondAggregation: BucketAggregation = { + id: '2', + type: 'date_histogram', + }; + + const expectedSecondAggregation: BucketAggregation = { + ...secondAggregation, + type: 'histogram', + settings: bucketAggregationConfig['histogram'].defaultSettings, + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeBucketAggregationType(secondAggregation.id, expectedSecondAggregation.type)) + .thenStateShouldEqual([firstAggregation, expectedSecondAggregation]); + }); + + it("Should correctly change aggregation's field", () => { + const firstAggregation: BucketAggregation = { + id: '1', + type: 'date_histogram', + }; + const secondAggregation: BucketAggregation = { + id: '2', + type: 'date_histogram', + }; + + const expectedSecondAggregation = { + ...secondAggregation, + field: 'new field', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeBucketAggregationField(secondAggregation.id, expectedSecondAggregation.field)) + .thenStateShouldEqual([firstAggregation, expectedSecondAggregation]); + }); + + describe("When changing a metric aggregation's type", () => { + it('Should remove and restore bucket aggregations correctly', () => { + const initialState: BucketAggregation[] = [ + { + id: '1', + type: 'date_histogram', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + // If the new metric aggregation is `isSingleMetric` we should remove all bucket aggregations. + .whenActionIsDispatched(changeMetricType('Some id', 'raw_data')) + .thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 0) + // Switching back to another aggregation that is NOT `isSingleMetric` should bring back a bucket aggregation + .whenActionIsDispatched(changeMetricType('Some id', 'max')) + .thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 1) + // When none of the above is true state shouldn't change. + .whenActionIsDispatched(changeMetricType('Some id', 'min')) + .thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 1); + }); + }); + + it("Should correctly change aggregation's settings", () => { + const firstAggregation: DateHistogram = { + id: '1', + type: 'date_histogram', + settings: { + min_doc_count: '0', + }, + }; + const secondAggregation: DateHistogram = { + id: '2', + type: 'date_histogram', + }; + + const expectedSettings: typeof firstAggregation['settings'] = { + min_doc_count: '1', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched( + changeBucketAggregationSetting(firstAggregation, 'min_doc_count', expectedSettings.min_doc_count!) + ) + .thenStateShouldEqual([{ ...firstAggregation, settings: expectedSettings }, secondAggregation]); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts new file mode 100644 index 00000000000..db2b17473ff --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts @@ -0,0 +1,110 @@ +import { defaultBucketAgg } from '../../../../query_def'; +import { ElasticsearchQuery } from '../../../../types'; +import { ChangeMetricTypeAction, CHANGE_METRIC_TYPE } from '../../MetricAggregationsEditor/state/types'; +import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils'; +import { BucketAggregation, Terms } from '../aggregations'; +import { INIT, InitAction } from '../../state'; +import { + ADD_BUCKET_AGG, + REMOVE_BUCKET_AGG, + CHANGE_BUCKET_AGG_TYPE, + CHANGE_BUCKET_AGG_FIELD, + CHANGE_BUCKET_AGG_SETTING, + BucketAggregationAction, +} from './types'; +import { bucketAggregationConfig } from '../utils'; +import { removeEmpty } from '../../../../utils'; + +export const reducer = ( + state: BucketAggregation[], + action: BucketAggregationAction | ChangeMetricTypeAction | InitAction +): ElasticsearchQuery['bucketAggs'] => { + switch (action.type) { + case ADD_BUCKET_AGG: + const newAgg: Terms = { + id: action.payload.id, + type: 'terms', + settings: bucketAggregationConfig['terms'].defaultSettings, + }; + + // If the last bucket aggregation is a `date_histogram` we add the new one before it. + const lastAgg = state[state.length - 1]; + if (lastAgg?.type === 'date_histogram') { + return [...state.slice(0, state.length - 1), newAgg, lastAgg]; + } + + return [...state, newAgg]; + + case REMOVE_BUCKET_AGG: + return state.filter(bucketAgg => bucketAgg.id !== action.payload.id); + + case CHANGE_BUCKET_AGG_TYPE: + return state.map(bucketAgg => { + if (bucketAgg.id !== action.payload.id) { + return bucketAgg; + } + + /* + TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations + in the new selected one (such as field or some settings). + It the future would be nice to have the same behavior but it's hard without a proper definition, + as Elasticsearch will error sometimes if some settings are not compatible. + */ + return { + id: bucketAgg.id, + type: action.payload.newType, + settings: bucketAggregationConfig[action.payload.newType].defaultSettings, + } as BucketAggregation; + }); + + case CHANGE_BUCKET_AGG_FIELD: + return state.map(bucketAgg => { + if (bucketAgg.id !== action.payload.id) { + return bucketAgg; + } + + return { + ...bucketAgg, + field: action.payload.newField, + }; + }); + + case CHANGE_METRIC_TYPE: + // If we are switching to a metric which requires the absence of bucket aggregations + // we remove all of them. + if (metricAggregationConfig[action.payload.type].isSingleMetric) { + return []; + } else if (state.length === 0) { + // Else, if there are no bucket aggregations we restore a default one. + // This happens when switching from a metric that requires the absence of bucket aggregations to + // one that requires it. + return [defaultBucketAgg()]; + } + return state; + + case CHANGE_BUCKET_AGG_SETTING: + return state.map(bucketAgg => { + if (bucketAgg.id !== action.payload.bucketAgg.id) { + return bucketAgg; + } + + const newSettings = removeEmpty({ + ...bucketAgg.settings, + [action.payload.settingName]: action.payload.newValue, + }); + + return { + ...bucketAgg, + settings: { + ...newSettings, + }, + }; + }); + + case INIT: + return [defaultBucketAgg()]; + + default: + return state; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts new file mode 100644 index 00000000000..a0b0f6e9510 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts @@ -0,0 +1,51 @@ +import { Action } from '../../../../hooks/useStatelessReducer'; +import { SettingKeyOf } from '../../../types'; +import { BucketAggregation, BucketAggregationWithField } from '../aggregations'; + +export const ADD_BUCKET_AGG = '@bucketAggs/add'; +export const REMOVE_BUCKET_AGG = '@bucketAggs/remove'; +export const CHANGE_BUCKET_AGG_TYPE = '@bucketAggs/change_type'; +export const CHANGE_BUCKET_AGG_FIELD = '@bucketAggs/change_field'; +export const CHANGE_BUCKET_AGG_SETTING = '@bucketAggs/change_setting'; + +export interface AddBucketAggregationAction extends Action { + payload: { + id: BucketAggregation['id']; + }; +} + +export interface RemoveBucketAggregationAction extends Action { + payload: { + id: BucketAggregation['id']; + }; +} + +export interface ChangeBucketAggregationTypeAction extends Action { + payload: { + id: BucketAggregation['id']; + newType: BucketAggregation['type']; + }; +} + +export interface ChangeBucketAggregationFieldAction extends Action { + payload: { + id: BucketAggregation['id']; + newField: BucketAggregationWithField['field']; + }; +} + +export interface ChangeBucketAggregationSettingAction + extends Action { + payload: { + bucketAgg: T; + settingName: SettingKeyOf; + newValue: unknown; + }; +} + +export type BucketAggregationAction = + | AddBucketAggregationAction + | RemoveBucketAggregationAction + | ChangeBucketAggregationTypeAction + | ChangeBucketAggregationFieldAction + | ChangeBucketAggregationSettingAction; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts new file mode 100644 index 00000000000..3149deef23a --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts @@ -0,0 +1,79 @@ +import { BucketsConfiguration } from '../../../types'; +import { defaultFilter } from './SettingsEditor/FiltersSettingsEditor/utils'; + +export const bucketAggregationConfig: BucketsConfiguration = { + terms: { + label: 'Terms', + requiresField: true, + defaultSettings: { + min_doc_count: '0', + size: '10', + order: 'desc', + orderBy: '_term', + }, + }, + filters: { + label: 'Filters', + requiresField: false, + defaultSettings: { + filters: [defaultFilter()], + }, + }, + geohash_grid: { + label: 'Geo Hash Grid', + requiresField: true, + defaultSettings: { + precision: '3', + }, + }, + date_histogram: { + label: 'Date Histogram', + requiresField: true, + defaultSettings: { + interval: 'auto', + min_doc_count: '0', + trimEdges: '0', + }, + }, + histogram: { + label: 'Histogram', + requiresField: true, + defaultSettings: { + interval: '1000', + min_doc_count: '0', + }, + }, +}; + +// TODO: Define better types for the following +export const orderOptions = [ + { label: 'Top', value: 'desc' }, + { label: 'Bottom', value: 'asc' }, +]; + +export const sizeOptions = [ + { label: 'No limit', value: '0' }, + { label: '1', value: '1' }, + { label: '2', value: '2' }, + { label: '3', value: '3' }, + { label: '5', value: '5' }, + { label: '10', value: '10' }, + { label: '15', value: '15' }, + { label: '20', value: '20' }, +]; + +export const orderByOptions = [ + { label: 'Term value', value: '_term' }, + { label: 'Doc Count', value: '_count' }, +]; + +export const intervalOptions = [ + { label: 'auto', value: 'auto' }, + { label: '10s', value: '10s' }, + { label: '1m', value: '1m' }, + { label: '5m', value: '5m' }, + { label: '10m', value: '10m' }, + { label: '20m', value: '20m' }, + { label: '1h', value: '1h' }, + { label: '1d', value: '1d' }, +]; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx new file mode 100644 index 00000000000..6240b2b6cb6 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx @@ -0,0 +1,59 @@ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ElasticsearchProvider, useDatasource, useQuery } from './ElasticsearchQueryContext'; +import { ElasticsearchQuery } from '../../types'; +import { ElasticDatasource } from '../../datasource'; + +const query: ElasticsearchQuery = { + refId: 'A', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], +}; + +describe('ElasticsearchQueryContext', () => { + describe('useQuery Hook', () => { + it('Should throw when used outside of ElasticsearchQueryContext', () => { + const { result } = renderHook(() => useQuery()); + + expect(result.error).toBeTruthy(); + }); + + it('Should return the current query object', () => { + const wrapper: FunctionComponent = ({ children }) => ( + {}}> + {children} + + ); + + const { result } = renderHook(() => useQuery(), { + wrapper, + }); + + expect(result.current).toBe(query); + }); + }); + + describe('useDatasource Hook', () => { + it('Should throw when used outside of ElasticsearchQueryContext', () => { + const { result } = renderHook(() => useDatasource()); + + expect(result.error).toBeTruthy(); + }); + + it('Should return the current datasource instance', () => { + const datasource = {} as ElasticDatasource; + + const wrapper: FunctionComponent = ({ children }) => ( + {}}> + {children} + + ); + + const { result } = renderHook(() => useDatasource(), { + wrapper, + }); + + expect(result.current).toBe(datasource); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx new file mode 100644 index 00000000000..b1322f49417 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, FunctionComponent, useContext } from 'react'; +import { ElasticDatasource } from '../../datasource'; +import { combineReducers, useStatelessReducer, DispatchContext } from '../../hooks/useStatelessReducer'; +import { ElasticsearchQuery } from '../../types'; + +import { reducer as metricsReducer } from './MetricAggregationsEditor/state/reducer'; +import { reducer as bucketAggsReducer } from './BucketAggregationsEditor/state/reducer'; +import { aliasPatternReducer, queryReducer, initQuery } from './state'; + +const DatasourceContext = createContext(undefined); +const QueryContext = createContext(undefined); + +interface Props { + query: ElasticsearchQuery; + onChange: (query: ElasticsearchQuery) => void; + datasource: ElasticDatasource; +} + +export const ElasticsearchProvider: FunctionComponent = ({ children, onChange, query, datasource }) => { + const reducer = combineReducers({ + query: queryReducer, + alias: aliasPatternReducer, + metrics: metricsReducer, + bucketAggs: bucketAggsReducer, + }); + + const dispatch = useStatelessReducer(newState => onChange({ ...query, ...newState }), query, reducer); + + // This initializes the query by dispatching an init action to each reducer. + // useStatelessReducer will then call `onChange` with the newly generated query + if (!query.metrics && !query.bucketAggs) { + dispatch(initQuery()); + + return null; + } + + return ( + + + {children} + + + ); +}; + +export const useQuery = (): ElasticsearchQuery => { + const query = useContext(QueryContext); + + if (!query) { + throw new Error('use ElasticsearchProvider first.'); + } + + return query; +}; + +export const useDatasource = () => { + const datasource = useContext(DatasourceContext); + if (!datasource) { + throw new Error('use ElasticsearchProvider first.'); + } + + return datasource; +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx new file mode 100644 index 00000000000..8b48c8025b2 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx @@ -0,0 +1,120 @@ +import { MetricFindValue, SelectableValue } from '@grafana/data'; +import { Segment, SegmentAsync, useTheme } from '@grafana/ui'; +import { cx } from 'emotion'; +import React, { FunctionComponent } from 'react'; +import { useDatasource, useQuery } from '../ElasticsearchQueryContext'; +import { useDispatch } from '../../../hooks/useStatelessReducer'; +import { getStyles } from './styles'; +import { SettingsEditor } from './SettingsEditor'; +import { MetricAggregationAction } from './state/types'; +import { metricAggregationConfig } from './utils'; +import { changeMetricField, changeMetricType } from './state/actions'; +import { MetricPicker } from '../../MetricPicker'; +import { segmentStyles } from '../styles'; +import { + isMetricAggregationWithField, + isMetricAggregationWithSettings, + isPipelineAggregation, + isPipelineAggregationWithMultipleBucketPaths, + MetricAggregation, + MetricAggregationType, +} from './aggregations'; + +const toOption = (metric: MetricAggregation) => ({ + label: metricAggregationConfig[metric.type].label, + value: metric.type, +}); + +const toSelectableValue = ({ value, text }: MetricFindValue): SelectableValue => ({ + label: text, + value: `${value || text}`, +}); + +interface Props { + value: MetricAggregation; +} + +// If a metric is a Pipeline Aggregation (https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html) +// it doesn't make sense to show it in the type picker when there is no non-pipeline-aggregation previously selected +// as they work on the outputs produced from other aggregations rather than from documents or fields. +// This means we should filter them out from the type picker if there's no other "basic" aggregation before the current one. +const isBasicAggregation = (metric: MetricAggregation) => !metricAggregationConfig[metric.type].isPipelineAgg; + +const getTypeOptions = ( + previousMetrics: MetricAggregation[], + esVersion: number +): Array> => { + // we'll include Pipeline Aggregations only if at least one previous metric is a "Basic" one + const includePipelineAggregations = previousMetrics.some(isBasicAggregation); + + return ( + Object.entries(metricAggregationConfig) + // Only showing metrics type supported by the configured version of ES + .filter(([_, { minVersion = 0, maxVersion = esVersion }]) => { + // TODO: Double check this + return esVersion >= minVersion && esVersion <= maxVersion; + }) + // Filtering out Pipeline Aggregations if there's no basic metric selected before + .filter(([_, config]) => includePipelineAggregations || !config.isPipelineAgg) + .map(([key, { label }]) => ({ + label, + value: key as MetricAggregationType, + })) + ); +}; + +export const MetricEditor: FunctionComponent = ({ value }) => { + const styles = getStyles(useTheme(), !!value.hide); + const datasource = useDatasource(); + const query = useQuery(); + const dispatch = useDispatch(); + + const previousMetrics = query.metrics!.slice( + 0, + query.metrics!.findIndex(m => m.id === value.id) + ); + + // TODO: This could be common with the one in BucketAggregationEditor + const getFields = async () => { + const get = () => { + if (value.type === 'cardinality') { + return datasource.getFields(); + } + return datasource.getFields('number'); + }; + + return (await get()).map(toSelectableValue); + }; + + return ( + <> + dispatch(changeMetricType(value.id, e.value!))} + value={toOption(value)} + /> + + {isMetricAggregationWithField(value) && !isPipelineAggregation(value) && ( + dispatch(changeMetricField(value.id, e.value!))} + placeholder="Select Field" + value={value.field} + /> + )} + + {isPipelineAggregation(value) && !isPipelineAggregationWithMultipleBucketPaths(value) && ( + dispatch(changeMetricField(value.id, e.value?.id!))} + options={previousMetrics} + value={value.field} + /> + )} + + {isMetricAggregationWithSettings(value) && } + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx new file mode 100644 index 00000000000..c0f28b58f5c --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx @@ -0,0 +1,98 @@ +import React, { Fragment, FunctionComponent, useEffect } from 'react'; +import { Input, InlineLabel } from '@grafana/ui'; +import { MetricAggregationAction } from '../../state/types'; +import { changeMetricAttribute } from '../../state/actions'; +import { css } from 'emotion'; +import { AddRemove } from '../../../../AddRemove'; +import { useStatelessReducer, useDispatch } from '../../../../../hooks/useStatelessReducer'; +import { MetricPicker } from '../../../../MetricPicker'; +import { reducer } from './state/reducer'; +import { + addPipelineVariable, + removePipelineVariable, + renamePipelineVariable, + changePipelineVariableMetric, +} from './state/actions'; +import { SettingField } from '../SettingField'; +import { BucketScript, MetricAggregation } from '../../aggregations'; + +interface Props { + value: BucketScript; + previousMetrics: MetricAggregation[]; +} + +export const BucketScriptSettingsEditor: FunctionComponent = ({ value, previousMetrics }) => { + const upperStateDispatch = useDispatch>(); + + const dispatch = useStatelessReducer( + newState => upperStateDispatch(changeMetricAttribute(value, 'pipelineVariables', newState)), + value.pipelineVariables, + reducer + ); + + // The model might not have pipeline variables (or an empty array of pipeline vars) in it because of the way it was built in previous versions of the datasource. + // If this is the case we add a default one. + useEffect(() => { + if (!value.pipelineVariables?.length) { + dispatch(addPipelineVariable()); + } + }, []); + + return ( + <> +
+ Variables +
+ {value.pipelineVariables!.map((pipelineVar, index) => ( + +
+ dispatch(renamePipelineVariable(e.target.value, index))} + /> + dispatch(changePipelineVariableMetric(e.value!.id, index))} + options={previousMetrics} + value={pipelineVar.pipelineAgg} + /> +
+ + dispatch(addPipelineVariable())} + onRemove={() => dispatch(removePipelineVariable(index))} + /> +
+ ))} +
+
+ + + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts new file mode 100644 index 00000000000..67d32db96ca --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts @@ -0,0 +1,34 @@ +import { + ADD_PIPELINE_VARIABLE, + REMOVE_PIPELINE_VARIABLE, + PipelineVariablesAction, + RENAME_PIPELINE_VARIABLE, + CHANGE_PIPELINE_VARIABLE_METRIC, +} from './types'; + +export const addPipelineVariable = (): PipelineVariablesAction => ({ + type: ADD_PIPELINE_VARIABLE, +}); + +export const removePipelineVariable = (index: number): PipelineVariablesAction => ({ + type: REMOVE_PIPELINE_VARIABLE, + payload: { + index, + }, +}); + +export const renamePipelineVariable = (newName: string, index: number): PipelineVariablesAction => ({ + type: RENAME_PIPELINE_VARIABLE, + payload: { + index, + newName, + }, +}); + +export const changePipelineVariableMetric = (newMetric: string, index: number): PipelineVariablesAction => ({ + type: CHANGE_PIPELINE_VARIABLE_METRIC, + payload: { + index, + newMetric, + }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts new file mode 100644 index 00000000000..bb1c99f5c49 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts @@ -0,0 +1,102 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { PipelineVariable } from '../../../aggregations'; +import { + addPipelineVariable, + changePipelineVariableMetric, + removePipelineVariable, + renamePipelineVariable, +} from './actions'; +import { reducer } from './reducer'; + +describe('BucketScript Settings Reducer', () => { + it('Should correctly add new pipeline variable', () => { + const expectedPipelineVar: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addPipelineVariable()) + .thenStateShouldEqual([expectedPipelineVar]); + }); + + it('Should correctly remove pipeline variables', () => { + const firstVar: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + const secondVar: PipelineVariable = { + name: 'var2', + pipelineAgg: '', + }; + + reducerTester() + .givenReducer(reducer, [firstVar, secondVar]) + .whenActionIsDispatched(removePipelineVariable(0)) + .thenStateShouldEqual([secondVar]); + }); + + it('Should correctly rename pipeline variable', () => { + const firstVar: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + const secondVar: PipelineVariable = { + name: 'var2', + pipelineAgg: '', + }; + + const expectedSecondVar: PipelineVariable = { + ...secondVar, + name: 'new name', + }; + + reducerTester() + .givenReducer(reducer, [firstVar, secondVar]) + .whenActionIsDispatched(renamePipelineVariable(expectedSecondVar.name, 1)) + .thenStateShouldEqual([firstVar, expectedSecondVar]); + }); + + it('Should correctly change pipeline variable target metric', () => { + const firstVar: PipelineVariable = { + name: 'var1', + pipelineAgg: '', + }; + + const secondVar: PipelineVariable = { + name: 'var2', + pipelineAgg: 'some agg', + }; + + const expectedSecondVar: PipelineVariable = { + ...secondVar, + pipelineAgg: 'some new agg', + }; + + reducerTester() + .givenReducer(reducer, [firstVar, secondVar]) + .whenActionIsDispatched(changePipelineVariableMetric(expectedSecondVar.pipelineAgg, 1)) + .thenStateShouldEqual([firstVar, expectedSecondVar]); + }); + + it('Should not change state with other action types', () => { + const initialState: PipelineVariable[] = [ + { + name: 'var1', + pipelineAgg: '1', + }, + { + name: 'var2', + pipelineAgg: '2', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts new file mode 100644 index 00000000000..3541ae52d51 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts @@ -0,0 +1,46 @@ +import { PipelineVariable } from '../../../aggregations'; +import { defaultPipelineVariable } from '../utils'; +import { + PipelineVariablesAction, + REMOVE_PIPELINE_VARIABLE, + ADD_PIPELINE_VARIABLE, + RENAME_PIPELINE_VARIABLE, + CHANGE_PIPELINE_VARIABLE_METRIC, +} from './types'; + +export const reducer = (state: PipelineVariable[] = [], action: PipelineVariablesAction) => { + switch (action.type) { + case ADD_PIPELINE_VARIABLE: + return [...state, defaultPipelineVariable()]; + + case REMOVE_PIPELINE_VARIABLE: + return state.slice(0, action.payload.index).concat(state.slice(action.payload.index + 1)); + + case RENAME_PIPELINE_VARIABLE: + return state.map((pipelineVariable, index) => { + if (index !== action.payload.index) { + return pipelineVariable; + } + + return { + ...pipelineVariable, + name: action.payload.newName, + }; + }); + + case CHANGE_PIPELINE_VARIABLE_METRIC: + return state.map((pipelineVariable, index) => { + if (index !== action.payload.index) { + return pipelineVariable; + } + + return { + ...pipelineVariable, + pipelineAgg: action.payload.newMetric, + }; + }); + + default: + return state; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts new file mode 100644 index 00000000000..7807a550b30 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts @@ -0,0 +1,34 @@ +import { Action } from '../../../../../../hooks/useStatelessReducer'; + +export const ADD_PIPELINE_VARIABLE = '@pipelineVariables/add'; +export const REMOVE_PIPELINE_VARIABLE = '@pipelineVariables/remove'; +export const RENAME_PIPELINE_VARIABLE = '@pipelineVariables/rename'; +export const CHANGE_PIPELINE_VARIABLE_METRIC = '@pipelineVariables/change_metric'; + +export type AddPipelineVariableAction = Action; + +export interface RemovePipelineVariableAction extends Action { + payload: { + index: number; + }; +} + +export interface RenamePipelineVariableAction extends Action { + payload: { + index: number; + newName: string; + }; +} + +export interface ChangePipelineVariableMetricAction extends Action { + payload: { + index: number; + newMetric: string; + }; +} + +export type PipelineVariablesAction = + | AddPipelineVariableAction + | RemovePipelineVariableAction + | RenamePipelineVariableAction + | ChangePipelineVariableMetricAction; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts new file mode 100644 index 00000000000..a3169a07366 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts @@ -0,0 +1,3 @@ +import { PipelineVariable } from '../../aggregations'; + +export const defaultPipelineVariable = (): PipelineVariable => ({ name: 'var1', pipelineAgg: '' }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx new file mode 100644 index 00000000000..0ac5e43f875 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx @@ -0,0 +1,178 @@ +import { Input, InlineField, Select, Switch } from '@grafana/ui'; +import React, { FunctionComponent } from 'react'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { movingAvgModelOptions } from '../../../../query_def'; +import { isEWMAMovingAverage, isHoltMovingAverage, isHoltWintersMovingAverage, MovingAverage } from '../aggregations'; +import { changeMetricSetting } from '../state/actions'; + +interface Props { + metric: MovingAverage; +} + +// The way we handle changes for those settings is not ideal compared to the other components in the editor +export const MovingAverageSettingsEditor: FunctionComponent = ({ metric }) => { + const dispatch = useDispatch(); + + return ( + <> + + dispatch(changeMetricSetting(metric, 'window', parseInt(e.target.value!, 10)))} + defaultValue={metric.settings?.window} + /> + + + + dispatch(changeMetricSetting(metric, 'predict', parseInt(e.target.value!, 10)))} + defaultValue={metric.settings?.predict} + /> + + + {isEWMAMovingAverage(metric) && ( + <> + + dispatch(changeMetricSetting(metric, 'alpha', parseInt(e.target.value!, 10)))} + defaultValue={metric.settings?.alpha} + /> + + + + ) => + dispatch(changeMetricSetting(metric, 'minimize', e.target.checked)) + } + checked={!!metric.settings?.minimize} + /> + + + )} + + {isHoltMovingAverage(metric) && ( + <> + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + alpha: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.alpha} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + beta: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.beta} + /> + + + + ) => + dispatch(changeMetricSetting(metric, 'minimize', e.target.checked)) + } + checked={!!metric.settings?.minimize} + /> + + + )} + + {isHoltWintersMovingAverage(metric) && ( + <> + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + alpha: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.alpha} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + beta: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.beta} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + gamma: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.gamma} + /> + + + + dispatch( + changeMetricSetting(metric, 'settings', { + ...metric.settings?.settings, + period: parseInt(e.target.value!, 10), + }) + ) + } + defaultValue={metric.settings?.settings?.period} + /> + + + + ) => + dispatch( + changeMetricSetting(metric, 'settings', { ...metric.settings?.settings, pad: e.target.checked }) + ) + } + checked={!!metric.settings?.settings?.pad} + /> + + + + ) => + dispatch(changeMetricSetting(metric, 'minimize', e.target.checked)) + } + checked={!!metric.settings?.minimize} + /> + + + )} + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx new file mode 100644 index 00000000000..a29b43c5acf --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx @@ -0,0 +1,39 @@ +import React, { ComponentProps, useState } from 'react'; +import { InlineField, Input } from '@grafana/ui'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { changeMetricSetting } from '../state/actions'; +import { ChangeMetricSettingAction } from '../state/types'; +import { SettingKeyOf } from '../../../types'; +import { MetricAggregationWithSettings } from '../aggregations'; +import { uniqueId } from 'lodash'; + +interface Props> { + label: string; + settingName: K; + metric: T; + placeholder?: ComponentProps['placeholder']; + tooltip?: ComponentProps['tooltip']; +} + +export function SettingField>({ + label, + settingName, + metric, + placeholder, + tooltip, +}: Props) { + const dispatch = useDispatch>(); + const [id] = useState(uniqueId(`es-field-id-`)); + const settings = metric.settings; + + return ( + + dispatch(changeMetricSetting(metric, settingName, e.target.value as any))} + defaultValue={settings?.[settingName as keyof typeof settings]} + /> + + ); +} diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx new file mode 100644 index 00000000000..b8f5619ddec --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx @@ -0,0 +1,129 @@ +import { InlineField, Input, Switch } from '@grafana/ui'; +import React, { FunctionComponent, ComponentProps, useState } from 'react'; +import { extendedStats } from '../../../../query_def'; +import { useDispatch } from '../../../../hooks/useStatelessReducer'; +import { changeMetricMeta, changeMetricSetting } from '../state/actions'; +import { + MetricAggregation, + isMetricAggregationWithInlineScript, + isMetricAggregationWithMissingSupport, + ExtendedStat, +} from '../aggregations'; +import { BucketScriptSettingsEditor } from './BucketScriptSettingsEditor'; +import { SettingField } from './SettingField'; +import { SettingsEditorContainer } from '../../SettingsEditorContainer'; +import { useDescription } from './useDescription'; +import { MovingAverageSettingsEditor } from './MovingAverageSettingsEditor'; +import { uniqueId } from 'lodash'; +import { metricAggregationConfig } from '../utils'; + +// TODO: Move this somewhere and share it with BucketsAggregation Editor +const inlineFieldProps: Partial> = { + labelWidth: 16, +}; + +interface Props { + metric: MetricAggregation; + previousMetrics: MetricAggregation[]; +} + +export const SettingsEditor: FunctionComponent = ({ metric, previousMetrics }) => { + const dispatch = useDispatch(); + const description = useDescription(metric); + + return ( + + ); +}; + +interface ExtendedStatSettingProps { + stat: ExtendedStat; + onChange: (checked: boolean) => void; + value: boolean; +} +const ExtendedStatSetting: FunctionComponent = ({ stat, onChange, value }) => { + // this is needed for the htmlFor prop in the label so that clicking the label will toggle the switch state. + const [id] = useState(uniqueId(`es-field-id-`)); + + return ( + + ) => onChange(e.target.checked)} value={value} /> + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts new file mode 100644 index 00000000000..9fb41306031 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts @@ -0,0 +1,40 @@ +import { extendedStats } from '../../../../query_def'; +import { MetricAggregation } from '../aggregations'; + +const hasValue = (value: string) => (object: { value: string }) => object.value === value; + +// FIXME: All the defaults and validations down here should be defined somewhere else +// as they are also the defaults that are gonna be applied to the query. +// In the previous version, the same method was taking care of describing the settings and setting defaults. +export const useDescription = (metric: MetricAggregation): string => { + switch (metric.type) { + case 'cardinality': { + const precisionThreshold = metric.settings?.precision_threshold || ''; + return `Precision threshold: ${precisionThreshold}`; + } + + case 'percentiles': + if (metric.settings?.percents && metric.settings?.percents?.length >= 1) { + return `Values: ${metric.settings?.percents}`; + } + + return 'Percents: Default'; + + case 'extended_stats': { + const selectedStats = Object.entries(metric.meta || {}) + .map(([key, value]) => value && extendedStats.find(hasValue(key))?.label) + .filter(Boolean); + + return `Stats: ${selectedStats.length > 0 ? selectedStats.join(', ') : 'None selected'}`; + } + + case 'raw_document': + case 'raw_data': { + const size = metric.settings?.size || 500; + return `Size: ${size}`; + } + + default: + return 'Options'; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts new file mode 100644 index 00000000000..fe6e645d2a3 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts @@ -0,0 +1,343 @@ +import { metricAggregationConfig } from './utils'; + +export type PipelineMetricAggregationType = + | 'moving_avg' + | 'moving_fn' + | 'derivative' + | 'cumulative_sum' + | 'bucket_script'; + +export type MetricAggregationType = + | 'count' + | 'avg' + | 'sum' + | 'min' + | 'max' + | 'extended_stats' + | 'percentiles' + | 'cardinality' + | 'raw_document' + | 'raw_data' + | 'logs' + | PipelineMetricAggregationType; + +interface BaseMetricAggregation { + id: string; + type: MetricAggregationType; + hide?: boolean; +} + +export interface PipelineVariable { + name: string; + pipelineAgg: string; +} + +export interface MetricAggregationWithField extends BaseMetricAggregation { + field?: string; +} + +export interface MetricAggregationWithMissingSupport extends BaseMetricAggregation { + settings?: { + missing?: string; + }; +} + +export interface MetricAggregationWithInlineScript extends BaseMetricAggregation { + settings?: { + script?: string; + }; +} + +interface Count extends BaseMetricAggregation { + type: 'count'; +} + +interface Average + extends MetricAggregationWithField, + MetricAggregationWithMissingSupport, + MetricAggregationWithInlineScript { + type: 'avg'; + settings?: { + script?: string; + missing?: string; + }; +} + +interface Sum extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'sum'; + settings?: { + script?: string; + missing?: string; + }; +} + +interface Max extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'max'; + settings?: { + script?: string; + missing?: string; + }; +} + +interface Min extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'min'; + settings?: { + script?: string; + missing?: string; + }; +} + +export type ExtendedStatMetaType = + | 'avg' + | 'min' + | 'max' + | 'sum' + | 'count' + | 'std_deviation' + | 'std_deviation_bounds_upper' + | 'std_deviation_bounds_lower'; +export interface ExtendedStat { + label: string; + value: ExtendedStatMetaType; +} + +export interface ExtendedStats extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'extended_stats'; + settings?: { + script?: string; + missing?: string; + sigma?: string; + }; + meta?: { + [P in ExtendedStatMetaType]?: boolean; + }; +} + +interface Percentiles extends MetricAggregationWithField, MetricAggregationWithInlineScript { + type: 'percentiles'; + settings?: { + percents?: string[]; + script?: string; + missing?: string; + }; +} + +export interface UniqueCount extends MetricAggregationWithField { + type: 'cardinality'; + settings?: { + precision_threshold?: string; + missing?: string; + }; +} + +interface RawDocument extends BaseMetricAggregation { + type: 'raw_document'; + settings?: { + size?: string; + }; +} + +interface RawData extends BaseMetricAggregation { + type: 'raw_data'; + settings?: { + size?: string; + }; +} + +interface Logs extends BaseMetricAggregation { + type: 'logs'; +} + +export interface BasePipelineMetricAggregation extends MetricAggregationWithField { + type: PipelineMetricAggregationType; +} + +interface PipelineMetricAggregationWithMultipleBucketPaths extends BaseMetricAggregation { + type: PipelineMetricAggregationType; + pipelineVariables?: PipelineVariable[]; +} + +export type MovingAverageModel = 'simple' | 'linear' | 'ewma' | 'holt' | 'holt_winters'; + +export interface MovingAverageModelOption { + label: string; + value: MovingAverageModel; +} + +interface BaseMovingAverageModelSettings { + model: MovingAverageModel; + window: number; + predict: number; +} + +interface MovingAverageSimpleModelSettings extends BaseMovingAverageModelSettings { + model: 'simple'; +} + +interface MovingAverageLinearModelSettings extends BaseMovingAverageModelSettings { + model: 'linear'; +} + +interface MovingAverageEWMAModelSettings extends BaseMovingAverageModelSettings { + model: 'ewma'; + alpha: number; + minimize: boolean; +} +interface MovingAverageHoltModelSettings extends BaseMovingAverageModelSettings { + model: 'holt'; + settings: { + alpha?: number; + beta?: number; + }; + minimize: boolean; +} +interface MovingAverageHoltWintersModelSettings extends BaseMovingAverageModelSettings { + model: 'holt_winters'; + settings: { + alpha?: number; + beta?: number; + gamma?: number; + period?: number; + pad?: boolean; + }; + minimize: boolean; +} + +export type MovingAverageModelSettings = Partial< + Extract< + | MovingAverageSimpleModelSettings + | MovingAverageLinearModelSettings + | MovingAverageEWMAModelSettings + | MovingAverageHoltModelSettings + | MovingAverageHoltWintersModelSettings, + { model: T } + > +>; + +export interface MovingAverage + extends BasePipelineMetricAggregation { + type: 'moving_avg'; + settings?: MovingAverageModelSettings; +} + +export const isEWMAMovingAverage = (metric: MovingAverage | MovingAverage<'ewma'>): metric is MovingAverage<'ewma'> => + metric.settings?.model === 'ewma'; + +export const isHoltMovingAverage = (metric: MovingAverage | MovingAverage<'holt'>): metric is MovingAverage<'holt'> => + metric.settings?.model === 'holt'; + +export const isHoltWintersMovingAverage = ( + metric: MovingAverage | MovingAverage<'holt_winters'> +): metric is MovingAverage<'holt_winters'> => metric.settings?.model === 'holt_winters'; + +interface MovingFunction extends BasePipelineMetricAggregation { + type: 'moving_fn'; + settings?: { + window?: string; + script?: string; + shift?: string; + }; +} + +export interface Derivative extends BasePipelineMetricAggregation { + type: 'derivative'; + settings?: { + unit?: string; + }; +} + +interface CumulativeSum extends BasePipelineMetricAggregation { + type: 'cumulative_sum'; + settings?: { + format?: string; + }; +} + +export interface BucketScript extends PipelineMetricAggregationWithMultipleBucketPaths { + type: 'bucket_script'; + settings?: { + script?: string; + }; +} + +type PipelineMetricAggregation = MovingAverage | Derivative | CumulativeSum | BucketScript; + +export type MetricAggregationWithSettings = + | BucketScript + | CumulativeSum + | Derivative + | RawData + | RawDocument + | UniqueCount + | Percentiles + | ExtendedStats + | Min + | Max + | Sum + | Average + | MovingAverage + | MovingFunction; + +export type MetricAggregationWithMeta = ExtendedStats; + +export type MetricAggregation = Count | Logs | PipelineMetricAggregation | MetricAggregationWithSettings; + +// Guards +// Given the structure of the aggregations (ie. `settings` field being always optional) we cannot +// determine types based solely on objects' properties, therefore we use `metricAggregationConfig` as the +// source of truth. + +/** + * Checks if `metric` requires a field (either referring to a document or another aggregation) + * @param metric + */ +export const isMetricAggregationWithField = ( + metric: BaseMetricAggregation | MetricAggregationWithField +): metric is MetricAggregationWithField => metricAggregationConfig[metric.type].requiresField; + +export const isPipelineAggregation = ( + metric: BaseMetricAggregation | PipelineMetricAggregation +): metric is PipelineMetricAggregation => metricAggregationConfig[metric.type].isPipelineAgg; + +export const isPipelineAggregationWithMultipleBucketPaths = ( + metric: BaseMetricAggregation | PipelineMetricAggregationWithMultipleBucketPaths +): metric is PipelineMetricAggregationWithMultipleBucketPaths => + metricAggregationConfig[metric.type].supportsMultipleBucketPaths; + +export const isMetricAggregationWithMissingSupport = ( + metric: BaseMetricAggregation | MetricAggregationWithMissingSupport +): metric is MetricAggregationWithMissingSupport => metricAggregationConfig[metric.type].supportsMissing; + +export const isMetricAggregationWithSettings = ( + metric: BaseMetricAggregation | MetricAggregationWithSettings +): metric is MetricAggregationWithSettings => metricAggregationConfig[metric.type].hasSettings; + +export const isMetricAggregationWithMeta = ( + metric: BaseMetricAggregation | MetricAggregationWithMeta +): metric is MetricAggregationWithMeta => metricAggregationConfig[metric.type].hasMeta; + +export const isMetricAggregationWithInlineScript = ( + metric: BaseMetricAggregation | MetricAggregationWithInlineScript +): metric is MetricAggregationWithInlineScript => metricAggregationConfig[metric.type].supportsInlineScript; + +export const METRIC_AGGREGATION_TYPES = [ + 'count', + 'avg', + 'sum', + 'min', + 'max', + 'extended_stats', + 'percentiles', + 'cardinality', + 'raw_document', + 'raw_data', + 'logs', + 'moving_avg', + 'moving_fn', + 'derivative', + 'cumulative_sum', + 'bucket_script', +]; + +export const isMetricAggregationType = (s: MetricAggregationType | string): s is MetricAggregationType => + METRIC_AGGREGATION_TYPES.includes(s); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx new file mode 100644 index 00000000000..8c81bcc81be --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx @@ -0,0 +1,40 @@ +import React, { FunctionComponent } from 'react'; +import { MetricEditor } from './MetricEditor'; +import { useDispatch } from '../../../hooks/useStatelessReducer'; +import { MetricAggregationAction } from './state/types'; +import { metricAggregationConfig } from './utils'; +import { addMetric, removeMetric, toggleMetricVisibility } from './state/actions'; +import { MetricAggregation } from './aggregations'; +import { useQuery } from '../ElasticsearchQueryContext'; +import { QueryEditorRow } from '../QueryEditorRow'; +import { IconButton } from '../../IconButton'; + +interface Props { + nextId: MetricAggregation['id']; +} + +export const MetricAggregationsEditor: FunctionComponent = ({ nextId }) => { + const dispatch = useDispatch(); + const { metrics } = useQuery(); + const totalMetrics = metrics?.length || 0; + + return ( + <> + {metrics?.map((metric, index) => ( + + ))} + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts new file mode 100644 index 00000000000..63a09ec6a09 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts @@ -0,0 +1,96 @@ +import { SettingKeyOf } from '../../../types'; +import { MetricAggregation, MetricAggregationWithMeta, MetricAggregationWithSettings } from '../aggregations'; +import { + ADD_METRIC, + CHANGE_METRIC_FIELD, + CHANGE_METRIC_TYPE, + REMOVE_METRIC, + TOGGLE_METRIC_VISIBILITY, + CHANGE_METRIC_SETTING, + CHANGE_METRIC_META, + CHANGE_METRIC_ATTRIBUTE, + MetricAggregationAction, + ChangeMetricAttributeAction, + ChangeMetricSettingAction, + ChangeMetricMetaAction, +} from './types'; + +export const addMetric = (id: MetricAggregation['id']): MetricAggregationAction => ({ + type: ADD_METRIC, + payload: { + id, + }, +}); + +export const removeMetric = (id: MetricAggregation['id']): MetricAggregationAction => ({ + type: REMOVE_METRIC, + payload: { + id, + }, +}); + +export const changeMetricType = ( + id: MetricAggregation['id'], + type: MetricAggregation['type'] +): MetricAggregationAction => ({ + type: CHANGE_METRIC_TYPE, + payload: { + id, + type, + }, +}); + +export const changeMetricField = (id: MetricAggregation['id'], field: string): MetricAggregationAction => ({ + type: CHANGE_METRIC_FIELD, + payload: { + id, + field, + }, +}); + +export const toggleMetricVisibility = (id: MetricAggregation['id']): MetricAggregationAction => ({ + type: TOGGLE_METRIC_VISIBILITY, + payload: { + id, + }, +}); + +export const changeMetricAttribute = >( + metric: T, + attribute: K, + newValue: T[K] +): ChangeMetricAttributeAction => ({ + type: CHANGE_METRIC_ATTRIBUTE, + payload: { + metric, + attribute, + newValue, + }, +}); + +export const changeMetricSetting = >( + metric: T, + settingName: K, + // Maybe this could have been NonNullable[K], but it doesn't seem to work really well + newValue: NonNullable[K] +): ChangeMetricSettingAction => ({ + type: CHANGE_METRIC_SETTING, + payload: { + metric, + settingName, + newValue, + }, +}); + +export const changeMetricMeta = ( + metric: T, + meta: Extract['meta'], string>, + newValue: string | number | boolean +): ChangeMetricMetaAction => ({ + type: CHANGE_METRIC_META, + payload: { + metric, + meta, + newValue, + }, +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts new file mode 100644 index 00000000000..fa2701c1466 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -0,0 +1,222 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { reducer } from './reducer'; +import { + addMetric, + changeMetricAttribute, + changeMetricField, + changeMetricMeta, + changeMetricSetting, + changeMetricType, + removeMetric, + toggleMetricVisibility, +} from './actions'; +import { Derivative, ExtendedStats, MetricAggregation } from '../aggregations'; +import { defaultMetricAgg } from '../../../../query_def'; +import { metricAggregationConfig } from '../utils'; + +describe('Metric Aggregations Reducer', () => { + it('should correctly add new aggregations', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + reducerTester() + .givenReducer(reducer, []) + .whenActionIsDispatched(addMetric(firstAggregation.id)) + .thenStateShouldEqual([firstAggregation]) + .whenActionIsDispatched(addMetric(secondAggregation.id)) + .thenStateShouldEqual([firstAggregation, secondAggregation]); + }); + + describe('When removing aggregations', () => { + it('Should correctly remove aggregations', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(removeMetric(firstAggregation.id)) + .thenStateShouldEqual([secondAggregation]); + }); + + it('Should insert a default aggregation when the last one is removed', () => { + const initialState: MetricAggregation[] = [{ id: '2', type: 'avg' }]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched(removeMetric(initialState[0].id)) + .thenStateShouldEqual([defaultMetricAgg()]); + }); + }); + + describe("When changing existing aggregation's type", () => { + it('Should correctly change type to selected aggregation', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedSecondAggregation: MetricAggregation = { ...secondAggregation, type: 'avg' }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricType(secondAggregation.id, expectedSecondAggregation.type)) + .thenStateShouldEqual([firstAggregation, { ...secondAggregation, type: expectedSecondAggregation.type }]); + }); + + it('Should remove all other aggregations when the newly selected one is `isSingleMetric`', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedAggregation: MetricAggregation = { + ...secondAggregation, + type: 'raw_data', + ...metricAggregationConfig['raw_data'].defaults, + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricType(secondAggregation.id, expectedAggregation.type)) + .thenStateShouldEqual([expectedAggregation]); + }); + }); + + it("Should correctly change aggregation's field", () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'moving_fn', + }; + + const expectedSecondAggregation = { + ...secondAggregation, + field: 'new field', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricField(secondAggregation.id, expectedSecondAggregation.field)) + .thenStateShouldEqual([firstAggregation, expectedSecondAggregation]); + }); + + it('Should correctly toggle `hide` field', () => { + const firstAggregation: MetricAggregation = { + id: '1', + type: 'count', + }; + + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(toggleMetricVisibility(firstAggregation.id)) + .thenStateShouldEqual([{ ...firstAggregation, hide: true }, secondAggregation]) + .whenActionIsDispatched(toggleMetricVisibility(firstAggregation.id)) + .thenStateShouldEqual([{ ...firstAggregation, hide: false }, secondAggregation]); + }); + + it("Should correctly change aggregation's settings", () => { + const firstAggregation: Derivative = { + id: '1', + type: 'derivative', + settings: { + unit: 'Some unit', + }, + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedSettings: typeof firstAggregation['settings'] = { + unit: 'Changed unit', + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricSetting(firstAggregation, 'unit', expectedSettings.unit!)) + .thenStateShouldEqual([{ ...firstAggregation, settings: expectedSettings }, secondAggregation]); + }); + + it("Should correctly change aggregation's meta", () => { + const firstAggregation: ExtendedStats = { + id: '1', + type: 'extended_stats', + meta: { + avg: true, + }, + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedMeta: typeof firstAggregation['meta'] = { + avg: false, + }; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricMeta(firstAggregation, 'avg', expectedMeta.avg!)) + .thenStateShouldEqual([{ ...firstAggregation, meta: expectedMeta }, secondAggregation]); + }); + + it("Should correctly change aggregation's attribute", () => { + const firstAggregation: ExtendedStats = { + id: '1', + type: 'extended_stats', + }; + const secondAggregation: MetricAggregation = { + id: '2', + type: 'count', + }; + + const expectedHide: typeof firstAggregation['hide'] = false; + + reducerTester() + .givenReducer(reducer, [firstAggregation, secondAggregation]) + .whenActionIsDispatched(changeMetricAttribute(firstAggregation, 'hide', expectedHide)) + .thenStateShouldEqual([{ ...firstAggregation, hide: expectedHide }, secondAggregation]); + }); + + it('Should not change state with other action types', () => { + const initialState: MetricAggregation[] = [ + { + id: '1', + type: 'count', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); +}); 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 new file mode 100644 index 00000000000..d6ce9d1f77f --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -0,0 +1,149 @@ +import { defaultMetricAgg } from '../../../../query_def'; +import { ElasticsearchQuery } from '../../../../types'; +import { removeEmpty } from '../../../../utils'; +import { INIT, InitAction } from '../../state'; +import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, MetricAggregation } from '../aggregations'; +import { getChildren, metricAggregationConfig } from '../utils'; +import { + ADD_METRIC, + CHANGE_METRIC_TYPE, + REMOVE_METRIC, + TOGGLE_METRIC_VISIBILITY, + MetricAggregationAction, + CHANGE_METRIC_FIELD, + CHANGE_METRIC_SETTING, + CHANGE_METRIC_META, + CHANGE_METRIC_ATTRIBUTE, +} from './types'; + +export const reducer = ( + state: MetricAggregation[], + action: MetricAggregationAction | InitAction +): ElasticsearchQuery['metrics'] => { + switch (action.type) { + case ADD_METRIC: + return [...state, defaultMetricAgg(action.payload.id)]; + + case REMOVE_METRIC: + const metricToRemove = state.find(m => m.id === action.payload.id)!; + const metricsToRemove = [metricToRemove, ...getChildren(metricToRemove, state)]; + const resultingMetrics = state.filter(metric => !metricsToRemove.some(toRemove => toRemove.id === metric.id)); + if (resultingMetrics.length === 0) { + return [defaultMetricAgg('1')]; + } + return resultingMetrics; + + case CHANGE_METRIC_TYPE: + return state + .filter(metric => + // When the new metric type is `isSingleMetric` we remove all other metrics from the query + // leaving only the current one. + !!metricAggregationConfig[action.payload.type].isSingleMetric ? metric.id === action.payload.id : true + ) + .map(metric => { + if (metric.id !== action.payload.id) { + return metric; + } + + /* + TODO: The previous version of the query editor was keeping some of the old metric's configurations + in the new selected one (such as field or some settings). + It the future would be nice to have the same behavior but it's hard without a proper definition, + as Elasticsearch will error sometimes if some settings are not compatible. + */ + return { + id: metric.id, + type: action.payload.type, + ...metricAggregationConfig[action.payload.type].defaults, + } as MetricAggregation; + }); + + case CHANGE_METRIC_FIELD: + return state.map(metric => { + if (metric.id !== action.payload.id) { + return metric; + } + + return { + ...metric, + field: action.payload.field, + }; + }); + + case TOGGLE_METRIC_VISIBILITY: + return state.map(metric => { + if (metric.id !== action.payload.id) { + return metric; + } + + return { + ...metric, + hide: !metric.hide, + }; + }); + + case CHANGE_METRIC_SETTING: + return state.map(metric => { + if (metric.id !== action.payload.metric.id) { + return metric; + } + + // TODO: Here, instead of this if statement, we should assert that metric is MetricAggregationWithSettings + if (isMetricAggregationWithSettings(metric)) { + const newSettings = removeEmpty({ + ...metric.settings, + [action.payload.settingName]: action.payload.newValue, + }); + + return { + ...metric, + settings: { + ...newSettings, + }, + }; + } + + // This should never happen. + return metric; + }); + + case CHANGE_METRIC_META: + return state.map(metric => { + if (metric.id !== action.payload.metric.id) { + return metric; + } + + // TODO: Here, instead of this if statement, we should assert that metric is MetricAggregationWithMeta + if (isMetricAggregationWithMeta(metric)) { + return { + ...metric, + meta: { + ...metric.meta, + [action.payload.meta]: action.payload.newValue, + }, + }; + } + + // This should never happen. + return metric; + }); + + case CHANGE_METRIC_ATTRIBUTE: + return state.map(metric => { + if (metric.id !== action.payload.metric.id) { + return metric; + } + + return { + ...metric, + [action.payload.attribute]: action.payload.newValue, + }; + }); + + case INIT: + return [defaultMetricAgg()]; + + default: + return state; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts new file mode 100644 index 00000000000..4c1a6f9a3d7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts @@ -0,0 +1,89 @@ +import { Action } from '../../../../hooks/useStatelessReducer'; +import { SettingKeyOf } from '../../../types'; +import { + MetricAggregation, + MetricAggregationWithMeta, + MetricAggregationWithSettings, + MetricAggregationWithField, +} from '../aggregations'; + +export const ADD_METRIC = '@metrics/add'; +export const REMOVE_METRIC = '@metrics/remove'; +export const CHANGE_METRIC_TYPE = '@metrics/change_type'; +export const CHANGE_METRIC_FIELD = '@metrics/change_field'; +export const CHANGE_METRIC_SETTING = '@metrics/change_setting'; +export const CHANGE_METRIC_META = '@metrics/change_meta'; +export const CHANGE_METRIC_ATTRIBUTE = '@metrics/change_attr'; +export const TOGGLE_METRIC_VISIBILITY = '@metrics/toggle_visibility'; + +export interface AddMetricAction extends Action { + payload: { + id: MetricAggregation['id']; + }; +} + +export interface RemoveMetricAction extends Action { + payload: { + id: MetricAggregation['id']; + }; +} + +export interface ChangeMetricTypeAction extends Action { + payload: { + id: MetricAggregation['id']; + type: MetricAggregation['type']; + }; +} + +export interface ChangeMetricFieldAction extends Action { + payload: { + id: MetricAggregation['id']; + field: MetricAggregationWithField['field']; + }; +} +export interface ToggleMetricVisibilityAction extends Action { + payload: { + id: MetricAggregation['id']; + }; +} + +export interface ChangeMetricSettingAction + extends Action { + payload: { + metric: T; + settingName: SettingKeyOf; + newValue: unknown; + }; +} + +export interface ChangeMetricMetaAction extends Action { + payload: { + metric: T; + meta: Extract['meta'], string>; + newValue: string | number | boolean; + }; +} + +export interface ChangeMetricAttributeAction< + T extends MetricAggregation, + K extends Extract = Extract +> extends Action { + payload: { + metric: T; + attribute: K; + newValue: T[K]; + }; +} + +type CommonActions = + | AddMetricAction + | RemoveMetricAction + | ChangeMetricTypeAction + | ChangeMetricFieldAction + | ToggleMetricVisibilityAction; + +export type MetricAggregationAction = + | (T extends MetricAggregationWithSettings ? ChangeMetricSettingAction : never) + | (T extends MetricAggregationWithMeta ? ChangeMetricMetaAction : never) + | ChangeMetricAttributeAction + | CommonActions; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts new file mode 100644 index 00000000000..470de7e0312 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts @@ -0,0 +1,16 @@ +import { GrafanaTheme } from '@grafana/data'; +import { stylesFactory } from '@grafana/ui'; +import { css } from 'emotion'; + +export const getStyles = stylesFactory((theme: GrafanaTheme, hidden: boolean) => ({ + color: + hidden && + css` + &, + &:hover, + label, + a { + color: ${hidden ? theme.colors.textFaint : theme.colors.text}; + } + `, +})); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts new file mode 100644 index 00000000000..bb01fa79220 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts @@ -0,0 +1,261 @@ +import { MetricsConfiguration } from '../../../types'; +import { + isMetricAggregationWithField, + isPipelineAggregationWithMultipleBucketPaths, + MetricAggregation, + PipelineMetricAggregationType, +} from './aggregations'; +import { defaultPipelineVariable } from './SettingsEditor/BucketScriptSettingsEditor/utils'; + +export const metricAggregationConfig: MetricsConfiguration = { + count: { + label: 'Count', + requiresField: false, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: false, + hasMeta: false, + supportsInlineScript: false, + defaults: {}, + }, + avg: { + label: 'Average', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + sum: { + label: 'Sum', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + max: { + label: 'Max', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + min: { + label: 'Min', + requiresField: true, + supportsInlineScript: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: {}, + }, + extended_stats: { + label: 'Extended Stats', + requiresField: true, + supportsMissing: true, + supportsInlineScript: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: true, + defaults: { + meta: { + std_deviation_bounds_lower: true, + std_deviation_bounds_upper: true, + }, + }, + }, + percentiles: { + label: 'Percentiles', + requiresField: true, + supportsMissing: true, + supportsInlineScript: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + hasMeta: false, + defaults: { + settings: { + percents: ['25', '50', '75', '95', '99'], + }, + }, + }, + cardinality: { + label: 'Unique Count', + requiresField: true, + supportsMissing: true, + isPipelineAgg: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, + moving_avg: { + label: 'Moving Average', + requiresField: true, + isPipelineAgg: true, + minVersion: 2, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: { + settings: { + model: 'simple', + window: 5, + }, + }, + }, + moving_fn: { + // TODO: Check this + label: 'Moving Function', + requiresField: true, + isPipelineAgg: true, + supportsMultipleBucketPaths: false, + supportsInlineScript: false, + supportsMissing: false, + hasMeta: false, + hasSettings: true, + minVersion: 70, + defaults: {}, + }, + derivative: { + label: 'Derivative', + requiresField: true, + isPipelineAgg: true, + minVersion: 2, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, + cumulative_sum: { + label: 'Cumulative Sum', + requiresField: true, + isPipelineAgg: true, + minVersion: 2, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, + bucket_script: { + label: 'Bucket Script', + requiresField: false, + isPipelineAgg: true, + supportsMissing: false, + supportsMultipleBucketPaths: true, + minVersion: 2, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: { + pipelineVariables: [defaultPipelineVariable()], + }, + }, + raw_document: { + label: 'Raw Document (legacy)', + requiresField: false, + isSingleMetric: true, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: true, + supportsInlineScript: false, + hasMeta: false, + defaults: { + settings: { + size: '500', + }, + }, + }, + raw_data: { + label: 'Raw Data', + requiresField: false, + isSingleMetric: true, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: false, + supportsInlineScript: false, + hasMeta: false, + defaults: { + settings: { + size: '500', + }, + }, + }, + logs: { + label: 'Logs', + requiresField: false, + isPipelineAgg: false, + supportsMissing: false, + supportsMultipleBucketPaths: false, + hasSettings: false, + supportsInlineScript: false, + hasMeta: false, + defaults: {}, + }, +}; + +interface PipelineOption { + label: string; + default?: string | number | boolean; +} + +type PipelineOptions = { + [K in PipelineMetricAggregationType]: PipelineOption[]; +}; + +export const pipelineOptions: PipelineOptions = { + moving_avg: [ + { label: 'window', default: 5 }, + { label: 'model', default: 'simple' }, + { label: 'predict' }, + { label: 'minimize', default: false }, + ], + moving_fn: [{ label: 'window', default: 5 }, { label: 'script' }], + derivative: [{ label: 'unit' }], + cumulative_sum: [{ label: 'format' }], + bucket_script: [], +}; + +/** + * Given a metric `MetricA` and an array of metrics, returns all children of `MetricA`. + * `MetricB` is considered a child of `MetricA` if `MetricA` is referenced by `MetricB` in it's `field` attribute + * (`MetricA.id === MetricB.field`) or in it's pipeline aggregation variables (for bucket_scripts). + * @param metric + * @param metrics + */ +export const getChildren = (metric: MetricAggregation, metrics: MetricAggregation[]): MetricAggregation[] => { + const children = metrics.filter(m => { + // TODO: Check this. + if (isPipelineAggregationWithMultipleBucketPaths(m)) { + return m.pipelineVariables?.some(pv => pv.pipelineAgg === metric.id); + } + + return isMetricAggregationWithField(m) && metric.id === m.field; + }); + + return [...children, ...children.flatMap(child => getChildren(child, metrics))]; +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx new file mode 100644 index 00000000000..b48f12354f0 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx @@ -0,0 +1,70 @@ +import { GrafanaTheme } from '@grafana/data'; +import { IconButton, stylesFactory, useTheme } from '@grafana/ui'; +import { getInlineLabelStyles } from '@grafana/ui/src/components/Forms/InlineLabel'; +import { css } from 'emotion'; +import { noop } from 'lodash'; +import React, { FunctionComponent } from 'react'; + +interface Props { + label: string; + onRemoveClick?: false | (() => void); + onHideClick?: false | (() => void); + hidden?: boolean; +} + +export const QueryEditorRow: FunctionComponent = ({ + children, + label, + onRemoveClick, + onHideClick, + hidden = false, +}) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( +
+
+ {label} + {onHideClick && ( + + )} + +
+ {children} +
+ ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + root: css` + display: flex; + margin-bottom: ${theme.spacing.xs}; + `, + label: css` + font-size: ${theme.typography.size.sm}; + margin: 0; + `, + icon: css` + color: ${theme.colors.textWeak}; + margin-left: ${theme.spacing.xxs}; + `, + }; +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx new file mode 100644 index 00000000000..13d48b1d1d3 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx @@ -0,0 +1,52 @@ +import { GrafanaTheme } from '@grafana/data'; +import { Icon, stylesFactory, useTheme } from '@grafana/ui'; +import { css, cx } from 'emotion'; +import React, { FunctionComponent, useState } from 'react'; +import { segmentStyles } from './styles'; + +const getStyles = stylesFactory((theme: GrafanaTheme, hidden: boolean) => { + return { + wrapper: css` + display: flex; + flex-direction: column; + `, + settingsWrapper: css` + padding-top: ${theme.spacing.xs}; + `, + icon: css` + margin-right: ${theme.spacing.xs}; + `, + button: css` + justify-content: start; + ${hidden && + css` + color: ${theme.colors.textFaint}; + `} + `, + }; +}); +interface Props { + label: string; + hidden?: boolean; +} + +export const SettingsEditorContainer: FunctionComponent = ({ label, children, hidden = false }) => { + const [open, setOpen] = useState(false); + + const styles = getStyles(useTheme(), hidden); + + return ( +
+ + + {open &&
{children}
} +
+ ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx new file mode 100644 index 00000000000..7ba7551cdd6 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx @@ -0,0 +1,52 @@ +import React, { FunctionComponent } from 'react'; +import { QueryEditorProps } from '@grafana/data'; +import { ElasticDatasource } from '../../datasource'; +import { ElasticsearchOptions, ElasticsearchQuery } from '../../types'; +import { ElasticsearchProvider } from './ElasticsearchQueryContext'; +import { InlineField, InlineFieldRow, Input, QueryField } from '@grafana/ui'; +import { changeAliasPattern, changeQuery } from './state'; +import { MetricAggregationsEditor } from './MetricAggregationsEditor'; +import { BucketAggregationsEditor } from './BucketAggregationsEditor'; +import { useDispatch } from '../../hooks/useStatelessReducer'; +import { useNextId } from '../../hooks/useNextId'; + +export type ElasticQueryEditorProps = QueryEditorProps; + +export const QueryEditor: FunctionComponent = ({ query, onChange, datasource }) => ( + + + +); + +interface Props { + value: ElasticsearchQuery; +} + +const QueryEditorForm: FunctionComponent = ({ value }) => { + const dispatch = useDispatch(); + const nextId = useNextId(); + + return ( + <> + + + {}} + onChange={query => dispatch(changeQuery(query))} + placeholder="Lucene Query" + portalOrigin="elasticsearch" + /> + + + dispatch(changeAliasPattern(e.currentTarget.value))} /> + + + + + + + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts new file mode 100644 index 00000000000..d821b76cde4 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts @@ -0,0 +1,43 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { ElasticsearchQuery } from '../../types'; +import { aliasPatternReducer, changeAliasPattern, changeQuery, queryReducer } from './state'; + +describe('Query Reducer', () => { + it('Should correctly set `query`', () => { + const expectedQuery: ElasticsearchQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, '') + .whenActionIsDispatched(changeQuery(expectedQuery)) + .thenStateShouldEqual(expectedQuery); + }); + + it('Should not change state with other action types', () => { + const initialState: ElasticsearchQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); +}); + +describe('Alias Pattern Reducer', () => { + it('Should correctly set `alias`', () => { + const expectedAlias: ElasticsearchQuery['alias'] = 'Some alias pattern'; + + reducerTester() + .givenReducer(aliasPatternReducer, '') + .whenActionIsDispatched(changeAliasPattern(expectedAlias)) + .thenStateShouldEqual(expectedAlias); + }); + + it('Should not change state with other action types', () => { + const initialState: ElasticsearchQuery['alias'] = 'Some alias pattern'; + + reducerTester() + .givenReducer(aliasPatternReducer, initialState) + .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) + .thenStateShouldEqual(initialState); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts new file mode 100644 index 00000000000..b1ac9551ef7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -0,0 +1,61 @@ +import { Action } from '../../hooks/useStatelessReducer'; + +export const INIT = 'init'; +const CHANGE_QUERY = 'change_query'; +const CHANGE_ALIAS_PATTERN = 'change_alias_pattern'; + +export interface InitAction extends Action {} + +interface ChangeQueryAction extends Action { + payload: { + query: string; + }; +} + +interface ChangeAliasPatternAction extends Action { + payload: { + aliasPattern: string; + }; +} + +export const initQuery = (): InitAction => ({ type: INIT }); + +export const changeQuery = (query: string): ChangeQueryAction => ({ + type: CHANGE_QUERY, + payload: { + query, + }, +}); + +export const changeAliasPattern = (aliasPattern: string): ChangeAliasPatternAction => ({ + type: CHANGE_ALIAS_PATTERN, + payload: { + aliasPattern, + }, +}); + +export const queryReducer = (prevQuery: string, action: ChangeQueryAction | InitAction) => { + switch (action.type) { + case CHANGE_QUERY: + return action.payload.query; + + case INIT: + return ''; + + default: + return prevQuery; + } +}; + +export const aliasPatternReducer = (prevAliasPattern: string, action: ChangeAliasPatternAction | InitAction) => { + switch (action.type) { + case CHANGE_ALIAS_PATTERN: + return action.payload.aliasPattern; + + case INIT: + return ''; + + default: + return prevAliasPattern; + } +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts new file mode 100644 index 00000000000..70ee88de758 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts @@ -0,0 +1,5 @@ +import { css } from 'emotion'; + +export const segmentStyles = css` + min-width: 150px; +`; diff --git a/public/app/plugins/datasource/elasticsearch/components/types.ts b/public/app/plugins/datasource/elasticsearch/components/types.ts new file mode 100644 index 00000000000..f7dd1ab7fb4 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/types.ts @@ -0,0 +1,4 @@ +export type SettingKeyOf }> = Extract< + keyof NonNullable, + string +>; diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx index da18eb33998..bdd64191d01 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx @@ -20,9 +20,9 @@ describe('ConfigEditor', () => { it('should set defaults', () => { const options = createDefaultConfigOptions(); - //@ts-ignore + // @ts-ignore delete options.jsonData.esVersion; - //@ts-ignore + // @ts-ignore delete options.jsonData.timeField; delete options.jsonData.maxConcurrentShardRequests; diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index 15a570c0498..33bc654b68b 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -1,13 +1,15 @@ -import angular from 'angular'; import { ArrayVector, CoreApp, DataQueryRequest, DataSourceInstanceSettings, dateMath, + DateTime, dateTime, Field, + MetricFindValue, MutableDataFrame, + TimeRange, toUtc, } from '@grafana/data'; import _ from 'lodash'; @@ -16,6 +18,7 @@ import { backendSrv } from 'app/core/services/backend_srv'; // will use the vers import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { ElasticsearchOptions, ElasticsearchQuery } from './types'; +import { Filters } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; const ELASTICSEARCH_MOCK_URL = 'http://elasticsearch.local'; @@ -31,6 +34,15 @@ jest.mock('@grafana/runtime', () => ({ }, })); +const createTimeRange = (from: DateTime, to: DateTime): TimeRange => ({ + from, + to, + raw: { + from, + to, + }, +}); + describe('ElasticDatasource', function(this: any) { const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); @@ -38,11 +50,6 @@ describe('ElasticDatasource', function(this: any) { jest.clearAllMocks(); }); - const $rootScope = { - $on: jest.fn(), - appEvent: jest.fn(), - }; - const templateSrv: any = { replace: jest.fn(text => { if (text.startsWith('$')) { @@ -56,9 +63,10 @@ describe('ElasticDatasource', function(this: any) { const timeSrv: any = createTimeSrv('now-1h'); - const ctx = { - $rootScope, - } as any; + interface TestContext { + ds: ElasticDatasource; + } + const ctx = {} as TestContext; function createTimeSrv(from: string) { const srv: any = { @@ -164,7 +172,7 @@ describe('ElasticDatasource', function(this: any) { result = await ctx.ds.query(query); parts = requestOptions.data.split('\n'); - header = angular.fromJson(parts[0]); + header = JSON.parse(parts[0]); }); it('should translate index pattern to current day', () => { @@ -180,7 +188,7 @@ describe('ElasticDatasource', function(this: any) { }); it('should json escape lucene query', () => { - const body = angular.fromJson(parts[1]); + const body = JSON.parse(parts[1]); expect(body.query.bool.filter[1].query_string.query).toBe('escape\\:test'); }); }); @@ -202,11 +210,8 @@ describe('ElasticDatasource', function(this: any) { return Promise.resolve(logsResponse); }); - const query = { - range: { - from: toUtc([2015, 4, 30, 10]), - to: toUtc([2019, 7, 1, 10]), - }, + const query: DataQueryRequest = { + range: createTimeRange(toUtc([2015, 4, 30, 10]), toUtc([2019, 7, 1, 10])), targets: [ { alias: '$varAlias', @@ -214,12 +219,11 @@ describe('ElasticDatasource', function(this: any) { bucketAggs: [{ type: 'date_histogram', settings: { interval: 'auto' }, id: '2' }], metrics: [{ type: 'count', id: '1' }], query: 'escape\\:test', - interval: '10s', isLogsQuery: true, timeField: '@timestamp', }, ], - }; + } as DataQueryRequest; const queryBuilderSpy = jest.spyOn(ctx.ds.queryBuilder, 'getLogsQuery'); const response = await ctx.ds.query(query); @@ -263,22 +267,21 @@ describe('ElasticDatasource', function(this: any) { return Promise.resolve({ data: { responses: [] } }); }); - ctx.ds.query({ - range: { - from: dateTime([2015, 4, 30, 10]), - to: dateTime([2015, 5, 1, 10]), - }, + const query: DataQueryRequest = { + range: createTimeRange(dateTime([2015, 4, 30, 10]), dateTime([2015, 5, 1, 10])), targets: [ { - bucketAggs: [], - metrics: [{ type: 'raw_document' }], + refId: 'A', + metrics: [{ type: 'raw_document', id: '1' }], query: 'test', }, ], - }); + } as DataQueryRequest; + + ctx.ds.query(query); parts = requestOptions.data.split('\n'); - header = angular.fromJson(parts[0]); + header = JSON.parse(parts[0]); }); it('should set search type to query_then_fetch', () => { @@ -286,26 +289,24 @@ describe('ElasticDatasource', function(this: any) { }); it('should set size', () => { - const body = angular.fromJson(parts[1]); + const body = JSON.parse(parts[1]); expect(body.size).toBe(500); }); }); describe('When getting an error on response', () => { - const query = { - range: { - from: toUtc([2020, 1, 1, 10]), - to: toUtc([2020, 2, 1, 10]), - }, + const query: DataQueryRequest = { + range: createTimeRange(toUtc([2020, 1, 1, 10]), toUtc([2020, 2, 1, 10])), targets: [ { + refId: 'A', alias: '$varAlias', bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], metrics: [{ type: 'count', id: '1' }], query: 'escape\\:test', }, ], - }; + } as DataQueryRequest; createDatasource({ url: ELASTICSEARCH_MOCK_URL, @@ -431,11 +432,10 @@ describe('ElasticDatasource', function(this: any) { }); it('should return nested fields', async () => { - const fieldObjects = await ctx.ds.getFields({ - find: 'fields', - query: '*', - }); + const fieldObjects = await ctx.ds.getFields(); + const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual([ '@timestamp', '__timestamp', @@ -451,24 +451,18 @@ describe('ElasticDatasource', function(this: any) { }); it('should return number fields', async () => { - const fieldObjects = await ctx.ds.getFields({ - find: 'fields', - query: '*', - type: 'number', - }); + const fieldObjects = await ctx.ds.getFields('number'); const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual(['system.cpu.system', 'system.cpu.user', 'system.process.cpu.total']); }); it('should return date fields', async () => { - const fieldObjects = await ctx.ds.getFields({ - find: 'fields', - query: '*', - type: 'date', - }); + const fieldObjects = await ctx.ds.getFields('date'); const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual(['@timestamp', '__timestamp', '@timestampnano']); }); }); @@ -540,10 +534,8 @@ describe('ElasticDatasource', function(this: any) { return Promise.reject({ status: 404 }); }); - const fieldObjects = await ctx.ds.getFields({ - find: 'fields', - query: '*', - }); + const fieldObjects = await ctx.ds.getFields(); + const fields = _.map(fieldObjects, 'text'); expect(fields).toEqual(['@timestamp', 'beat.hostname']); }); @@ -562,10 +554,7 @@ describe('ElasticDatasource', function(this: any) { expect.assertions(2); try { - await ctx.ds.getFields({ - find: 'fields', - query: '*', - }); + await ctx.ds.getFields(); } catch (e) { expect(e).toStrictEqual({ status: 500 }); expect(datasourceRequestMock).toBeCalledTimes(1); @@ -579,10 +568,7 @@ describe('ElasticDatasource', function(this: any) { expect.assertions(2); try { - await ctx.ds.getFields({ - find: 'fields', - query: '*', - }); + await ctx.ds.getFields(); } catch (e) { expect(e).toStrictEqual({ status: 404 }); expect(datasourceRequestMock).toBeCalledTimes(7); @@ -687,12 +673,10 @@ describe('ElasticDatasource', function(this: any) { }); it('should return nested fields', async () => { - const fieldObjects = await ctx.ds.getFields({ - find: 'fields', - query: '*', - }); + const fieldObjects = await ctx.ds.getFields(); const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual([ '@timestamp_millis', 'classification_terms', @@ -712,13 +696,10 @@ describe('ElasticDatasource', function(this: any) { }); it('should return number fields', async () => { - const fieldObjects = await ctx.ds.getFields({ - find: 'fields', - query: '*', - type: 'number', - }); + const fieldObjects = await ctx.ds.getFields('number'); const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual([ 'justification_blob.overall_vote_score', 'justification_blob.shallow.jsi.sdb.dsel2.bootlegged-gille.botness', @@ -730,13 +711,10 @@ describe('ElasticDatasource', function(this: any) { }); it('should return date fields', async () => { - const fieldObjects = await ctx.ds.getFields({ - find: 'fields', - query: '*', - type: 'date', - }); + const fieldObjects = await ctx.ds.getFields('date'); const fields = _.map(fieldObjects, 'text'); + expect(fields).toEqual(['@timestamp_millis']); }); }); @@ -756,22 +734,22 @@ describe('ElasticDatasource', function(this: any) { return Promise.resolve({ data: { responses: [] } }); }); - ctx.ds.query({ - range: { - from: dateTime([2015, 4, 30, 10]), - to: dateTime([2015, 5, 1, 10]), - }, + const query: DataQueryRequest = { + range: createTimeRange(dateTime([2015, 4, 30, 10]), dateTime([2015, 5, 1, 10])), targets: [ { + refId: 'A', bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], - metrics: [{ type: 'count' }], + metrics: [{ type: 'count', id: '1' }], query: 'test', }, ], - }); + } as DataQueryRequest; + + ctx.ds.query(query); parts = requestOptions.data.split('\n'); - header = angular.fromJson(parts[0]); + header = JSON.parse(parts[0]); }); it('should not set search type to count', () => { @@ -779,13 +757,14 @@ describe('ElasticDatasource', function(this: any) { }); it('should set size to 0', () => { - const body = angular.fromJson(parts[1]); + const body = JSON.parse(parts[1]); expect(body.size).toBe(0); }); }); describe('When issuing metricFind query on es5.x', () => { - let requestOptions: any, parts, header: any, body: any, results: any; + let requestOptions: any, parts, header: any, body: any; + let results: MetricFindValue[]; beforeEach(() => { createDatasource({ @@ -818,13 +797,13 @@ describe('ElasticDatasource', function(this: any) { }); }); - ctx.ds.metricFindQuery('{"find": "terms", "field": "test"}').then((res: any) => { + ctx.ds.metricFindQuery('{"find": "terms", "field": "test"}').then(res => { results = res; }); parts = requestOptions.data.split('\n'); - header = angular.fromJson(parts[0]); - body = angular.fromJson(parts[1]); + header = JSON.parse(parts[0]); + body = JSON.parse(parts[1]); }); it('should get results', () => { @@ -873,8 +852,8 @@ describe('ElasticDatasource', function(this: any) { }); it('should correctly interpolate variables in query', () => { - const query = { - alias: '', + const query: ElasticsearchQuery = { + refId: 'A', bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '$var', label: '' }] }, id: '1' }], metrics: [{ type: 'count', id: '1' }], query: '$var', @@ -883,12 +862,12 @@ describe('ElasticDatasource', function(this: any) { const interpolatedQuery = ctx.ds.interpolateVariablesInQueries([query], {})[0]; expect(interpolatedQuery.query).toBe('resolvedVariable'); - expect(interpolatedQuery.bucketAggs[0].settings.filters[0].query).toBe('resolvedVariable'); + expect((interpolatedQuery.bucketAggs![0] as Filters).settings!.filters![0].query).toBe('resolvedVariable'); }); it('should correctly handle empty query strings', () => { - const query = { - alias: '', + const query: ElasticsearchQuery = { + refId: 'A', bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '', label: '' }] }, id: '1' }], metrics: [{ type: 'count', id: '1' }], query: '', @@ -897,7 +876,7 @@ describe('ElasticDatasource', function(this: any) { const interpolatedQuery = ctx.ds.interpolateVariablesInQueries([query], {})[0]; expect(interpolatedQuery.query).toBe('*'); - expect(interpolatedQuery.bucketAggs[0].settings.filters[0].query).toBe('*'); + expect((interpolatedQuery.bucketAggs![0] as Filters).settings!.filters![0].query).toBe('*'); }); }); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 05665a5a302..e1835630b63 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -1,4 +1,3 @@ -import angular from 'angular'; import _ from 'lodash'; import { DataSourceApi, @@ -10,17 +9,25 @@ import { DataLink, PluginMeta, DataQuery, + MetricFindValue, } from '@grafana/data'; import LanguageProvider from './language_provider'; import { ElasticResponse } from './elastic_response'; import { IndexPattern } from './index_pattern'; import { ElasticQueryBuilder } from './query_builder'; import { toUtc } from '@grafana/data'; -import * as queryDef from './query_def'; +import { defaultBucketAgg, hasMetricOfType } from './query_def'; import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery } from './types'; +import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; +import { + isMetricAggregationWithField, + isPipelineAggregationWithMultipleBucketPaths, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { bucketAggregationConfig } from './components/QueryEditor/BucketAggregationsEditor/utils'; +import { isBucketAggregationWithField } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; // Those are metadata fields as defined in https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-fields.html#_identity_metadata_fields. // custom fields can start with underscores, therefore is not safe to exclude anything that starts with one. @@ -235,7 +242,7 @@ export class ElasticDatasource extends DataSourceApi { const list = []; @@ -325,7 +332,7 @@ export class ElasticDatasource extends DataSourceApi { const timeField: any = _.find(dateFields, { text: this.timeField }); if (!timeField) { @@ -371,7 +378,58 @@ export class ElasticDatasource extends DataSourceApi { + const metricConfig = metricAggregationConfig[metric.type]; + + let text = metricConfig.label + '('; + + if (isMetricAggregationWithField(metric)) { + text += metric.field; + } + if (isPipelineAggregationWithMultipleBucketPaths(metric)) { + text += metric.settings?.script?.replace(new RegExp('params.', 'g'), ''); + } + text += '), '; + + return `${acc} ${text}`; + }, ''); + + text += bucketAggs?.reduce((acc, bucketAgg, index) => { + const bucketConfig = bucketAggregationConfig[bucketAgg.type]; + + let text = ''; + if (index === 0) { + text += ' Group by: '; + } + + text += bucketConfig.label + '('; + if (isBucketAggregationWithField(bucketAgg)) { + text += bucketAgg.field; + } + + return `${acc} ${text}), `; + }, ''); + + if (query.alias) { + text += 'Alias: ' + query.alias; + } + + return text; } query(options: DataQueryRequest): Promise { @@ -388,8 +446,8 @@ export class ElasticDatasource extends DataSourceApi { const configuredEsVersion = this.esVersion; return this.get('/_mapping').then((result: any) => { const typeMap: any = { @@ -462,17 +521,17 @@ export class ElasticDatasource extends DataSourceApi { + const shouldAddField = (obj: any, key: string) => { if (this.isMetadataField(key)) { return false; } - if (!query.type) { + if (!type) { return true; } // equal query type filter, or via typemap translation - return query.type === obj.type || query.type === typeMap[obj.type]; + return type === obj.type || type === typeMap[obj.type]; }; // Store subfield names: [system, process, cpu, total] -> system.process.cpu.total @@ -498,7 +557,7 @@ export class ElasticDatasource extends DataSourceApi= 5 ? 'query_then_fetch' : 'count'; const header = this.getQueryHeader(searchType, range.from, range.to); - let esQuery = angular.toJson(this.queryBuilder.getTermsQuery(queryDef)); + let esQuery = JSON.stringify(this.queryBuilder.getTermsQuery(queryDef)); esQuery = esQuery.replace(/\$timeFrom/g, range.from.valueOf().toString()); esQuery = esQuery.replace(/\$timeTo/g, range.to.valueOf().toString()); @@ -568,17 +627,17 @@ export class ElasticDatasource extends DataSourceApi { + const parsedQuery = JSON.parse(query); if (query) { - if (query.find === 'fields') { - query.field = this.templateSrv.replace(query.field, {}, 'lucene'); + if (parsedQuery.find === 'fields') { + parsedQuery.field = this.templateSrv.replace(parsedQuery.field, {}, 'lucene'); return this.getFields(query); } - if (query.find === 'terms') { - query.field = this.templateSrv.replace(query.field, {}, 'lucene'); - query.query = this.templateSrv.replace(query.query || '*', {}, 'lucene'); + if (parsedQuery.find === 'terms') { + parsedQuery.field = this.templateSrv.replace(parsedQuery.field, {}, 'lucene'); + parsedQuery.query = this.templateSrv.replace(parsedQuery.query || '*', {}, 'lucene'); return this.getTerms(query); } } @@ -587,7 +646,7 @@ export class ElasticDatasource extends DataSourceApi 1) { - metricName += ' ' + metric.field; + if (isMetricAggregationWithField(metric)) { + metricName += ' ' + metric.field; + } + if (metric.type === 'bucket_script') { //Use the formula in the column name - metricName = metric.settings.script; + metricName = metric.settings?.script || ''; } } @@ -203,9 +221,9 @@ export class ElasticResponse { // This is quite complex // need to recurse down the nested buckets to build series - processBuckets(aggs: any, target: any, seriesList: any, table: TableModel, props: any, depth: any) { + processBuckets(aggs: any, target: ElasticsearchQuery, seriesList: any, table: TableModel, props: any, depth: number) { let bucket, aggDef: any, esAgg, aggId; - const maxDepth = target.bucketAggs.length - 1; + const maxDepth = target.bucketAggs!.length - 1; for (aggId in aggs) { aggDef = _.find(target.bucketAggs, { id: aggId }); @@ -239,16 +257,24 @@ export class ElasticResponse { } } - private getMetricName(metric: any) { - let metricDef: any = _.find(queryDef.metricAggTypes, { value: metric }); - if (!metricDef) { - metricDef = _.find(queryDef.extendedStats, { value: metric }); + private getMetricName(metric: string): string { + const metricDef = Object.entries(metricAggregationConfig) + .filter(([key]) => key === metric) + .map(([_, value]) => value)[0]; + + if (metricDef) { + return metricDef.label; } - return metricDef ? metricDef.text : metric; + const extendedStat = queryDef.extendedStats.find(e => e.value === metric); + if (extendedStat) { + return extendedStat.label; + } + + return metric; } - private getSeriesName(series: any, target: any, metricTypeCount: any) { + private getSeriesName(series: any, target: ElasticsearchQuery, metricTypeCount: any) { let metricName = this.getMetricName(series.metric); if (target.alias) { @@ -274,7 +300,7 @@ export class ElasticResponse { }); } - if (series.field && queryDef.isPipelineAgg(series.metric)) { + if (queryDef.isPipelineAgg(series.metric)) { if (series.metric && queryDef.isPipelineAggWithMultipleBucketPaths(series.metric)) { const agg: any = _.find(target.metrics, { id: series.metricId }); if (agg && agg.settings.script) { @@ -283,7 +309,7 @@ export class ElasticResponse { for (const pv of agg.pipelineVariables) { const appliedAgg: any = _.find(target.metrics, { id: pv.pipelineAgg }); if (appliedAgg) { - metricName = metricName.replace('params.' + pv.name, queryDef.describeMetric(appliedAgg)); + metricName = metricName.replace('params.' + pv.name, describeMetric(appliedAgg)); } } } else { @@ -292,7 +318,7 @@ export class ElasticResponse { } else { const appliedAgg: any = _.find(target.metrics, { id: series.field }); if (appliedAgg) { - metricName += ' ' + queryDef.describeMetric(appliedAgg); + metricName += ' ' + describeMetric(appliedAgg); } else { metricName = 'Unset'; } @@ -318,7 +344,7 @@ export class ElasticResponse { return name.trim() + ' ' + metricName; } - nameSeries(seriesList: any, target: any) { + nameSeries(seriesList: any, target: ElasticsearchQuery) { const metricTypeCount = _.uniq(_.map(seriesList, 'metric')).length; for (let i = 0; i < seriesList.length; i++) { @@ -327,7 +353,7 @@ export class ElasticResponse { } } - processHits(hits: { total: { value: any }; hits: any[] }, seriesList: any[], target: any) { + processHits(hits: { total: { value: any }; hits: any[] }, seriesList: any[], target: ElasticsearchQuery) { const hitsTotal = typeof hits.total === 'number' ? hits.total : hits.total.value; // <- Works with Elasticsearch 7.0+ const series: any = { @@ -363,7 +389,7 @@ export class ElasticResponse { seriesList.push(series); } - trimDatapoints(aggregations: any, target: any) { + trimDatapoints(aggregations: any, target: ElasticsearchQuery) { const histogram: any = _.find(target.bucketAggs, { type: 'date_histogram' }); const shouldDropFirstAndLast = histogram && histogram.settings && histogram.settings.trimEdges; @@ -395,7 +421,7 @@ export class ElasticResponse { } getTimeSeries() { - if (this.targets.some((target: any) => target.metrics.some((metric: any) => metric.type === 'raw_data'))) { + if (this.targets.some(target => target.metrics?.some(metric => metric.type === 'raw_data'))) { return this.processResponseToDataFrames(false); } return this.processResponseToSeries(); @@ -423,7 +449,7 @@ export class ElasticResponse { if (docs.length > 0) { let series = createEmptyDataFrame( propNames, - this.targets[0].timeField, + this.targets[0].timeField!, isLogsRequest, logMessageField, logLevelField @@ -498,6 +524,7 @@ export class ElasticResponse { if (response.aggregations) { const aggregations = response.aggregations; + const target = this.targets[i]; const tmpSeriesList: any[] = []; const table = new TableModel(); table.refId = target.refId; diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx new file mode 100644 index 00000000000..8854edbc3c1 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx @@ -0,0 +1,28 @@ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ElasticsearchProvider } from '../components/QueryEditor/ElasticsearchQueryContext'; +import { useNextId } from './useNextId'; +import { ElasticsearchQuery } from '../types'; + +describe('useNextId', () => { + it('Should return the next available id', () => { + const query: ElasticsearchQuery = { + refId: 'A', + metrics: [{ id: '1', type: 'avg' }], + bucketAggs: [{ id: '2', type: 'date_histogram' }], + }; + const wrapper: FunctionComponent = ({ children }) => { + return ( + {}}> + {children} + + ); + }; + + const { result } = renderHook(() => useNextId(), { + wrapper, + }); + + expect(result.current).toBe('3'); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts new file mode 100644 index 00000000000..8d0d994e4c4 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useQuery } from '../components/QueryEditor/ElasticsearchQueryContext'; +import { BucketAggregation } from '../components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { MetricAggregation } from '../components/QueryEditor/MetricAggregationsEditor/aggregations'; + +const toId = (e: T): T['id'] => e.id; + +const toInt = (idString: string) => parseInt(idString, 10); + +export const useNextId = (): MetricAggregation['id'] | BucketAggregation['id'] => { + const { metrics, bucketAggs } = useQuery(); + + return useMemo( + () => + (Math.max(...[...(metrics?.map(toId) || ['0']), ...(bucketAggs?.map(toId) || ['0'])].map(toInt)) + 1).toString(), + [metrics, bucketAggs] + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx new file mode 100644 index 00000000000..91e2cf7f65b --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx @@ -0,0 +1,69 @@ +import React, { FunctionComponent } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useStatelessReducer, useDispatch, DispatchContext, combineReducers } from './useStatelessReducer'; + +describe('useStatelessReducer Hook', () => { + it('When dispatch is called, it should call the provided reducer with the correct action and state', () => { + const action = { type: 'SOME ACTION' }; + const reducer = jest.fn(); + const state = { someProp: 'some state' }; + + const { result } = renderHook(() => useStatelessReducer(() => {}, state, reducer)); + + result.current(action); + + expect(reducer).toHaveBeenCalledWith(state, action); + }); + + it('When an action is dispatched, it should call the provided onChange callback with the result from the reducer', () => { + const action = { type: 'SOME ACTION' }; + const state = { propA: 'A', propB: 'B' }; + const expectedState = { ...state, propB: 'Changed' }; + const reducer = () => expectedState; + const onChange = jest.fn(); + + const { result } = renderHook(() => useStatelessReducer(onChange, state, reducer)); + + result.current(action); + + expect(onChange).toHaveBeenLastCalledWith(expectedState); + }); +}); + +describe('useDispatch Hook', () => { + it('Should throw when used outside of DispatchContext', () => { + const { result } = renderHook(() => useDispatch()); + + expect(result.error).toBeTruthy(); + }); + + it('Should return a dispatch function', () => { + const dispatch = jest.fn(); + const wrapper: FunctionComponent = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useDispatch(), { + wrapper, + }); + + expect(result.current).toBe(dispatch); + }); +}); + +describe('combineReducers', () => { + it('Should correctly combine reducers', () => { + const reducerA = jest.fn(); + const reducerB = jest.fn(); + + const combinedReducer = combineReducers({ reducerA, reducerB }); + + const action = { type: 'SOME ACTION' }; + const initialState = { reducerA: 'A', reducerB: 'B' }; + + combinedReducer(initialState, action); + + expect(reducerA).toHaveBeenCalledWith(initialState.reducerA, action); + expect(reducerB).toHaveBeenCalledWith(initialState.reducerB, action); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts new file mode 100644 index 00000000000..8a38cc3ce7c --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts @@ -0,0 +1,45 @@ +import { createContext, useCallback, useContext } from 'react'; + +export interface Action { + type: T; +} + +export type Reducer = (state: S, action: A) => S; + +export const combineReducers = (reducers: { [P in keyof S]: Reducer }) => ( + state: S, + action: A +): Partial => { + const newState = {} as S; + for (const key in reducers) { + newState[key] = reducers[key](state[key], action); + } + return newState; +}; + +export const useStatelessReducer = ( + onChange: (value: State) => void, + state: State, + reducer: (state: State, action: A) => State +) => { + const dispatch = useCallback( + (action: A) => { + onChange(reducer(state, action)); + }, + [onChange, state, reducer] + ); + + return dispatch; +}; + +export const DispatchContext = createContext<((action: Action) => void) | undefined>(undefined); + +export const useDispatch = (): ((action: T) => void) => { + const dispatch = useContext(DispatchContext); + + if (!dispatch) { + throw new Error('Use DispatchContext first.'); + } + + return dispatch; +}; diff --git a/public/app/plugins/datasource/elasticsearch/metric_agg.ts b/public/app/plugins/datasource/elasticsearch/metric_agg.ts deleted file mode 100644 index 590d11067eb..00000000000 --- a/public/app/plugins/datasource/elasticsearch/metric_agg.ts +++ /dev/null @@ -1,253 +0,0 @@ -import coreModule from 'app/core/core_module'; -import _ from 'lodash'; -import * as queryDef from './query_def'; -import { ElasticsearchAggregation } from './types'; -import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; -import { CoreEvents } from 'app/types'; - -function createDefaultMetric(id = 0): ElasticsearchAggregation { - return { type: 'count', field: 'select field', id: (id + 1).toString() }; -} - -export class ElasticMetricAggCtrl { - /** @ngInject */ - constructor($scope: any, uiSegmentSrv: any, $rootScope: GrafanaRootScope) { - const metricAggs: ElasticsearchAggregation[] = $scope.target.metrics; - $scope.metricAggTypes = queryDef.getMetricAggTypes($scope.esVersion); - $scope.extendedStats = queryDef.extendedStats; - $scope.pipelineAggOptions = []; - $scope.modelSettingsValues = {}; - - $scope.init = () => { - $scope.agg = metricAggs[$scope.index]; - $scope.validateModel(); - $scope.updatePipelineAggOptions(); - }; - - $scope.updatePipelineAggOptions = () => { - $scope.pipelineAggOptions = queryDef.getPipelineAggOptions($scope.target, $scope.agg); - }; - - $rootScope.onAppEvent( - CoreEvents.elasticQueryUpdated, - () => { - $scope.index = _.indexOf(metricAggs, $scope.agg); - $scope.updatePipelineAggOptions(); - $scope.validateModel(); - }, - $scope - ); - - $scope.validateModel = () => { - $scope.isFirst = $scope.index === 0; - $scope.isSingle = metricAggs.length === 1; - $scope.settingsLinkText = ''; - $scope.variablesLinkText = ''; - $scope.aggDef = _.find($scope.metricAggTypes, { value: $scope.agg.type }); - $scope.isValidAgg = $scope.aggDef != null; - - if (queryDef.isPipelineAgg($scope.agg.type)) { - if (queryDef.isPipelineAggWithMultipleBucketPaths($scope.agg.type)) { - $scope.variablesLinkText = 'Options'; - - if ($scope.agg.settings.script) { - $scope.variablesLinkText = 'Script: ' + $scope.agg.settings.script.replace(new RegExp('params.', 'g'), ''); - } - } else { - $scope.agg.pipelineAgg = $scope.agg.pipelineAgg || 'select metric'; - $scope.agg.field = $scope.agg.pipelineAgg; - } - - const pipelineOptions = queryDef.getPipelineOptions($scope.agg); - if (pipelineOptions.length > 0) { - _.each(pipelineOptions, opt => { - $scope.agg.settings[opt.text] = $scope.agg.settings[opt.text] || opt.default; - }); - $scope.settingsLinkText = 'Options'; - } - } else if (!$scope.agg.field) { - $scope.agg.field = 'select field'; - } - switch ($scope.agg.type) { - case 'cardinality': { - const precisionThreshold = $scope.agg.settings.precision_threshold || ''; - $scope.settingsLinkText = 'Precision threshold: ' + precisionThreshold; - break; - } - case 'percentiles': { - $scope.agg.settings.percents = $scope.agg.settings.percents || [25, 50, 75, 95, 99]; - $scope.settingsLinkText = 'Values: ' + $scope.agg.settings.percents.join(','); - break; - } - case 'extended_stats': { - if (_.keys($scope.agg.meta).length === 0) { - $scope.agg.meta.std_deviation_bounds_lower = true; - $scope.agg.meta.std_deviation_bounds_upper = true; - } - - const stats = _.reduce( - $scope.agg.meta, - (memo, val, key) => { - if (val) { - const def: any = _.find($scope.extendedStats, { value: key }); - memo.push(def.text); - } - return memo; - }, - [] as string[] - ); - - $scope.settingsLinkText = 'Stats: ' + stats.join(', '); - break; - } - case 'moving_avg': { - $scope.movingAvgModelTypes = queryDef.movingAvgModelOptions; - $scope.modelSettings = queryDef.getMovingAvgSettings($scope.agg.settings.model, true); - $scope.updateMovingAvgModelSettings(); - break; - } - case 'moving_fn': { - const movingFunctionOptions = queryDef.getPipelineOptions($scope.agg); - _.each(movingFunctionOptions, opt => { - $scope.agg.settings[opt.text] = $scope.agg.settings[opt.text] || opt.default; - }); - break; - } - case 'raw_document': - case 'raw_data': { - $scope.agg.settings.size = $scope.agg.settings.size || 500; - $scope.settingsLinkText = 'Size: ' + $scope.agg.settings.size; - $scope.target.metrics.splice(0, $scope.target.metrics.length, $scope.agg); - - $scope.target.bucketAggs = []; - break; - } - } - if ($scope.aggDef?.supportsInlineScript) { - // I know this stores the inline script twice - // but having it like this simplifes the query_builder - const inlineScript = $scope.agg.inlineScript; - if (inlineScript) { - $scope.agg.settings.script = { inline: inlineScript }; - } else { - delete $scope.agg.settings.script; - } - - if ($scope.settingsLinkText === '') { - $scope.settingsLinkText = 'Options'; - } - } - }; - - $scope.toggleOptions = () => { - $scope.showOptions = !$scope.showOptions; - $scope.updatePipelineAggOptions(); - }; - - $scope.toggleVariables = () => { - $scope.showVariables = !$scope.showVariables; - }; - - $scope.onChangeInternal = () => { - $scope.onChange(); - }; - - $scope.updateMovingAvgModelSettings = () => { - const modelSettingsKeys = []; - const modelSettings = queryDef.getMovingAvgSettings($scope.agg.settings.model, false); - for (let i = 0; i < modelSettings.length; i++) { - modelSettingsKeys.push(modelSettings[i].value); - } - - for (const key in $scope.agg.settings.settings) { - if ($scope.agg.settings.settings[key] === null || modelSettingsKeys.indexOf(key) === -1) { - delete $scope.agg.settings.settings[key]; - } - } - }; - - $scope.onChangeClearInternal = () => { - delete $scope.agg.settings.minimize; - $scope.onChange(); - }; - - $scope.onTypeChange = () => { - $scope.agg.settings = {}; - $scope.agg.meta = {}; - $scope.showOptions = false; - - // reset back to metric/group by query - if ( - $scope.target.bucketAggs.length === 0 && - ($scope.agg.type !== 'raw_document' || $scope.agg.type !== 'raw_data') - ) { - $scope.target.bucketAggs = [queryDef.defaultBucketAgg()]; - } - - $scope.showVariables = queryDef.isPipelineAggWithMultipleBucketPaths($scope.agg.type); - $scope.updatePipelineAggOptions(); - $scope.onChange(); - }; - - $scope.getFieldsInternal = () => { - if ($scope.agg.type === 'cardinality') { - return $scope.getFields(); - } - return $scope.getFields({ $fieldType: 'number' }); - }; - - $scope.addMetricAgg = () => { - const addIndex = metricAggs.length; - - const id = _.reduce( - $scope.target.bucketAggs.concat($scope.target.metrics), - (max, val) => { - return parseInt(val.id, 10) > max ? parseInt(val.id, 10) : max; - }, - 0 - ); - - metricAggs.splice(addIndex, 0, createDefaultMetric(id)); - $scope.onChange(); - }; - - $scope.removeMetricAgg = () => { - const metricBeingRemoved = metricAggs[$scope.index]; - const metricsToRemove = queryDef.getAncestors($scope.target, metricBeingRemoved); - const newMetricAggs = metricAggs.filter(m => !metricsToRemove.includes(m.id)); - if (newMetricAggs.length > 0) { - metricAggs.splice(0, metricAggs.length, ...newMetricAggs); - } else { - metricAggs.splice(0, metricAggs.length, createDefaultMetric()); - } - $scope.onChange(); - }; - - $scope.toggleShowMetric = () => { - $scope.agg.hide = !$scope.agg.hide; - if (!$scope.agg.hide) { - delete $scope.agg.hide; - } - $scope.onChange(); - }; - - $scope.init(); - } -} - -export function elasticMetricAgg() { - return { - templateUrl: 'public/app/plugins/datasource/elasticsearch/partials/metric_agg.html', - controller: ElasticMetricAggCtrl, - restrict: 'E', - scope: { - target: '=', - index: '=', - onChange: '&', - getFields: '&', - esVersion: '=', - }, - }; -} - -coreModule.directive('elasticMetricAgg', elasticMetricAgg); diff --git a/public/app/plugins/datasource/elasticsearch/module.ts b/public/app/plugins/datasource/elasticsearch/module.ts index b51eed2df22..1ac22e392ab 100644 --- a/public/app/plugins/datasource/elasticsearch/module.ts +++ b/public/app/plugins/datasource/elasticsearch/module.ts @@ -1,13 +1,13 @@ import { DataSourcePlugin } from '@grafana/data'; import { ElasticDatasource } from './datasource'; -import { ElasticQueryCtrl } from './query_ctrl'; import { ConfigEditor } from './configuration/ConfigEditor'; +import { QueryEditor } from './components/QueryEditor'; class ElasticAnnotationsQueryCtrl { static templateUrl = 'partials/annotations.editor.html'; } export const plugin = new DataSourcePlugin(ElasticDatasource) - .setQueryCtrl(ElasticQueryCtrl) + .setQueryEditor(QueryEditor) .setConfigEditor(ConfigEditor) .setAnnotationQueryCtrl(ElasticAnnotationsQueryCtrl); diff --git a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html deleted file mode 100644 index bf23328e623..00000000000 --- a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html +++ /dev/null @@ -1,239 +0,0 @@ -
-
- - - - - - -
- - - -
- - -
-
- -
-
-
- - - -
- -
- - -
- -
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - - -
-
- - - -
-
- - -
-
- - - -
-
- - -
-
- -
-
-
- - - - -
-
- - -
-
-
- -
-
- - -
-
-
diff --git a/public/app/plugins/datasource/elasticsearch/partials/metric_agg.html b/public/app/plugins/datasource/elasticsearch/partials/metric_agg.html deleted file mode 100644 index ab5f5c5e133..00000000000 --- a/public/app/plugins/datasource/elasticsearch/partials/metric_agg.html +++ /dev/null @@ -1,161 +0,0 @@ -
-
- -
- -
- - - -
- -
- -
- - - - - -
- - -
-
- -
- -
- - -
-
- -
-
- - -
- -
- - -
- -
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
- - -
- -
- - -
- -
- - -
- - -
- - -
- - - -
- -
- - -
-
- - -
- - -
- - -
- -
- - -
- - -
-
- -
- - -
- -
- - -
-
diff --git a/public/app/plugins/datasource/elasticsearch/partials/pipeline_variables.html b/public/app/plugins/datasource/elasticsearch/partials/pipeline_variables.html deleted file mode 100644 index 42d008cb102..00000000000 --- a/public/app/plugins/datasource/elasticsearch/partials/pipeline_variables.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
- - - - - -
-
- - - -
-
diff --git a/public/app/plugins/datasource/elasticsearch/partials/query.editor.html b/public/app/plugins/datasource/elasticsearch/partials/query.editor.html deleted file mode 100644 index 1100bc44df4..00000000000 --- a/public/app/plugins/datasource/elasticsearch/partials/query.editor.html +++ /dev/null @@ -1,31 +0,0 @@ - - -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- -
diff --git a/public/app/plugins/datasource/elasticsearch/pipeline_variables.ts b/public/app/plugins/datasource/elasticsearch/pipeline_variables.ts deleted file mode 100644 index c1630674af1..00000000000 --- a/public/app/plugins/datasource/elasticsearch/pipeline_variables.ts +++ /dev/null @@ -1,46 +0,0 @@ -import coreModule from 'app/core/core_module'; -import _ from 'lodash'; - -export function elasticPipelineVariables() { - return { - templateUrl: 'public/app/plugins/datasource/elasticsearch/partials/pipeline_variables.html', - controller: 'ElasticPipelineVariablesCtrl', - restrict: 'E', - scope: { - onChange: '&', - variables: '=', - options: '=', - }, - }; -} - -const newVariable = (index: any) => { - return { - name: 'var' + index, - pipelineAgg: 'select metric', - }; -}; - -export class ElasticPipelineVariablesCtrl { - /** @ngInject */ - constructor($scope: any) { - $scope.variables = $scope.variables || [newVariable(1)]; - - $scope.onChangeInternal = () => { - $scope.onChange(); - }; - - $scope.add = () => { - $scope.variables.push(newVariable($scope.variables.length + 1)); - $scope.onChange(); - }; - - $scope.remove = (index: number) => { - $scope.variables.splice(index, 1); - $scope.onChange(); - }; - } -} - -coreModule.directive('elasticPipelineVariables', elasticPipelineVariables); -coreModule.controller('ElasticPipelineVariablesCtrl', ElasticPipelineVariablesCtrl); diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.ts b/public/app/plugins/datasource/elasticsearch/query_builder.ts index 98c994cbfcd..60f2178e5b6 100644 --- a/public/app/plugins/datasource/elasticsearch/query_builder.ts +++ b/public/app/plugins/datasource/elasticsearch/query_builder.ts @@ -1,5 +1,17 @@ -import * as queryDef from './query_def'; -import { ElasticsearchAggregation } from './types'; +import { + Filters, + Histogram, + DateHistogram, + Terms, +} from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { + isMetricAggregationWithField, + isMetricAggregationWithSettings, + isPipelineAggregation, + isPipelineAggregationWithMultipleBucketPaths, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { defaultBucketAgg, defaultMetricAgg, findMetricById } from './query_def'; +import { ElasticsearchQuery } from './types'; export class ElasticQueryBuilder { timeField: string; @@ -21,15 +33,18 @@ export class ElasticQueryBuilder { return filter; } - buildTermsAgg(aggDef: ElasticsearchAggregation, queryNode: { terms?: any; aggs?: any }, target: { metrics: any[] }) { - let metricRef, metric, y; + buildTermsAgg(aggDef: Terms, queryNode: { terms?: any; aggs?: any }, target: ElasticsearchQuery) { + let metricRef; queryNode.terms = { field: aggDef.field }; if (!aggDef.settings) { return queryNode; } - queryNode.terms.size = parseInt(aggDef.settings.size, 10) === 0 ? 500 : parseInt(aggDef.settings.size, 10); + // TODO: This default should be somewhere else together with the one used in the UI + const size = aggDef.settings?.size ? parseInt(aggDef.settings.size, 10) : 500; + queryNode.terms.size = size === 0 ? 500 : size; + if (aggDef.settings.orderBy !== void 0) { queryNode.terms.order = {}; if (aggDef.settings.orderBy === '_term' && this.esVersion >= 60) { @@ -41,12 +56,13 @@ export class ElasticQueryBuilder { // if metric ref, look it up and add it to this agg level metricRef = parseInt(aggDef.settings.orderBy, 10); if (!isNaN(metricRef)) { - for (y = 0; y < target.metrics.length; y++) { - metric = target.metrics[y]; + for (let metric of target.metrics || []) { if (metric.id === aggDef.settings.orderBy) { queryNode.aggs = {}; queryNode.aggs[metric.id] = {}; - queryNode.aggs[metric.id][metric.type] = { field: metric.field }; + if (isMetricAggregationWithField(metric)) { + queryNode.aggs[metric.id][metric.type] = { field: metric.field }; + } break; } } @@ -68,7 +84,7 @@ export class ElasticQueryBuilder { return queryNode; } - getDateHistogramAgg(aggDef: ElasticsearchAggregation) { + getDateHistogramAgg(aggDef: DateHistogram) { const esAgg: any = {}; const settings = aggDef.settings || {}; esAgg.interval = settings.interval; @@ -85,33 +101,24 @@ export class ElasticQueryBuilder { esAgg.interval = '$__interval'; } - if (settings.missing) { - esAgg.missing = settings.missing; - } - return esAgg; } - getHistogramAgg(aggDef: ElasticsearchAggregation) { + getHistogramAgg(aggDef: Histogram) { const esAgg: any = {}; const settings = aggDef.settings || {}; esAgg.interval = settings.interval; esAgg.field = aggDef.field; esAgg.min_doc_count = settings.min_doc_count || 0; - if (settings.missing) { - esAgg.missing = settings.missing; - } return esAgg; } - getFiltersAgg(aggDef: ElasticsearchAggregation) { - const filterObj: any = {}; - for (let i = 0; i < aggDef.settings.filters.length; i++) { - const query = aggDef.settings.filters[i].query; - let label = aggDef.settings.filters[i].label; - label = label === '' || label === undefined ? query : label; - filterObj[label] = { + getFiltersAgg(aggDef: Filters) { + const filterObj: Record = {}; + + for (let { query, label } of aggDef.settings?.filters || []) { + filterObj[label || query] = { query_string: { query: query, analyze_wildcard: true, @@ -183,10 +190,10 @@ export class ElasticQueryBuilder { } } - build(target: any, adhocFilters?: any, queryString?: string) { + build(target: ElasticsearchQuery, adhocFilters?: any, queryString?: string) { // make sure query has defaults; - target.metrics = target.metrics || [queryDef.defaultMetricAgg()]; - target.bucketAggs = target.bucketAggs || [queryDef.defaultBucketAgg()]; + target.metrics = target.metrics || [defaultMetricAgg()]; + target.bucketAggs = target.bucketAggs || [defaultBucketAgg()]; target.timeField = this.timeField; let i, j, pv, nestedAggs, metric; @@ -224,14 +231,17 @@ export class ElasticQueryBuilder { */ if (target.metrics?.[0]?.type === 'raw_document' || target.metrics?.[0]?.type === 'raw_data') { metric = target.metrics[0]; - const size = (metric.settings && metric.settings.size !== 0 && metric.settings.size) || 500; - return this.documentQuery(query, size); + + // TODO: This default should be somewhere else together with the one used in the UI + const size = metric.settings?.size ? parseInt(metric.settings.size, 10) : 500; + + return this.documentQuery(query, size || 500); } nestedAggs = query; for (i = 0; i < target.bucketAggs.length; i++) { - const aggDef: any = target.bucketAggs[i]; + const aggDef = target.bucketAggs[i]; const esAgg: any = {}; switch (aggDef.type) { @@ -254,7 +264,7 @@ export class ElasticQueryBuilder { case 'geohash_grid': { esAgg['geohash_grid'] = { field: aggDef.field, - precision: aggDef.settings.precision, + precision: aggDef.settings?.precision, }; break; } @@ -276,8 +286,8 @@ export class ElasticQueryBuilder { const aggField: any = {}; let metricAgg: any = null; - if (queryDef.isPipelineAgg(metric.type)) { - if (queryDef.isPipelineAggWithMultipleBucketPaths(metric.type)) { + if (isPipelineAggregation(metric)) { + if (isPipelineAggregationWithMultipleBucketPaths(metric)) { if (metric.pipelineVariables) { metricAgg = { buckets_path: {}, @@ -287,7 +297,7 @@ export class ElasticQueryBuilder { pv = metric.pipelineVariables[j]; if (pv.name && pv.pipelineAgg && /^\d*$/.test(pv.pipelineAgg)) { - const appliedAgg = queryDef.findMetricById(target.metrics, pv.pipelineAgg); + const appliedAgg = findMetricById(target.metrics, pv.pipelineAgg); if (appliedAgg) { if (appliedAgg.type === 'count') { metricAgg.buckets_path[pv.name] = '_count'; @@ -301,28 +311,27 @@ export class ElasticQueryBuilder { continue; } } else { - if (metric.pipelineAgg && /^\d*$/.test(metric.pipelineAgg)) { - const appliedAgg = queryDef.findMetricById(target.metrics, metric.pipelineAgg); + if (metric.field && /^\d*$/.test(metric.field)) { + const appliedAgg = findMetricById(target.metrics, metric.field); if (appliedAgg) { if (appliedAgg.type === 'count') { metricAgg = { buckets_path: '_count' }; } else { - metricAgg = { buckets_path: metric.pipelineAgg }; + metricAgg = { buckets_path: metric.field }; } } } else { continue; } } - } else { + } else if (isMetricAggregationWithField(metric)) { metricAgg = { field: metric.field }; } - for (const prop in metric.settings) { - if (metric.settings.hasOwnProperty(prop) && metric.settings[prop] !== null) { - metricAgg[prop] = metric.settings[prop]; - } - } + metricAgg = { + ...metricAgg, + ...(isMetricAggregationWithSettings(metric) && metric.settings), + }; aggField[metric.type] = metricAgg; nestedAggs.aggs[metric.id] = aggField; @@ -391,7 +400,7 @@ export class ElasticQueryBuilder { return query; } - getLogsQuery(target: any, adhocFilters?: any, querystring?: string) { + getLogsQuery(target: ElasticsearchQuery, adhocFilters?: any, querystring?: string) { let query: any = { size: 0, query: { diff --git a/public/app/plugins/datasource/elasticsearch/query_ctrl.ts b/public/app/plugins/datasource/elasticsearch/query_ctrl.ts deleted file mode 100644 index 36b55a43ca2..00000000000 --- a/public/app/plugins/datasource/elasticsearch/query_ctrl.ts +++ /dev/null @@ -1,118 +0,0 @@ -import './bucket_agg'; -import './metric_agg'; -import './pipeline_variables'; - -import angular, { auto } from 'angular'; -import _ from 'lodash'; -import * as queryDef from './query_def'; -import { QueryCtrl } from 'app/plugins/sdk'; -import { ElasticsearchAggregation } from './types'; -import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; -import { CoreEvents } from 'app/types'; - -export class ElasticQueryCtrl extends QueryCtrl { - static templateUrl = 'partials/query.editor.html'; - - esVersion: any; - rawQueryOld: string; - targetMetricsOld: string; - - /** @ngInject */ - constructor( - $scope: any, - $injector: auto.IInjectorService, - private $rootScope: GrafanaRootScope, - private uiSegmentSrv: any - ) { - super($scope, $injector); - - this.esVersion = this.datasource.esVersion; - - this.target = this.target || {}; - this.target.metrics = this.target.metrics || [queryDef.defaultMetricAgg()]; - this.target.bucketAggs = this.target.bucketAggs || [queryDef.defaultBucketAgg()]; - - if (this.target.bucketAggs.length === 0) { - const metric = this.target.metrics[0]; - if (!metric || metric.type !== 'raw_document') { - this.target.bucketAggs = [queryDef.defaultBucketAgg()]; - } - this.refresh(); - } - - this.queryUpdated(); - } - - getFields(type: any) { - const jsonStr = angular.toJson({ find: 'fields', type: type }); - return this.datasource - .metricFindQuery(jsonStr) - .then(this.uiSegmentSrv.transformToSegments(false)) - .catch(this.handleQueryError.bind(this)); - } - - queryUpdated() { - const newJsonTargetMetrics = angular.toJson(this.target.metrics); - const newJsonRawQuery = angular.toJson(this.datasource.queryBuilder.build(this.target), true); - if ( - (this.rawQueryOld && newJsonRawQuery !== this.rawQueryOld) || - (this.targetMetricsOld && newJsonTargetMetrics !== this.targetMetricsOld) - ) { - this.refresh(); - } - - this.rawQueryOld = newJsonRawQuery; - this.targetMetricsOld = newJsonTargetMetrics; - this.$rootScope.appEvent(CoreEvents.elasticQueryUpdated); - } - - getCollapsedText() { - const metricAggs: ElasticsearchAggregation[] = this.target.metrics; - const bucketAggs = this.target.bucketAggs; - const metricAggTypes = queryDef.getMetricAggTypes(this.esVersion); - const bucketAggTypes = queryDef.bucketAggTypes; - let text = ''; - - if (this.target.query) { - text += 'Query: ' + this.target.query + ', '; - } - - text += 'Metrics: '; - - _.each(metricAggs, (metric, index) => { - const aggDef: any = _.find(metricAggTypes, { value: metric.type }); - text += aggDef.text + '('; - if (aggDef.requiresField) { - text += metric.field; - } - if (aggDef.supportsMultipleBucketPaths) { - text += metric.settings.script.replace(new RegExp('params.', 'g'), ''); - } - text += '), '; - }); - - _.each(bucketAggs, (bucketAgg: any, index: number) => { - if (index === 0) { - text += ' Group by: '; - } - - const aggDef: any = _.find(bucketAggTypes, { value: bucketAgg.type }); - text += aggDef.text + '('; - if (aggDef.requiresField) { - text += bucketAgg.field; - } - text += '), '; - }); - - if (this.target.alias) { - text += 'Alias: ' + this.target.alias; - } - - return text; - } - - handleQueryError(err: any): any[] { - this.error = err.message || 'Failed to issue metric query'; - return []; - } -} diff --git a/public/app/plugins/datasource/elasticsearch/query_def.ts b/public/app/plugins/datasource/elasticsearch/query_def.ts index eff18efe6ce..f03516f5d4e 100644 --- a/public/app/plugins/datasource/elasticsearch/query_def.ts +++ b/public/app/plugins/datasource/elasticsearch/query_def.ts @@ -1,308 +1,52 @@ -import _ from 'lodash'; -import { ElasticsearchAggregation, ElasticsearchQuery } from './types'; +import { BucketAggregation } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { + ExtendedStat, + MetricAggregation, + MovingAverageModelOption, + MetricAggregationType, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { metricAggregationConfig, pipelineOptions } from './components/QueryEditor/MetricAggregationsEditor/utils'; -export const metricAggTypes = [ - { text: 'Count', value: 'count', requiresField: false }, - { - text: 'Average', - value: 'avg', - requiresField: true, - supportsInlineScript: true, - supportsMissing: true, - }, - { - text: 'Sum', - value: 'sum', - requiresField: true, - supportsInlineScript: true, - supportsMissing: true, - }, - { - text: 'Max', - value: 'max', - requiresField: true, - supportsInlineScript: true, - supportsMissing: true, - }, - { - text: 'Min', - value: 'min', - requiresField: true, - supportsInlineScript: true, - supportsMissing: true, - }, - { - text: 'Extended Stats', - value: 'extended_stats', - requiresField: true, - supportsMissing: true, - supportsInlineScript: true, - }, - { - text: 'Percentiles', - value: 'percentiles', - requiresField: true, - supportsMissing: true, - supportsInlineScript: true, - }, - { - text: 'Unique Count', - value: 'cardinality', - requiresField: true, - supportsMissing: true, - }, - { - text: 'Moving Average', - value: 'moving_avg', - requiresField: false, - isPipelineAgg: true, - minVersion: 2, - maxVersion: 60, - }, - { - text: 'Moving Function', - value: 'moving_fn', - requiresField: false, - isPipelineAgg: true, - minVersion: 70, - }, - { - text: 'Derivative', - value: 'derivative', - requiresField: false, - isPipelineAgg: true, - minVersion: 2, - }, - { - text: 'Cumulative Sum', - value: 'cumulative_sum', - requiresField: false, - isPipelineAgg: true, - minVersion: 2, - }, - { - text: 'Bucket Script', - value: 'bucket_script', - requiresField: false, - isPipelineAgg: true, - supportsMultipleBucketPaths: true, - minVersion: 2, - }, - { text: 'Raw Document (legacy)', value: 'raw_document', requiresField: false }, - { text: 'Raw Data', value: 'raw_data', requiresField: false }, - { text: 'Logs', value: 'logs', requiresField: false }, +export const extendedStats: ExtendedStat[] = [ + { label: 'Avg', value: 'avg' }, + { label: 'Min', value: 'min' }, + { label: 'Max', value: 'max' }, + { label: 'Sum', value: 'sum' }, + { label: 'Count', value: 'count' }, + { label: 'Std Dev', value: 'std_deviation' }, + { label: 'Std Dev Upper', value: 'std_deviation_bounds_upper' }, + { label: 'Std Dev Lower', value: 'std_deviation_bounds_lower' }, ]; -export const bucketAggTypes = [ - { text: 'Terms', value: 'terms', requiresField: true }, - { text: 'Filters', value: 'filters' }, - { text: 'Geo Hash Grid', value: 'geohash_grid', requiresField: true }, - { text: 'Date Histogram', value: 'date_histogram', requiresField: true }, - { text: 'Histogram', value: 'histogram', requiresField: true }, +export const movingAvgModelOptions: MovingAverageModelOption[] = [ + { label: 'Simple', value: 'simple' }, + { label: 'Linear', value: 'linear' }, + { label: 'Exponentially Weighted', value: 'ewma' }, + { label: 'Holt Linear', value: 'holt' }, + { label: 'Holt Winters', value: 'holt_winters' }, ]; -export const orderByOptions = [ - { text: 'Doc Count', value: '_count' }, - { text: 'Term value', value: '_term' }, -]; - -export const orderOptions = [ - { text: 'Top', value: 'desc' }, - { text: 'Bottom', value: 'asc' }, -]; - -export const sizeOptions = [ - { text: 'No limit', value: '0' }, - { text: '1', value: '1' }, - { text: '2', value: '2' }, - { text: '3', value: '3' }, - { text: '5', value: '5' }, - { text: '10', value: '10' }, - { text: '15', value: '15' }, - { text: '20', value: '20' }, -]; - -export const extendedStats = [ - { text: 'Avg', value: 'avg' }, - { text: 'Min', value: 'min' }, - { text: 'Max', value: 'max' }, - { text: 'Sum', value: 'sum' }, - { text: 'Count', value: 'count' }, - { text: 'Std Dev', value: 'std_deviation' }, - { text: 'Std Dev Upper', value: 'std_deviation_bounds_upper' }, - { text: 'Std Dev Lower', value: 'std_deviation_bounds_lower' }, -]; - -export const intervalOptions = [ - { text: 'auto', value: 'auto' }, - { text: '10s', value: '10s' }, - { text: '1m', value: '1m' }, - { text: '5m', value: '5m' }, - { text: '10m', value: '10m' }, - { text: '20m', value: '20m' }, - { text: '1h', value: '1h' }, - { text: '1d', value: '1d' }, -]; - -export const movingAvgModelOptions = [ - { text: 'Simple', value: 'simple' }, - { text: 'Linear', value: 'linear' }, - { text: 'Exponentially Weighted', value: 'ewma' }, - { text: 'Holt Linear', value: 'holt' }, - { text: 'Holt Winters', value: 'holt_winters' }, -]; - -export const pipelineOptions: any = { - moving_avg: [ - { text: 'window', default: 5 }, - { text: 'model', default: 'simple' }, - { text: 'predict', default: undefined }, - { text: 'minimize', default: false }, - ], - moving_fn: [{ text: 'window', default: 5 }, { text: 'script' }], - derivative: [{ text: 'unit', default: undefined }], - cumulative_sum: [{ text: 'format', default: undefined }], - bucket_script: [], -}; - -export const movingAvgModelSettings: any = { - simple: [], - linear: [], - ewma: [{ text: 'Alpha', value: 'alpha', default: undefined }], - holt: [ - { text: 'Alpha', value: 'alpha', default: undefined }, - { text: 'Beta', value: 'beta', default: undefined }, - ], - holt_winters: [ - { text: 'Alpha', value: 'alpha', default: undefined }, - { text: 'Beta', value: 'beta', default: undefined }, - { text: 'Gamma', value: 'gamma', default: undefined }, - { text: 'Period', value: 'period', default: undefined }, - { text: 'Pad', value: 'pad', default: undefined, isCheckbox: true }, - ], -}; - -export function getMetricAggTypes(esVersion: any) { - return _.filter(metricAggTypes, f => { - if (f.minVersion || f.maxVersion) { - const minVersion = f.minVersion || 0; - const maxVersion = f.maxVersion || esVersion; - return esVersion >= minVersion && esVersion <= maxVersion; - } else { - return true; - } - }); +export function defaultMetricAgg(id = '1'): MetricAggregation { + return { type: 'count', id }; } -export function getPipelineOptions(metric: any) { - if (!isPipelineAgg(metric.type)) { - return []; - } - - return pipelineOptions[metric.type]; +export function defaultBucketAgg(id = '1'): BucketAggregation { + return { type: 'date_histogram', id, settings: { interval: 'auto' } }; } -export function isPipelineAgg(metricType: any) { - if (metricType) { - const po = pipelineOptions[metricType]; - return po !== null && po !== undefined; - } - - return false; -} - -export function isPipelineAggWithMultipleBucketPaths(metricType: any) { - if (metricType) { - return metricAggTypes.find(t => t.value === metricType && t.supportsMultipleBucketPaths) !== undefined; - } - - return false; -} - -export function getAncestors(target: ElasticsearchQuery, metric?: ElasticsearchAggregation) { - const { metrics } = target; - if (!metrics) { - return (metric && [metric.id]) || []; - } - const initialAncestors = metric != null ? [metric.id] : ([] as string[]); - return metrics.reduce((acc: string[], metric: ElasticsearchAggregation) => { - const includedInField = (metric.field && acc.includes(metric.field)) || false; - const includedInVariables = metric.pipelineVariables?.some(pv => acc.includes(pv?.pipelineAgg ?? '')); - return includedInField || includedInVariables ? [...acc, metric.id] : acc; - }, initialAncestors); -} - -export function getPipelineAggOptions(target: ElasticsearchQuery, metric?: ElasticsearchAggregation) { - const { metrics } = target; - if (!metrics) { - return []; - } - const ancestors = getAncestors(target, metric); - return metrics.filter(m => !ancestors.includes(m.id)).map(m => ({ text: describeMetric(m), value: m.id })); -} - -export function getMovingAvgSettings(model: any, filtered: boolean) { - const filteredResult: any[] = []; - if (filtered) { - _.each(movingAvgModelSettings[model], setting => { - if (!setting.isCheckbox) { - filteredResult.push(setting); - } - }); - return filteredResult; - } - return movingAvgModelSettings[model]; -} - -export function getOrderByOptions(target: any) { - const metricRefs: any[] = []; - _.each(target.metrics, metric => { - if (metric.type !== 'count' && !isPipelineAgg(metric.type)) { - metricRefs.push({ text: describeMetric(metric), value: metric.id }); - } - }); - - return orderByOptions.concat(metricRefs); -} - -export function describeOrder(order: string) { - const def: any = _.find(orderOptions, { value: order }); - return def.text; -} - -export function describeMetric(metric: ElasticsearchAggregation) { - const def: any = _.find(metricAggTypes, { value: metric.type }); - if (!def.requiresField && !isPipelineAgg(metric.type)) { - return def.text; - } - return def.text + ' ' + metric.field; -} - -export function describeOrderBy(orderBy: any, target: any) { - const def: any = _.find(orderByOptions, { value: orderBy }); - if (def) { - return def.text; - } - const metric: any = _.find(target.metrics, { id: orderBy }); - if (metric) { - return describeMetric(metric); - } else { - return 'metric not found'; - } -} - -export function defaultMetricAgg() { - return { type: 'count', id: '1' }; -} - -export function defaultBucketAgg() { - return { type: 'date_histogram', id: '2', settings: { interval: 'auto' } }; -} - -export const findMetricById = (metrics: any[], id: any) => { - return _.find(metrics, { id: id }); -}; +export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) => + metrics.find(metric => metric.id === id); export function hasMetricOfType(target: any, type: string): boolean { return target && target.metrics && target.metrics.some((m: any) => m.type === type); } + +// Even if we have type guards when building a query, we currently have no way of getting this information from the response. +// We should try to find a better (type safe) way of doing the following 2. +export function isPipelineAgg(metricType: MetricAggregationType) { + return metricType in pipelineOptions; +} + +export function isPipelineAggWithMultipleBucketPaths(metricType: MetricAggregationType) { + return !!metricAggregationConfig[metricType].supportsMultipleBucketPaths; +} diff --git a/public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts b/public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts index d158cab12f1..ded9191561f 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts @@ -1,9 +1,10 @@ import { DataFrameView, FieldCache, KeyValue, MutableDataFrame } from '@grafana/data'; import { ElasticResponse } from '../elastic_response'; import flatten from 'app/core/utils/flatten'; +import { ElasticsearchQuery } from '../types'; describe('ElasticResponse', () => { - let targets: any; + let targets: ElasticsearchQuery[]; let response: any; let result: any; @@ -12,12 +13,17 @@ describe('ElasticResponse', () => { // therefore we only process responses as DataFrames when there's at least one // raw_data (new) query type. // We should test if refId gets populated wether there's such type of query or not - const countQuery = { + interface MockedQueryData { + target: ElasticsearchQuery; + response: any; + } + + const countQuery: MockedQueryData = { target: { refId: 'COUNT_GROUPBY_DATE_HISTOGRAM', metrics: [{ type: 'count', id: 'c_1' }], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: 'c_2' }], - }, + } as ElasticsearchQuery, response: { aggregations: { c_2: { @@ -32,7 +38,7 @@ describe('ElasticResponse', () => { }, }; - const countGroupByHistogramQuery = { + const countGroupByHistogramQuery: MockedQueryData = { target: { refId: 'COUNT_GROUPBY_HISTOGRAM', metrics: [{ type: 'count', id: 'h_3' }], @@ -47,7 +53,7 @@ describe('ElasticResponse', () => { }, }; - const rawDocumentQuery = { + const rawDocumentQuery: MockedQueryData = { target: { refId: 'RAW_DOC', metrics: [{ type: 'raw_document', id: 'r_5' }], @@ -73,10 +79,10 @@ describe('ElasticResponse', () => { }, }; - const percentilesQuery = { + const percentilesQuery: MockedQueryData = { target: { refId: 'PERCENTILE', - metrics: [{ type: 'percentiles', settings: { percents: [75, 90] }, id: 'p_1' }], + metrics: [{ type: 'percentiles', settings: { percents: ['75', '90'] }, id: 'p_1' }], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: 'p_3' }], }, response: { @@ -99,7 +105,7 @@ describe('ElasticResponse', () => { }, }; - const extendedStatsQuery = { + const extendedStatsQuery: MockedQueryData = { target: { refId: 'EXTENDEDSTATS', metrics: [ @@ -475,7 +481,7 @@ describe('ElasticResponse', () => { targets = [ { refId: 'A', - metrics: [{ type: 'percentiles', settings: { percents: [75, 90] }, id: '1' }], + metrics: [{ type: 'percentiles', settings: { percents: ['75', '90'] }, id: '1', field: '@value' }], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], }, ]; @@ -508,8 +514,8 @@ describe('ElasticResponse', () => { it('should return 2 series', () => { expect(result.data.length).toBe(2); expect(result.data[0].datapoints.length).toBe(2); - expect(result.data[0].target).toBe('p75'); - expect(result.data[1].target).toBe('p90'); + expect(result.data[0].target).toBe('p75 @value'); + expect(result.data[1].target).toBe('p90 @value'); expect(result.data[0].datapoints[0][0]).toBe(3.3); expect(result.data[0].datapoints[0][1]).toBe(1000); expect(result.data[1].datapoints[1][0]).toBe(4.5); @@ -528,6 +534,7 @@ describe('ElasticResponse', () => { type: 'extended_stats', meta: { max: true, std_deviation_bounds_upper: true }, id: '1', + field: '@value', }, ], bucketAggs: [ @@ -587,8 +594,8 @@ describe('ElasticResponse', () => { it('should return 4 series', () => { expect(result.data.length).toBe(4); expect(result.data[0].datapoints.length).toBe(1); - expect(result.data[0].target).toBe('server1 Max'); - expect(result.data[1].target).toBe('server1 Std Dev Upper'); + expect(result.data[0].target).toBe('server1 Max @value'); + expect(result.data[1].target).toBe('server1 Std Dev Upper @value'); expect(result.data[0].datapoints[0][0]).toBe(10.2); expect(result.data[1].datapoints[0][0]).toBe(3); @@ -714,7 +721,10 @@ describe('ElasticResponse', () => { id: '2', type: 'filters', settings: { - filters: [{ query: '@metric:cpu' }, { query: '@metric:logins.count' }], + filters: [ + { query: '@metric:cpu', label: '' }, + { query: '@metric:logins.count', label: '' }, + ], }, }, { type: 'date_histogram', field: '@timestamp', id: '3' }, @@ -766,13 +776,16 @@ describe('ElasticResponse', () => { targets = [ { refId: 'A', - metrics: [{ type: 'avg', id: '1' }, { type: 'count' }], + metrics: [ + { type: 'avg', id: '1', field: '@value' }, + { type: 'count', id: '3' }, + ], bucketAggs: [ { id: '2', type: 'date_histogram', field: 'host', - settings: { trimEdges: 1 }, + settings: { trimEdges: '1' }, }, ], }, @@ -820,7 +833,10 @@ describe('ElasticResponse', () => { targets = [ { refId: 'A', - metrics: [{ type: 'avg', id: '1' }, { type: 'count' }], + metrics: [ + { type: 'avg', id: '1', field: '@value' }, + { type: 'count', id: '3' }, + ], bucketAggs: [{ id: '2', type: 'terms', field: 'host' }], }, ]; @@ -871,8 +887,8 @@ describe('ElasticResponse', () => { targets = [ { refId: 'A', - metrics: [{ type: 'percentiles', field: 'value', settings: { percents: [75, 90] }, id: '1' }], - bucketAggs: [{ type: 'term', field: 'id', id: '3' }], + metrics: [{ type: 'percentiles', field: 'value', settings: { percents: ['75', '90'] }, id: '1' }], + bucketAggs: [{ type: 'terms', field: 'id', id: '3' }], }, ]; response = { @@ -1016,7 +1032,6 @@ describe('ElasticResponse', () => { { id: '3', type: 'max', field: '@value' }, { id: '4', - field: 'select field', pipelineVariables: [ { name: 'var1', pipelineAgg: '1' }, { name: 'var2', pipelineAgg: '3' }, @@ -1084,7 +1099,6 @@ describe('ElasticResponse', () => { { id: '3', type: 'max', field: '@value' }, { id: '4', - field: 'select field', pipelineVariables: [ { name: 'var1', pipelineAgg: '1' }, { name: 'var2', pipelineAgg: '3' }, @@ -1094,7 +1108,6 @@ describe('ElasticResponse', () => { }, { id: '5', - field: 'select field', pipelineVariables: [ { name: 'var1', pipelineAgg: '1' }, { name: 'var2', pipelineAgg: '3' }, diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts b/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts index 162a2112eea..df1f20fedb8 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts @@ -1,4 +1,5 @@ import { ElasticQueryBuilder } from '../query_builder'; +import { ElasticsearchQuery } from '../types'; describe('ElasticQueryBuilder', () => { const builder = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 2 }); @@ -13,7 +14,8 @@ describe('ElasticQueryBuilder', () => { describe(`version ${builder.esVersion}`, () => { it('should return query with defaults', () => { const query = builder.build({ - metrics: [{ type: 'Count', id: '0' }], + refId: 'A', + metrics: [{ type: 'count', id: '0' }], timeField: '@timestamp', bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], }); @@ -24,6 +26,7 @@ describe('ElasticQueryBuilder', () => { it('with multiple bucket aggs', () => { const query = builder.build({ + refId: 'A', metrics: [{ type: 'count', id: '1' }], timeField: '@timestamp', bucketAggs: [ @@ -39,6 +42,7 @@ describe('ElasticQueryBuilder', () => { it('with select field', () => { const query = builder.build( { + refId: 'A', metrics: [{ type: 'avg', field: '@value', id: '1' }], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }], }, @@ -51,7 +55,8 @@ describe('ElasticQueryBuilder', () => { }); it('term agg and order by term', () => { - const target = { + const target: ElasticsearchQuery = { + refId: 'A', metrics: [ { type: 'count', id: '1' }, { type: 'avg', field: '@value', id: '5' }, @@ -60,14 +65,16 @@ describe('ElasticQueryBuilder', () => { { type: 'terms', field: '@host', - settings: { size: 5, order: 'asc', orderBy: '_term' }, + settings: { size: '5', order: 'asc', orderBy: '_term' }, id: '2', }, { type: 'date_histogram', field: '@timestamp', id: '3' }, ], }; + const query = builder.build(target, 100, '1000'); const firstLevel = query.aggs['2']; + if (builder.esVersion >= 60) { expect(firstLevel.terms.order._key).toBe('asc'); } else { @@ -78,6 +85,7 @@ describe('ElasticQueryBuilder', () => { it('with term agg and order by metric agg', () => { const query = builder.build( { + refId: 'A', metrics: [ { type: 'count', id: '1' }, { type: 'avg', field: '@value', id: '5' }, @@ -86,7 +94,7 @@ describe('ElasticQueryBuilder', () => { { type: 'terms', field: '@host', - settings: { size: 5, order: 'asc', orderBy: '5' }, + settings: { size: '5', order: 'asc', orderBy: '5' }, id: '2', }, { type: 'date_histogram', field: '@timestamp', id: '3' }, @@ -106,12 +114,13 @@ describe('ElasticQueryBuilder', () => { it('with term agg and valid min_doc_count', () => { const query = builder.build( { + refId: 'A', metrics: [{ type: 'count', id: '1' }], bucketAggs: [ { type: 'terms', field: '@host', - settings: { min_doc_count: 1 }, + settings: { min_doc_count: '1' }, id: '2', }, { type: 'date_histogram', field: '@timestamp', id: '3' }, @@ -128,6 +137,7 @@ describe('ElasticQueryBuilder', () => { it('with term agg and variable as min_doc_count', () => { const query = builder.build( { + refId: 'A', metrics: [{ type: 'count', id: '1' }], bucketAggs: [ { @@ -148,15 +158,19 @@ describe('ElasticQueryBuilder', () => { }); it('with metric percentiles', () => { + const percents = ['1', '2', '3', '4']; + const field = '@load_time'; + const query = builder.build( { + refId: 'A', metrics: [ { id: '1', type: 'percentiles', - field: '@load_time', + field, settings: { - percents: [1, 2, 3, 4], + percents, }, }, ], @@ -168,12 +182,13 @@ describe('ElasticQueryBuilder', () => { const firstLevel = query.aggs['3']; - expect(firstLevel.aggs['1'].percentiles.field).toBe('@load_time'); - expect(firstLevel.aggs['1'].percentiles.percents).toEqual([1, 2, 3, 4]); + expect(firstLevel.aggs['1'].percentiles.field).toBe(field); + expect(firstLevel.aggs['1'].percentiles.percents).toEqual(percents); }); it('with filters aggs', () => { const query = builder.build({ + refId: 'A', metrics: [{ type: 'count', id: '1' }], timeField: '@timestamp', bucketAggs: [ @@ -181,7 +196,10 @@ describe('ElasticQueryBuilder', () => { id: '2', type: 'filters', settings: { - filters: [{ query: '@metric:cpu' }, { query: '@metric:logins.count' }], + filters: [ + { query: '@metric:cpu', label: '' }, + { query: '@metric:logins.count', label: '' }, + ], }, }, { type: 'date_histogram', field: '@timestamp', id: '4' }, @@ -194,7 +212,8 @@ describe('ElasticQueryBuilder', () => { }); it('should return correct query for raw_document metric', () => { - const target = { + const target: ElasticsearchQuery = { + refId: 'A', metrics: [{ type: 'raw_document', id: '1', settings: {} }], timeField: '@timestamp', bucketAggs: [] as any[], @@ -236,7 +255,8 @@ describe('ElasticQueryBuilder', () => { it('should set query size from settings when raw_documents', () => { const query = builder.build({ - metrics: [{ type: 'raw_document', id: '1', settings: { size: 1337 } }], + refId: 'A', + metrics: [{ type: 'raw_document', id: '1', settings: { size: '1337' } }], timeField: '@timestamp', bucketAggs: [], }); @@ -246,6 +266,7 @@ describe('ElasticQueryBuilder', () => { it('with moving average', () => { const query = builder.build({ + refId: 'A', metrics: [ { id: '3', @@ -256,7 +277,6 @@ describe('ElasticQueryBuilder', () => { id: '2', type: 'moving_avg', field: '3', - pipelineAgg: '3', }, ], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], @@ -271,17 +291,16 @@ describe('ElasticQueryBuilder', () => { it('with moving average doc count', () => { const query = builder.build({ + refId: 'A', metrics: [ { id: '3', type: 'count', - field: 'select field', }, { id: '2', type: 'moving_avg', field: '3', - pipelineAgg: '3', }, ], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }], @@ -296,6 +315,7 @@ describe('ElasticQueryBuilder', () => { it('with broken moving average', () => { const query = builder.build({ + refId: 'A', metrics: [ { id: '3', @@ -305,12 +325,11 @@ describe('ElasticQueryBuilder', () => { { id: '2', type: 'moving_avg', - pipelineAgg: '3', + field: '3', }, { id: '4', type: 'moving_avg', - pipelineAgg: 'Metric to apply moving average', }, ], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], @@ -326,6 +345,7 @@ describe('ElasticQueryBuilder', () => { it('with derivative', () => { const query = builder.build({ + refId: 'A', metrics: [ { id: '3', @@ -335,7 +355,7 @@ describe('ElasticQueryBuilder', () => { { id: '2', type: 'derivative', - pipelineAgg: '3', + field: '3', }, ], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], @@ -350,16 +370,16 @@ describe('ElasticQueryBuilder', () => { it('with derivative doc count', () => { const query = builder.build({ + refId: 'A', metrics: [ { id: '3', type: 'count', - field: 'select field', }, { id: '2', type: 'derivative', - pipelineAgg: '3', + field: '3', }, ], bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }], @@ -374,6 +394,7 @@ describe('ElasticQueryBuilder', () => { it('with bucket_script', () => { const query = builder.build({ + refId: 'A', metrics: [ { id: '1', @@ -386,9 +407,7 @@ describe('ElasticQueryBuilder', () => { field: '@value', }, { - field: 'select field', id: '4', - meta: {}, pipelineVariables: [ { name: 'var1', @@ -417,16 +436,14 @@ describe('ElasticQueryBuilder', () => { it('with bucket_script doc count', () => { const query = builder.build({ + refId: 'A', metrics: [ { id: '3', type: 'count', - field: 'select field', }, { - field: 'select field', id: '4', - meta: {}, pipelineVariables: [ { name: 'var1', @@ -451,28 +468,32 @@ describe('ElasticQueryBuilder', () => { it('with histogram', () => { const query = builder.build({ + refId: 'A', metrics: [{ id: '1', type: 'count' }], bucketAggs: [ { type: 'histogram', field: 'bytes', id: '3', - settings: { interval: 10, min_doc_count: 2, missing: 5 }, + settings: { + interval: '10', + min_doc_count: '2', + }, }, ], }); const firstLevel = query.aggs['3']; expect(firstLevel.histogram.field).toBe('bytes'); - expect(firstLevel.histogram.interval).toBe(10); - expect(firstLevel.histogram.min_doc_count).toBe(2); - expect(firstLevel.histogram.missing).toBe(5); + expect(firstLevel.histogram.interval).toBe('10'); + expect(firstLevel.histogram.min_doc_count).toBe('2'); }); it('with adhoc filters', () => { const query = builder.build( { - metrics: [{ type: 'Count', id: '0' }], + refId: 'A', + metrics: [{ type: 'count', id: '0' }], timeField: '@timestamp', bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }], }, @@ -541,7 +562,7 @@ describe('ElasticQueryBuilder', () => { describe('getLogsQuery', () => { it('should return query with defaults', () => { - const query = builder.getLogsQuery({}, null, '*'); + const query = builder.getLogsQuery({ refId: 'A' }, null, '*'); expect(query.size).toEqual(500); @@ -555,7 +576,9 @@ describe('ElasticQueryBuilder', () => { expect(query.sort).toEqual({ '@timestamp': { order: 'desc', unmapped_type: 'boolean' } }); const expectedAggs = { - 2: { + // FIXME: It's pretty weak to include this '1' in the test as it's not part of what we are testing here and + // might change as a cause of unrelated changes + 1: { aggs: {}, date_histogram: { extended_bounds: { max: '$timeTo', min: '$timeFrom' }, @@ -570,7 +593,7 @@ describe('ElasticQueryBuilder', () => { }); it('with querystring', () => { - const query = builder.getLogsQuery({ query: 'foo' }, null, 'foo'); + const query = builder.getLogsQuery({ refId: 'A', query: 'foo' }, null, 'foo'); const expectedQuery = { bool: { @@ -584,6 +607,7 @@ describe('ElasticQueryBuilder', () => { }); it('with adhoc filters', () => { + // TODO: Types for AdHocFilters const adhocFilters = [ { key: 'key1', operator: '=', value: 'value1' }, { key: 'key2', operator: '!=', value: 'value2' }, @@ -592,7 +616,7 @@ describe('ElasticQueryBuilder', () => { { key: 'key5', operator: '=~', value: 'value5' }, { key: 'key6', operator: '!~', value: 'value6' }, ]; - const query = builder.getLogsQuery({}, adhocFilters, '*'); + const query = builder.getLogsQuery({ refId: 'A' }, adhocFilters, '*'); expect(query.query.bool.must[0].match_phrase['key1'].query).toBe('value1'); expect(query.query.bool.must_not[0].match_phrase['key2'].query).toBe('value2'); diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts b/public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts index 59e213e9cc2..ac77d82c126 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts @@ -1,114 +1,9 @@ -import * as queryDef from '../query_def'; +import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths } from '../query_def'; describe('ElasticQueryDef', () => { - describe('getAncestors', () => { - describe('with multiple pipeline aggs', () => { - const maxMetric = { id: '1', type: 'max', field: '@value' }; - const derivativeMetric = { id: '2', type: 'derivative', field: '1' }; - const bucketScriptMetric = { - id: '3', - type: 'bucket_script', - field: '2', - pipelineVariables: [{ name: 'var1', pipelineAgg: '2' }], - }; - const target = { - refId: '1', - isLogsQuery: false, - metrics: [maxMetric, derivativeMetric, bucketScriptMetric], - }; - test('should return id of derivative and bucket_script', () => { - const response = queryDef.getAncestors(target, derivativeMetric); - expect(response).toEqual(['2', '3']); - }); - test('should return id of the bucket_script', () => { - const response = queryDef.getAncestors(target, bucketScriptMetric); - expect(response).toEqual(['3']); - }); - test('should return id of all the metrics', () => { - const response = queryDef.getAncestors(target, maxMetric); - expect(response).toEqual(['1', '2', '3']); - }); - }); - }); - - describe('getPipelineAggOptions', () => { - describe('with zero metrics', () => { - const target = { - refId: '1', - isLogsQuery: false, - metrics: [], - }; - const response = queryDef.getPipelineAggOptions(target); - - test('should return zero', () => { - expect(response.length).toBe(0); - }); - }); - - describe('with count and sum metrics', () => { - const currentAgg = { type: 'moving_avg', field: '@value', id: '3' }; - const target = { - refId: '1', - isLogsQuery: false, - metrics: [{ type: 'count', field: '@value', id: '1' }, { type: 'sum', field: '@value', id: '2' }, currentAgg], - }; - - const response = queryDef.getPipelineAggOptions(target, currentAgg); - - test('should return zero', () => { - expect(response.length).toBe(2); - }); - }); - - describe('with count and moving average metrics', () => { - const currentAgg = { type: 'moving_avg', field: '@value', id: '2' }; - const target = { - refId: '1', - isLogsQuery: false, - metrics: [{ type: 'count', field: '@value', id: '1' }, currentAgg], - }; - - const response = queryDef.getPipelineAggOptions(target, currentAgg); - - test('should return one', () => { - expect(response.length).toBe(1); - }); - }); - - describe('with multiple chained pipeline aggs', () => { - const currentAgg = { type: 'moving_avg', field: '2', id: '3' }; - const target = { - refId: '1', - isLogsQuery: false, - metrics: [{ type: 'count', field: '@value', id: '1' }, { type: 'moving_avg', field: '1', id: '2' }, currentAgg], - }; - - const response = queryDef.getPipelineAggOptions(target, currentAgg); - - test('should return two', () => { - expect(response.length).toBe(2); - }); - }); - - describe('with derivatives metrics', () => { - const currentAgg = { type: 'derivative', field: '@value', id: '1' }; - const target = { - refId: '1', - isLogsQuery: false, - metrics: [currentAgg], - }; - - const response = queryDef.getPipelineAggOptions(target, currentAgg); - - test('should return zero', () => { - expect(response.length).toBe(0); - }); - }); - }); - describe('isPipelineMetric', () => { describe('moving_avg', () => { - const result = queryDef.isPipelineAgg('moving_avg'); + const result = isPipelineAgg('moving_avg'); test('is pipe line metric', () => { expect(result).toBe(true); @@ -116,7 +11,7 @@ describe('ElasticQueryDef', () => { }); describe('count', () => { - const result = queryDef.isPipelineAgg('count'); + const result = isPipelineAgg('count'); test('is not pipe line metric', () => { expect(result).toBe(false); @@ -126,7 +21,7 @@ describe('ElasticQueryDef', () => { describe('isPipelineAggWithMultipleBucketPaths', () => { describe('bucket_script', () => { - const result = queryDef.isPipelineAggWithMultipleBucketPaths('bucket_script'); + const result = isPipelineAggWithMultipleBucketPaths('bucket_script'); test('should have multiple bucket paths support', () => { expect(result).toBe(true); @@ -134,50 +29,11 @@ describe('ElasticQueryDef', () => { }); describe('moving_avg', () => { - const result = queryDef.isPipelineAggWithMultipleBucketPaths('moving_avg'); + const result = isPipelineAggWithMultipleBucketPaths('moving_avg'); test('should not have multiple bucket paths support', () => { expect(result).toBe(false); }); }); }); - - describe('pipeline aggs depending on esverison', () => { - describe('using esversion undefined', () => { - test('should not get pipeline aggs', () => { - expect(queryDef.getMetricAggTypes(undefined).length).toBe(11); - }); - }); - - describe('using esversion 1', () => { - test('should not get pipeline aggs', () => { - expect(queryDef.getMetricAggTypes(1).length).toBe(11); - }); - }); - - describe('using esversion 2', () => { - test('should get pipeline aggs', () => { - expect(queryDef.getMetricAggTypes(2).length).toBe(15); - }); - }); - - describe('using esversion 5', () => { - const metricAggTypes = queryDef.getMetricAggTypes(5); - test('should get pipeline aggs', () => { - expect(metricAggTypes.length).toBe(15); - }); - }); - describe('using esversion 70', () => { - const metricAggTypes = queryDef.getMetricAggTypes(70); - test('should get pipeline aggs', () => { - expect(metricAggTypes.length).toBe(15); - }); - test('should get pipeline aggs with moving function', () => { - expect(metricAggTypes.some(m => m.value === 'moving_fn')).toBeTruthy(); - }); - test('should get pipeline aggs without moving average', () => { - expect(metricAggTypes.some(m => m.value === 'moving_avg')).toBeFalsy(); - }); - }); - }); }); diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts index 8d58da2456a..5a92902ab94 100644 --- a/public/app/plugins/datasource/elasticsearch/types.ts +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -1,4 +1,12 @@ import { DataQuery, DataSourceJsonData } from '@grafana/data'; +import { + BucketAggregation, + BucketAggregationType, +} from './components/QueryEditor/BucketAggregationsEditor/aggregations'; +import { + MetricAggregation, + MetricAggregationType, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; export interface ElasticsearchOptions extends DataSourceJsonData { timeField: string; @@ -11,20 +19,50 @@ export interface ElasticsearchOptions extends DataSourceJsonData { dataLinks?: DataLinkConfig[]; } +interface MetricConfiguration { + label: string; + requiresField: boolean; + supportsInlineScript: boolean; + supportsMissing: boolean; + isPipelineAgg: boolean; + minVersion?: number; + maxVersion?: number; + supportsMultipleBucketPaths: boolean; + isSingleMetric?: boolean; + hasSettings: boolean; + hasMeta: boolean; + defaults: Omit, 'id' | 'type'>; +} + +type BucketConfiguration = { + label: string; + requiresField: boolean; + defaultSettings: Extract['settings']; +}; + +export type MetricsConfiguration = { + [P in MetricAggregationType]: MetricConfiguration

; +}; + +export type BucketsConfiguration = { + [P in BucketAggregationType]: BucketConfiguration

; +}; + export interface ElasticsearchAggregation { id: string; - type: string; - settings?: any; + type: MetricAggregationType | BucketAggregationType; + settings?: unknown; field?: string; - pipelineVariables?: Array<{ name?: string; pipelineAgg?: string }>; + hide: boolean; } export interface ElasticsearchQuery extends DataQuery { - isLogsQuery: boolean; + isLogsQuery?: boolean; alias?: string; query?: string; - bucketAggs?: ElasticsearchAggregation[]; - metrics?: ElasticsearchAggregation[]; + bucketAggs?: BucketAggregation[]; + metrics?: MetricAggregation[]; + timeField?: string; } export type DataLinkConfig = { diff --git a/public/app/plugins/datasource/elasticsearch/utils.test.ts b/public/app/plugins/datasource/elasticsearch/utils.test.ts new file mode 100644 index 00000000000..22f396b3c18 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/utils.test.ts @@ -0,0 +1,36 @@ +import { removeEmpty } from './utils'; + +describe('removeEmpty', () => { + it('Should remove all empty', () => { + const original = { + stringsShouldBeKept: 'Something', + unlessTheyAreEmpty: '', + nullToBeRemoved: null, + undefinedToBeRemoved: null, + zeroShouldBeKept: 0, + booleansShouldBeKept: false, + emptyObjectsShouldBeRemoved: {}, + emptyArrayShouldBeRemoved: [], + nonEmptyArraysShouldBeKept: [1, 2, 3], + nestedObjToBeRemoved: { + toBeRemoved: undefined, + }, + nestedObjectToKeep: { + thisShouldBeRemoved: null, + thisShouldBeKept: 'Hello, Grafana', + }, + }; + + const expectedResult = { + stringsShouldBeKept: 'Something', + zeroShouldBeKept: 0, + booleansShouldBeKept: false, + nonEmptyArraysShouldBeKept: [1, 2, 3], + nestedObjectToKeep: { + thisShouldBeKept: 'Hello, Grafana', + }, + }; + + expect(removeEmpty(original)).toStrictEqual(expectedResult); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/utils.ts b/public/app/plugins/datasource/elasticsearch/utils.ts new file mode 100644 index 00000000000..a76563e8cb3 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/utils.ts @@ -0,0 +1,54 @@ +import { + isMetricAggregationWithField, + MetricAggregation, +} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; +import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; + +export const describeMetric = (metric: MetricAggregation) => { + if (!isMetricAggregationWithField(metric)) { + return metricAggregationConfig[metric.type].label; + } + + // TODO: field might be undefined + return `${metricAggregationConfig[metric.type].label} ${metric.field}`; +}; + +/** + * Utility function to clean up aggregations settings objects. + * It removes nullish values and empty strings, array and objects + * recursing over nested objects (not arrays). + * @param obj + */ +export const removeEmpty = (obj: T): Partial => + Object.entries(obj).reduce((acc, [key, value]) => { + // Removing nullish values (null & undefined) + if (value == null) { + return { ...acc }; + } + + // Removing empty arrays (This won't recurse the array) + if (Array.isArray(value) && value.length === 0) { + return { ...acc }; + } + + // Removing empty strings + if (value?.length === 0) { + return { ...acc }; + } + + // Recursing over nested objects + if (!Array.isArray(value) && typeof value === 'object') { + const cleanObj = removeEmpty(value); + + if (Object.keys(cleanObj).length === 0) { + return { ...acc }; + } + + return { ...acc, [key]: cleanObj }; + } + + return { + ...acc, + [key]: value, + }; + }, {}); diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 92f5bbe4ea4..c1b402c0cd3 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -117,8 +117,6 @@ export const zoomOut = eventFactory('zoom-out'); export const shiftTime = eventFactory('shift-time'); -export const elasticQueryUpdated = eventFactory('elastic-query-updated'); - export const routeUpdated = eventFactory('$routeUpdate'); /**