Elasticsearch: Migrate queryeditor to React (#28033)

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
pull/29622/head
Giordano Ricci 5 years ago committed by GitHub
parent 3d6380a0aa
commit bb45f5fedc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx
  2. 231
      public/app/plugins/datasource/elasticsearch/bucket_agg.ts
  3. 42
      public/app/plugins/datasource/elasticsearch/components/AddRemove.test.tsx
  4. 28
      public/app/plugins/datasource/elasticsearch/components/AddRemove.tsx
  5. 85
      public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx
  6. 33
      public/app/plugins/datasource/elasticsearch/components/IconButton.tsx
  7. 34
      public/app/plugins/datasource/elasticsearch/components/MetricPicker.tsx
  8. 76
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/BucketAggregationEditor.tsx
  9. 81
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/index.tsx
  10. 16
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/actions.ts
  11. 52
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.test.ts
  12. 21
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/reducer.ts
  13. 22
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/state/types.ts
  14. 3
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/FiltersSettingsEditor/utils.ts
  15. 160
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/index.tsx
  16. 89
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/useDescription.ts
  17. 68
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts
  18. 37
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/index.tsx
  19. 61
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/actions.ts
  20. 143
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts
  21. 110
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts
  22. 51
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/types.ts
  23. 79
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/utils.ts
  24. 59
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.test.tsx
  25. 63
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx
  26. 120
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx
  27. 98
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/index.tsx
  28. 34
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/actions.ts
  29. 102
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.test.ts
  30. 46
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/reducer.ts
  31. 34
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/state/types.ts
  32. 3
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/BucketScriptSettingsEditor/utils.ts
  33. 178
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/MovingAverageSettingsEditor.tsx
  34. 39
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx
  35. 129
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx
  36. 40
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/useDescription.ts
  37. 343
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts
  38. 40
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/index.tsx
  39. 96
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts
  40. 222
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts
  41. 149
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts
  42. 89
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/types.ts
  43. 16
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/styles.ts
  44. 261
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/utils.ts
  45. 70
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryEditorRow.tsx
  46. 52
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/SettingsEditorContainer.tsx
  47. 52
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx
  48. 43
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts
  49. 61
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts
  50. 5
      public/app/plugins/datasource/elasticsearch/components/QueryEditor/styles.ts
  51. 4
      public/app/plugins/datasource/elasticsearch/components/types.ts
  52. 4
      public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx
  53. 165
      public/app/plugins/datasource/elasticsearch/datasource.test.ts
  54. 105
      public/app/plugins/datasource/elasticsearch/datasource.ts
  55. 109
      public/app/plugins/datasource/elasticsearch/elastic_response.ts
  56. 28
      public/app/plugins/datasource/elasticsearch/hooks/useNextId.test.tsx
  57. 18
      public/app/plugins/datasource/elasticsearch/hooks/useNextId.ts
  58. 69
      public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.test.tsx
  59. 45
      public/app/plugins/datasource/elasticsearch/hooks/useStatelessReducer.ts
  60. 253
      public/app/plugins/datasource/elasticsearch/metric_agg.ts
  61. 4
      public/app/plugins/datasource/elasticsearch/module.ts
  62. 239
      public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html
  63. 161
      public/app/plugins/datasource/elasticsearch/partials/metric_agg.html
  64. 46
      public/app/plugins/datasource/elasticsearch/partials/pipeline_variables.html
  65. 31
      public/app/plugins/datasource/elasticsearch/partials/query.editor.html
  66. 46
      public/app/plugins/datasource/elasticsearch/pipeline_variables.ts
  67. 97
      public/app/plugins/datasource/elasticsearch/query_builder.ts
  68. 118
      public/app/plugins/datasource/elasticsearch/query_ctrl.ts
  69. 330
      public/app/plugins/datasource/elasticsearch/query_def.ts
  70. 57
      public/app/plugins/datasource/elasticsearch/specs/elastic_response.test.ts
  71. 92
      public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts
  72. 154
      public/app/plugins/datasource/elasticsearch/specs/query_def.test.ts
  73. 50
      public/app/plugins/datasource/elasticsearch/types.ts
  74. 36
      public/app/plugins/datasource/elasticsearch/utils.test.ts
  75. 54
      public/app/plugins/datasource/elasticsearch/utils.ts
  76. 2
      public/app/types/events.ts

@ -14,7 +14,6 @@ export const useExpandableLabel = (
const Label: React.FC<LabelProps> = ({ Component, onClick }) => (
<div
className="gf-form"
ref={ref}
onClick={() => {
setExpanded(true);

@ -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);

@ -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) => (
<AddRemove key={index} elements={items} index={index} onAdd={noop} onRemove={noop} />
))}
</>
);
describe('AddRemove Button', () => {
describe("When There's only one element in the list", () => {
it('Should only show the add button', () => {
render(<TestComponent items={['something']} />);
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(<TestComponent items={items} />);
expect(screen.getAllByText('remove')).toHaveLength(items.length);
});
it('Should show the add button only once', () => {
const items = ['something', 'something else'];
render(<TestComponent items={items} />);
expect(screen.getAllByText('add')).toHaveLength(1);
});
});
});

@ -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<Props> = ({ index, onAdd, onRemove, elements }) => {
return (
<div
className={css`
display: flex;
`}
>
{index === 0 && <IconButton iconName="plus" onClick={onAdd} label="add" />}
{elements.length >= 2 && <IconButton iconName="minus" onClick={onRemove} label="remove" />}
</div>
);
};

@ -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<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions> {}
interface State {
syntaxLoaded: boolean;
}
class ElasticsearchQueryField extends React.PureComponent<Props, State> {
plugins: any[];
constructor(props: Props, context: React.Context<any>) {
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 (
<>
<div className="gf-form-inline gf-form-inline--nowrap">
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
additionalPlugins={this.plugins}
query={query.query}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
placeholder="Enter a Lucene query (run with Shift+Enter)"
portalOrigin="elasticsearch"
syntaxLoaded={syntaxLoaded}
/>
</div>
</div>
</>
);
}
}
export default ElasticsearchQueryField;

@ -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<typeof Icon>['name'];
onClick: () => void;
className?: string;
label: string;
}
export const IconButton: FunctionComponent<Props & ButtonHTMLAttributes<HTMLButtonElement>> = ({
iconName,
onClick,
className,
label,
...buttonProps
}) => (
<button className={cx('gf-form-label gf-form-label--btn query-part', className)} onClick={onClick} {...buttonProps}>
<span className={SROnly}>{label}</span>
<Icon name={iconName} aria-hidden="true" />
</button>
);

@ -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<SelectableValue<MetricAggregation>> => metrics.map(toOption);
interface Props {
options: MetricAggregation[];
onChange: (e: SelectableValue<MetricAggregation>) => void;
className?: string;
value?: string;
}
export const MetricPicker: FunctionComponent<Props> = ({ options, onChange, className, value }) => (
<Segment
className={cx(className, noWrap)}
options={toOptions(options)}
onChange={onChange}
placeholder="Select Metric"
value={!!value ? toOption(options.find(option => option.id === value)!) : null}
/>
);

@ -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<SelectableValue<BucketAggregationType>> = Object.entries(bucketAggregationConfig).map(
([key, { label }]) => ({
label,
value: key as BucketAggregationType,
})
);
const toSelectableValue = ({ value, text }: MetricFindValue): SelectableValue<string> => ({
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<QueryMetricEditorProps> = ({ value }) => {
const datasource = useDatasource();
const dispatch = useDispatch<BucketAggregationAction>();
// 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 (
<>
<Segment
className={segmentStyles}
options={bucketAggOptions}
onChange={e => dispatch(changeBucketAggregationType(value.id, e.value!))}
value={toOption(value)}
/>
{isBucketAggregationWithField(value) && (
<SegmentAsync
className={segmentStyles}
loadOptions={getFields}
onChange={e => dispatch(changeBucketAggregationField(value.id, e.value))}
placeholder="Select Field"
value={value.field}
/>
)}
<SettingsEditor bucketAgg={value} />
</>
);
};

@ -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<Props> = ({ value }) => {
const upperStateDispatch = useDispatch<BucketAggregationAction<Filters>>();
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 (
<>
<div
className={css`
display: flex;
flex-direction: column;
`}
>
{value.settings?.filters!.map((filter, index) => (
<div
key={index}
className={css`
display: flex;
`}
>
<div
className={css`
width: 250px;
`}
>
<InlineField label="Query" labelWidth={10}>
<QueryField
placeholder="Lucene Query"
portalOrigin="elasticsearch"
onBlur={() => {}}
onChange={query => dispatch(changeFilter(index, { ...filter, query }))}
query={filter.query}
/>
</InlineField>
</div>
<InlineField label="Label" labelWidth={10}>
<Input
placeholder="Label"
onBlur={e => dispatch(changeFilter(index, { ...filter, label: e.target.value }))}
defaultValue={filter.label}
/>
</InlineField>
<AddRemove
index={index}
elements={value.settings?.filters || []}
onAdd={() => dispatch(addFilter())}
onRemove={() => dispatch(removeFilter(index))}
/>
</div>
))}
</div>
</>
);
};

@ -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 },
});

@ -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]);
});
});

@ -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;
});
}
};

@ -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<typeof ADD_FILTER>;
export interface RemoveFilterAction extends Action<typeof REMOVE_FILTER> {
payload: {
index: number;
};
}
export interface ChangeFilterAction extends Action<typeof CHANGE_FILTER> {
payload: {
index: number;
filter: Filter;
};
}
export type FilterAction = AddFilterAction | RemoveFilterAction | ChangeFilterAction;

@ -0,0 +1,3 @@
import { Filter } from '../../aggregations';
export const defaultFilter = (): Filter => ({ label: '', query: '*' });

@ -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<ComponentProps<typeof InlineField>> = {
labelWidth: 16,
};
interface Props {
bucketAgg: BucketAggregation;
}
export const SettingsEditor: FunctionComponent<Props> = ({ bucketAgg }) => {
const dispatch = useDispatch();
const { metrics } = useQuery();
const settingsDescription = useDescription(bucketAgg);
const orderBy = [...orderByOptions, ...(metrics || []).map(m => ({ label: describeMetric(m), value: m.id }))];
return (
<SettingsEditorContainer label={settingsDescription}>
{bucketAgg.type === 'terms' && (
<>
<InlineField label="Order" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'order', e.value!))}
options={orderOptions}
value={bucketAgg.settings?.order || bucketAggregationConfig[bucketAgg.type].defaultSettings?.order}
/>
</InlineField>
<InlineField label="Size" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'size', e.value!))}
options={sizeOptions}
value={bucketAgg.settings?.size || bucketAggregationConfig[bucketAgg.type].defaultSettings?.size}
allowCustomValue
/>
</InlineField>
<InlineField label="Min Doc Count" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))}
defaultValue={
bucketAgg.settings?.min_doc_count ||
bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count
}
/>
</InlineField>
<InlineField label="Order By" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'orderBy', e.value!))}
options={orderBy}
value={bucketAgg.settings?.orderBy || bucketAggregationConfig[bucketAgg.type].defaultSettings?.orderBy}
/>
</InlineField>
<InlineField label="Missing" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'missing', e.target.value!))}
defaultValue={
bucketAgg.settings?.missing || bucketAggregationConfig[bucketAgg.type].defaultSettings?.missing
}
/>
</InlineField>
</>
)}
{bucketAgg.type === 'geohash_grid' && (
<InlineField label="Precision" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'precision', e.target.value!))}
defaultValue={
bucketAgg.settings?.precision || bucketAggregationConfig[bucketAgg.type].defaultSettings?.precision
}
/>
</InlineField>
)}
{bucketAgg.type === 'date_histogram' && (
<>
<InlineField label="Interval" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'interval', e.value!))}
options={intervalOptions}
value={bucketAgg.settings?.interval || bucketAggregationConfig[bucketAgg.type].defaultSettings?.interval}
allowCustomValue
/>
</InlineField>
<InlineField label="Min Doc Count" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))}
defaultValue={
bucketAgg.settings?.min_doc_count ||
bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count
}
/>
</InlineField>
<InlineField label="Trim Edges" {...inlineFieldProps} tooltip="Trim the edges on the timeseries datapoints">
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'trimEdges', e.target.value!))}
defaultValue={
bucketAgg.settings?.trimEdges || bucketAggregationConfig[bucketAgg.type].defaultSettings?.trimEdges
}
/>
</InlineField>
<InlineField
label="Offset"
{...inlineFieldProps}
tooltip="Change the start value of each bucket by the specified positive (+) or negative offset (-) duration, such as 1h for an hour, or 1d for a day"
>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'offset', e.target.value!))}
defaultValue={
bucketAgg.settings?.offset || bucketAggregationConfig[bucketAgg.type].defaultSettings?.offset
}
/>
</InlineField>
</>
)}
{bucketAgg.type === 'histogram' && (
<>
<InlineField label="Interval" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'interval', e.target.value!))}
defaultValue={
bucketAgg.settings?.interval || bucketAggregationConfig[bucketAgg.type].defaultSettings?.interval
}
/>
</InlineField>
<InlineField label="Min Doc Count" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))}
defaultValue={
bucketAgg.settings?.min_doc_count ||
bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count
}
/>
</InlineField>
</>
)}
{bucketAgg.type === 'filters' && <FiltersSettingsEditor value={bucketAgg} />}
</SettingsEditorContainer>
);
};

@ -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';
}
};

@ -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<string, unknown>;
}
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;

@ -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<Props> = ({ nextId }) => {
const dispatch = useDispatch<BucketAggregationAction>();
const { bucketAggs } = useQuery();
const totalBucketAggs = bucketAggs?.length || 0;
return (
<>
{bucketAggs!.map((bucketAgg, index) => (
<QueryEditorRow
key={bucketAgg.id}
label={index === 0 ? 'Group By' : 'Then By'}
onRemoveClick={totalBucketAggs > 1 && (() => dispatch(removeBucketAggregation(bucketAgg.id)))}
>
<BucketAggregationEditor value={bucketAgg} />
{index === 0 && (
<IconButton iconName="plus" onClick={() => dispatch(addBucketAggregation(nextId))} label="add" />
)}
</QueryEditorRow>
))}
</>
);
};

@ -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 = <T extends BucketAggregation, K extends SettingKeyOf<T>>(
bucketAgg: T,
settingName: K,
// This could be inferred from T, but it's causing some troubles
newValue: string | string[] | any
): ChangeBucketAggregationSettingAction<T> => ({
type: CHANGE_BUCKET_AGG_SETTING,
payload: {
bucketAgg,
settingName,
newValue,
},
});

@ -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]);
});
});

@ -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;
}
};

@ -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<typeof ADD_BUCKET_AGG> {
payload: {
id: BucketAggregation['id'];
};
}
export interface RemoveBucketAggregationAction extends Action<typeof REMOVE_BUCKET_AGG> {
payload: {
id: BucketAggregation['id'];
};
}
export interface ChangeBucketAggregationTypeAction extends Action<typeof CHANGE_BUCKET_AGG_TYPE> {
payload: {
id: BucketAggregation['id'];
newType: BucketAggregation['type'];
};
}
export interface ChangeBucketAggregationFieldAction extends Action<typeof CHANGE_BUCKET_AGG_FIELD> {
payload: {
id: BucketAggregation['id'];
newField: BucketAggregationWithField['field'];
};
}
export interface ChangeBucketAggregationSettingAction<T extends BucketAggregation>
extends Action<typeof CHANGE_BUCKET_AGG_SETTING> {
payload: {
bucketAgg: T;
settingName: SettingKeyOf<T>;
newValue: unknown;
};
}
export type BucketAggregationAction<T extends BucketAggregation = BucketAggregation> =
| AddBucketAggregationAction
| RemoveBucketAggregationAction
| ChangeBucketAggregationTypeAction
| ChangeBucketAggregationFieldAction
| ChangeBucketAggregationSettingAction<T>;

@ -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' },
];

@ -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 }) => (
<ElasticsearchProvider datasource={{} as ElasticDatasource} query={query} onChange={() => {}}>
{children}
</ElasticsearchProvider>
);
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 }) => (
<ElasticsearchProvider datasource={datasource} query={query} onChange={() => {}}>
{children}
</ElasticsearchProvider>
);
const { result } = renderHook(() => useDatasource(), {
wrapper,
});
expect(result.current).toBe(datasource);
});
});
});

@ -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<ElasticDatasource | undefined>(undefined);
const QueryContext = createContext<ElasticsearchQuery | undefined>(undefined);
interface Props {
query: ElasticsearchQuery;
onChange: (query: ElasticsearchQuery) => void;
datasource: ElasticDatasource;
}
export const ElasticsearchProvider: FunctionComponent<Props> = ({ 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 (
<DatasourceContext.Provider value={datasource}>
<QueryContext.Provider value={query}>
<DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
</QueryContext.Provider>
</DatasourceContext.Provider>
);
};
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;
};

@ -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<string> => ({
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<SelectableValue<MetricAggregationType>> => {
// 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<Props> = ({ value }) => {
const styles = getStyles(useTheme(), !!value.hide);
const datasource = useDatasource();
const query = useQuery();
const dispatch = useDispatch<MetricAggregationAction>();
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 (
<>
<Segment
className={cx(styles.color, segmentStyles)}
options={getTypeOptions(previousMetrics, datasource.esVersion)}
onChange={e => dispatch(changeMetricType(value.id, e.value!))}
value={toOption(value)}
/>
{isMetricAggregationWithField(value) && !isPipelineAggregation(value) && (
<SegmentAsync
className={cx(styles.color, segmentStyles)}
loadOptions={getFields}
onChange={e => dispatch(changeMetricField(value.id, e.value!))}
placeholder="Select Field"
value={value.field}
/>
)}
{isPipelineAggregation(value) && !isPipelineAggregationWithMultipleBucketPaths(value) && (
<MetricPicker
className={cx(styles.color, segmentStyles)}
onChange={e => dispatch(changeMetricField(value.id, e.value?.id!))}
options={previousMetrics}
value={value.field}
/>
)}
{isMetricAggregationWithSettings(value) && <SettingsEditor metric={value} previousMetrics={previousMetrics} />}
</>
);
};

@ -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<Props> = ({ value, previousMetrics }) => {
const upperStateDispatch = useDispatch<MetricAggregationAction<BucketScript>>();
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 (
<>
<div
className={css`
display: flex;
`}
>
<InlineLabel width={16}>Variables</InlineLabel>
<div
className={css`
display: grid;
grid-template-columns: 1fr auto;
row-gap: 4px;
margin-bottom: 4px;
`}
>
{value.pipelineVariables!.map((pipelineVar, index) => (
<Fragment key={pipelineVar.name}>
<div
className={css`
display: grid;
column-gap: 4px;
grid-template-columns: auto auto;
`}
>
<Input
defaultValue={pipelineVar.name}
placeholder="Variable Name"
onBlur={e => dispatch(renamePipelineVariable(e.target.value, index))}
/>
<MetricPicker
onChange={e => dispatch(changePipelineVariableMetric(e.value!.id, index))}
options={previousMetrics}
value={pipelineVar.pipelineAgg}
/>
</div>
<AddRemove
index={index}
elements={value.pipelineVariables || []}
onAdd={() => dispatch(addPipelineVariable())}
onRemove={() => dispatch(removePipelineVariable(index))}
/>
</Fragment>
))}
</div>
</div>
<SettingField
label="Script"
metric={value}
settingName="script"
tooltip="Elasticsearch v5.0 and above: Scripting language is Painless. Use params.<var> to reference a variable. Elasticsearch pre-v5.0: Scripting language is per default Groovy if not changed. For Groovy use <var> to reference a variable."
placeholder="params.var1 / params.var2"
/>
</>
);
};

@ -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,
},
});

@ -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);
});
});

@ -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;
}
};

@ -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<typeof ADD_PIPELINE_VARIABLE>;
export interface RemovePipelineVariableAction extends Action<typeof REMOVE_PIPELINE_VARIABLE> {
payload: {
index: number;
};
}
export interface RenamePipelineVariableAction extends Action<typeof RENAME_PIPELINE_VARIABLE> {
payload: {
index: number;
newName: string;
};
}
export interface ChangePipelineVariableMetricAction extends Action<typeof CHANGE_PIPELINE_VARIABLE_METRIC> {
payload: {
index: number;
newMetric: string;
};
}
export type PipelineVariablesAction =
| AddPipelineVariableAction
| RemovePipelineVariableAction
| RenamePipelineVariableAction
| ChangePipelineVariableMetricAction;

@ -0,0 +1,3 @@
import { PipelineVariable } from '../../aggregations';
export const defaultPipelineVariable = (): PipelineVariable => ({ name: 'var1', pipelineAgg: '' });

@ -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<Props> = ({ metric }) => {
const dispatch = useDispatch();
return (
<>
<InlineField label="Model">
<Select
onChange={value => dispatch(changeMetricSetting(metric, 'model', value.value!))}
options={movingAvgModelOptions}
value={metric.settings?.model}
/>
</InlineField>
<InlineField label="Window">
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'window', parseInt(e.target.value!, 10)))}
defaultValue={metric.settings?.window}
/>
</InlineField>
<InlineField label="Predict">
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'predict', parseInt(e.target.value!, 10)))}
defaultValue={metric.settings?.predict}
/>
</InlineField>
{isEWMAMovingAverage(metric) && (
<>
<InlineField label="Alpha">
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'alpha', parseInt(e.target.value!, 10)))}
defaultValue={metric.settings?.alpha}
/>
</InlineField>
<InlineField label="Minimize">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(changeMetricSetting(metric, 'minimize', e.target.checked))
}
checked={!!metric.settings?.minimize}
/>
</InlineField>
</>
)}
{isHoltMovingAverage(metric) && (
<>
<InlineField label="Alpha">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
alpha: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.alpha}
/>
</InlineField>
<InlineField label="Beta">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
beta: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.beta}
/>
</InlineField>
<InlineField label="Minimize">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(changeMetricSetting(metric, 'minimize', e.target.checked))
}
checked={!!metric.settings?.minimize}
/>
</InlineField>
</>
)}
{isHoltWintersMovingAverage(metric) && (
<>
<InlineField label="Alpha">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
alpha: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.alpha}
/>
</InlineField>
<InlineField label="Beta">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
beta: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.beta}
/>
</InlineField>
<InlineField label="Gamma">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
gamma: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.gamma}
/>
</InlineField>
<InlineField label="Period">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
period: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.period}
/>
</InlineField>
<InlineField label="Pad">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(
changeMetricSetting(metric, 'settings', { ...metric.settings?.settings, pad: e.target.checked })
)
}
checked={!!metric.settings?.settings?.pad}
/>
</InlineField>
<InlineField label="Minimize">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(changeMetricSetting(metric, 'minimize', e.target.checked))
}
checked={!!metric.settings?.minimize}
/>
</InlineField>
</>
)}
</>
);
};

@ -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<T extends MetricAggregationWithSettings, K extends SettingKeyOf<T>> {
label: string;
settingName: K;
metric: T;
placeholder?: ComponentProps<typeof Input>['placeholder'];
tooltip?: ComponentProps<typeof InlineField>['tooltip'];
}
export function SettingField<T extends MetricAggregationWithSettings, K extends SettingKeyOf<T>>({
label,
settingName,
metric,
placeholder,
tooltip,
}: Props<T, K>) {
const dispatch = useDispatch<ChangeMetricSettingAction<T>>();
const [id] = useState(uniqueId(`es-field-id-`));
const settings = metric.settings;
return (
<InlineField label={label} labelWidth={16} tooltip={tooltip}>
<Input
id={id}
placeholder={placeholder}
onBlur={e => dispatch(changeMetricSetting(metric, settingName, e.target.value as any))}
defaultValue={settings?.[settingName as keyof typeof settings]}
/>
</InlineField>
);
}

@ -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<ComponentProps<typeof InlineField>> = {
labelWidth: 16,
};
interface Props {
metric: MetricAggregation;
previousMetrics: MetricAggregation[];
}
export const SettingsEditor: FunctionComponent<Props> = ({ metric, previousMetrics }) => {
const dispatch = useDispatch();
const description = useDescription(metric);
return (
<SettingsEditorContainer label={description} hidden={metric.hide}>
{metric.type === 'derivative' && <SettingField label="Unit" metric={metric} settingName="unit" />}
{metric.type === 'cumulative_sum' && <SettingField label="Format" metric={metric} settingName="format" />}
{metric.type === 'moving_avg' && <MovingAverageSettingsEditor metric={metric} />}
{metric.type === 'moving_fn' && (
<>
<SettingField label="Window" metric={metric} settingName="window" />
<SettingField label="Script" metric={metric} settingName="script" />
<SettingField label="Shift" metric={metric} settingName="shift" />
</>
)}
{metric.type === 'bucket_script' && (
<BucketScriptSettingsEditor value={metric} previousMetrics={previousMetrics} />
)}
{(metric.type === 'raw_data' || metric.type === 'raw_document') && (
<InlineField label="Size" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'size', e.target.value))}
defaultValue={metric.settings?.size ?? metricAggregationConfig['raw_data'].defaults.settings?.size}
/>
</InlineField>
)}
{metric.type === 'cardinality' && (
<SettingField label="Precision Threshold" metric={metric} settingName="precision_threshold" />
)}
{metric.type === 'extended_stats' && (
<>
{extendedStats.map(stat => (
<ExtendedStatSetting
key={stat.value}
stat={stat}
onChange={checked => dispatch(changeMetricMeta(metric, stat.value, checked))}
value={
metric.meta?.[stat.value] !== undefined
? !!metric.meta?.[stat.value]
: !!metricAggregationConfig['extended_stats'].defaults.meta?.[stat.value]
}
/>
))}
<SettingField label="Sigma" metric={metric} settingName="sigma" placeholder="3" />
</>
)}
{metric.type === 'percentiles' && (
<InlineField label="Percentiles" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'percents', e.target.value.split(',').filter(Boolean)))}
defaultValue={
metric.settings?.percents || metricAggregationConfig['percentiles'].defaults.settings?.percents
}
placeholder="1,5,25,50,75,95,99"
/>
</InlineField>
)}
{isMetricAggregationWithInlineScript(metric) && (
<SettingField label="Script" metric={metric} settingName="script" placeholder="_value * 1" />
)}
{isMetricAggregationWithMissingSupport(metric) && (
<SettingField
label="Missing"
metric={metric}
settingName="missing"
tooltip="The missing parameter defines how documents that are missing a value should be treated. By default
they will be ignored but it is also possible to treat them as if they had a value"
/>
)}
</SettingsEditorContainer>
);
};
interface ExtendedStatSettingProps {
stat: ExtendedStat;
onChange: (checked: boolean) => void;
value: boolean;
}
const ExtendedStatSetting: FunctionComponent<ExtendedStatSettingProps> = ({ 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 (
<InlineField label={stat.label} {...inlineFieldProps} key={stat.value}>
<Switch id={id} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)} value={value} />
</InlineField>
);
};

@ -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';
}
};

@ -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<T extends MovingAverageModel = MovingAverageModel> = Partial<
Extract<
| MovingAverageSimpleModelSettings
| MovingAverageLinearModelSettings
| MovingAverageEWMAModelSettings
| MovingAverageHoltModelSettings
| MovingAverageHoltWintersModelSettings,
{ model: T }
>
>;
export interface MovingAverage<T extends MovingAverageModel = MovingAverageModel>
extends BasePipelineMetricAggregation {
type: 'moving_avg';
settings?: MovingAverageModelSettings<T>;
}
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);

@ -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<Props> = ({ nextId }) => {
const dispatch = useDispatch<MetricAggregationAction>();
const { metrics } = useQuery();
const totalMetrics = metrics?.length || 0;
return (
<>
{metrics?.map((metric, index) => (
<QueryEditorRow
key={metric.id}
label={`Metric (${metric.id})`}
hidden={metric.hide}
onHideClick={() => dispatch(toggleMetricVisibility(metric.id))}
onRemoveClick={totalMetrics > 1 && (() => dispatch(removeMetric(metric.id)))}
>
<MetricEditor value={metric} />
{!metricAggregationConfig[metric.type].isSingleMetric && index === 0 && (
<IconButton iconName="plus" onClick={() => dispatch(addMetric(nextId))} label="add" />
)}
</QueryEditorRow>
))}
</>
);
};

@ -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 = <T extends MetricAggregation, K extends Extract<keyof T, string>>(
metric: T,
attribute: K,
newValue: T[K]
): ChangeMetricAttributeAction<T> => ({
type: CHANGE_METRIC_ATTRIBUTE,
payload: {
metric,
attribute,
newValue,
},
});
export const changeMetricSetting = <T extends MetricAggregationWithSettings, K extends SettingKeyOf<T>>(
metric: T,
settingName: K,
// Maybe this could have been NonNullable<T['settings']>[K], but it doesn't seem to work really well
newValue: NonNullable<T['settings']>[K]
): ChangeMetricSettingAction<T> => ({
type: CHANGE_METRIC_SETTING,
payload: {
metric,
settingName,
newValue,
},
});
export const changeMetricMeta = <T extends MetricAggregationWithMeta>(
metric: T,
meta: Extract<keyof Required<T>['meta'], string>,
newValue: string | number | boolean
): ChangeMetricMetaAction<T> => ({
type: CHANGE_METRIC_META,
payload: {
metric,
meta,
newValue,
},
});

@ -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);
});
});

@ -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;
}
};

@ -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<typeof ADD_METRIC> {
payload: {
id: MetricAggregation['id'];
};
}
export interface RemoveMetricAction extends Action<typeof REMOVE_METRIC> {
payload: {
id: MetricAggregation['id'];
};
}
export interface ChangeMetricTypeAction extends Action<typeof CHANGE_METRIC_TYPE> {
payload: {
id: MetricAggregation['id'];
type: MetricAggregation['type'];
};
}
export interface ChangeMetricFieldAction extends Action<typeof CHANGE_METRIC_FIELD> {
payload: {
id: MetricAggregation['id'];
field: MetricAggregationWithField['field'];
};
}
export interface ToggleMetricVisibilityAction extends Action<typeof TOGGLE_METRIC_VISIBILITY> {
payload: {
id: MetricAggregation['id'];
};
}
export interface ChangeMetricSettingAction<T extends MetricAggregationWithSettings>
extends Action<typeof CHANGE_METRIC_SETTING> {
payload: {
metric: T;
settingName: SettingKeyOf<T>;
newValue: unknown;
};
}
export interface ChangeMetricMetaAction<T extends MetricAggregationWithMeta> extends Action<typeof CHANGE_METRIC_META> {
payload: {
metric: T;
meta: Extract<keyof Required<T>['meta'], string>;
newValue: string | number | boolean;
};
}
export interface ChangeMetricAttributeAction<
T extends MetricAggregation,
K extends Extract<keyof T, string> = Extract<keyof T, string>
> extends Action<typeof CHANGE_METRIC_ATTRIBUTE> {
payload: {
metric: T;
attribute: K;
newValue: T[K];
};
}
type CommonActions =
| AddMetricAction
| RemoveMetricAction
| ChangeMetricTypeAction
| ChangeMetricFieldAction
| ToggleMetricVisibilityAction;
export type MetricAggregationAction<T extends MetricAggregation = MetricAggregation> =
| (T extends MetricAggregationWithSettings ? ChangeMetricSettingAction<T> : never)
| (T extends MetricAggregationWithMeta ? ChangeMetricMetaAction<T> : never)
| ChangeMetricAttributeAction<T>
| CommonActions;

@ -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};
}
`,
}));

@ -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))];
};

@ -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<Props> = ({
children,
label,
onRemoveClick,
onHideClick,
hidden = false,
}) => {
const theme = useTheme();
const styles = getStyles(theme);
return (
<fieldset className={styles.root}>
<div className={getInlineLabelStyles(theme, 17).label}>
<legend className={styles.label}>{label}</legend>
{onHideClick && (
<IconButton
name={hidden ? 'eye-slash' : 'eye'}
onClick={onHideClick}
surface="header"
size="sm"
aria-pressed={hidden}
aria-label="hide metric"
className={styles.icon}
/>
)}
<IconButton
name="trash-alt"
surface="header"
size="sm"
className={styles.icon}
onClick={onRemoveClick || noop}
disabled={!onRemoveClick}
aria-label="remove metric"
/>
</div>
{children}
</fieldset>
);
};
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};
`,
};
});

@ -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<Props> = ({ label, children, hidden = false }) => {
const [open, setOpen] = useState(false);
const styles = getStyles(useTheme(), hidden);
return (
<div className={cx(styles.wrapper)}>
<button
className={cx('gf-form-label query-part', styles.button, segmentStyles)}
onClick={() => setOpen(!open)}
aria-expanded={open}
>
<Icon name={open ? 'angle-down' : 'angle-right'} aria-hidden="true" className={styles.icon} />
{label}
</button>
{open && <div className={styles.settingsWrapper}>{children}</div>}
</div>
);
};

@ -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<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions>;
export const QueryEditor: FunctionComponent<ElasticQueryEditorProps> = ({ query, onChange, datasource }) => (
<ElasticsearchProvider datasource={datasource} onChange={onChange} query={query}>
<QueryEditorForm value={query} />
</ElasticsearchProvider>
);
interface Props {
value: ElasticsearchQuery;
}
const QueryEditorForm: FunctionComponent<Props> = ({ value }) => {
const dispatch = useDispatch();
const nextId = useNextId();
return (
<>
<InlineFieldRow>
<InlineField label="Query" labelWidth={17} grow>
<QueryField
query={value.query}
// By default QueryField calls onChange if onBlur is not defined, this will trigger a rerender
// And slate will claim the focus, making it impossible to leave the field.
onBlur={() => {}}
onChange={query => dispatch(changeQuery(query))}
placeholder="Lucene Query"
portalOrigin="elasticsearch"
/>
</InlineField>
<InlineField label="Alias" labelWidth={15}>
<Input placeholder="Alias Pattern" onBlur={e => dispatch(changeAliasPattern(e.currentTarget.value))} />
</InlineField>
</InlineFieldRow>
<MetricAggregationsEditor nextId={nextId} />
<BucketAggregationsEditor nextId={nextId} />
</>
);
};

@ -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);
});
});

@ -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<typeof INIT> {}
interface ChangeQueryAction extends Action<typeof CHANGE_QUERY> {
payload: {
query: string;
};
}
interface ChangeAliasPatternAction extends Action<typeof CHANGE_ALIAS_PATTERN> {
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;
}
};

@ -0,0 +1,5 @@
import { css } from 'emotion';
export const segmentStyles = css`
min-width: 150px;
`;

@ -0,0 +1,4 @@
export type SettingKeyOf<T extends { settings?: Record<string, unknown> }> = Extract<
keyof NonNullable<T['settings']>,
string
>;

@ -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;

@ -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<ElasticsearchQuery> = {
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<ElasticsearchQuery>;
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<ElasticsearchQuery> = {
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<ElasticsearchQuery>;
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<ElasticsearchQuery> = {
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<ElasticsearchQuery>;
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<ElasticsearchQuery> = {
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<ElasticsearchQuery>;
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('*');
});
});

@ -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<ElasticsearchQuery, Elastic
header.index = this.indexPattern.getIndexList(options.range.from, options.range.to);
}
const payload = angular.toJson(header) + '\n' + angular.toJson(data) + '\n';
const payload = JSON.stringify(header) + '\n' + JSON.stringify(data) + '\n';
return this.post('_msearch', payload).then((res: any) => {
const list = [];
@ -325,7 +332,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
for (let bucketAgg of query.bucketAggs || []) {
if (bucketAgg.type === 'filters') {
for (let filter of bucketAgg.settings.filters) {
for (let filter of bucketAgg.settings?.filters || []) {
filter.query = this.interpolateLuceneQuery(filter.query, scopedVars);
}
}
@ -338,7 +345,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
testDatasource() {
// validate that the index exist and has date field
return this.getFields({ type: 'date' }).then(
return this.getFields('date').then(
(dateFields: any) => {
const timeField: any = _.find(dateFields, { text: this.timeField });
if (!timeField) {
@ -371,7 +378,58 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
queryHeader['max_concurrent_shard_requests'] = this.maxConcurrentShardRequests;
}
return angular.toJson(queryHeader);
return JSON.stringify(queryHeader);
}
getQueryDisplayText(query: ElasticsearchQuery) {
// TODO: This might be refactored a bit.
const metricAggs = query.metrics;
const bucketAggs = query.bucketAggs;
let text = '';
if (query.query) {
text += 'Query: ' + query.query + ', ';
}
text += 'Metrics: ';
text += metricAggs?.reduce((acc, metric) => {
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<ElasticsearchQuery>): Promise<DataQueryResponse> {
@ -388,8 +446,8 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
}
let queryObj;
if (target.isLogsQuery || queryDef.hasMetricOfType(target, 'logs')) {
target.bucketAggs = [queryDef.defaultBucketAgg()];
if (target.isLogsQuery || hasMetricOfType(target, 'logs')) {
target.bucketAggs = [defaultBucketAgg()];
target.metrics = [];
// Setting this for metrics queries that are typed as logs
target.isLogsQuery = true;
@ -402,7 +460,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
queryObj = this.queryBuilder.build(target, adhocFilters, target.query);
}
const esQuery = angular.toJson(queryObj);
const esQuery = JSON.stringify(queryObj);
const searchType = queryObj.size === 0 && this.esVersion < 5 ? 'count' : 'query_then_fetch';
const header = this.getQueryHeader(searchType, options.range.from, options.range.to);
@ -446,7 +504,8 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
return ELASTIC_META_FIELDS.includes(fieldName);
}
getFields(query: any) {
// TODO: instead of being a string, this could be a custom type representing all the elastic types
async getFields(type?: string): Promise<MetricFindValue[]> {
const configuredEsVersion = this.esVersion;
return this.get('/_mapping').then((result: any) => {
const typeMap: any = {
@ -462,17 +521,17 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
nested: 'nested',
};
const shouldAddField = (obj: any, key: string, query: any) => {
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<ElasticsearchQuery, Elastic
const fieldName = fieldNameParts.concat(key).join('.');
// Hide meta-fields and check field type
if (shouldAddField(subObj, key, query)) {
if (shouldAddField(subObj, key)) {
fields[fieldName] = {
text: fieldName,
type: subObj.type,
@ -537,7 +596,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
const range = this.timeSrv.timeRange();
const searchType = this.esVersion >= 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<ElasticsearchQuery, Elastic
return '_msearch';
}
metricFindQuery(query: any) {
query = angular.fromJson(query);
metricFindQuery(query: string): Promise<MetricFindValue[]> {
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<ElasticsearchQuery, Elastic
}
getTagKeys() {
return this.getFields({});
return this.getFields();
}
getTagValues(options: any) {

@ -10,30 +10,35 @@ import {
MutableDataFrame,
PreferredVisualisationType,
} from '@grafana/data';
import { ElasticsearchAggregation } from './types';
import { ElasticsearchAggregation, ElasticsearchQuery } from './types';
import {
ExtendedStatMetaType,
isMetricAggregationWithField,
} from './components/QueryEditor/MetricAggregationsEditor/aggregations';
import { describeMetric } from './utils';
import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils';
export class ElasticResponse {
constructor(private targets: any, private response: any) {
constructor(private targets: ElasticsearchQuery[], private response: any) {
this.targets = targets;
this.response = response;
}
processMetrics(esAgg: any, target: any, seriesList: any, props: any) {
let metric, y, i, bucket, value;
processMetrics(esAgg: any, target: ElasticsearchQuery, seriesList: any, props: any) {
let newSeries: any;
for (y = 0; y < target.metrics.length; y++) {
metric = target.metrics[y];
for (let y = 0; y < target.metrics!.length; y++) {
const metric = target.metrics![y];
if (metric.hide) {
continue;
}
switch (metric.type) {
case 'count': {
newSeries = { datapoints: [], metric: 'count', props: props, refId: target.refId };
for (i = 0; i < esAgg.buckets.length; i++) {
bucket = esAgg.buckets[i];
value = bucket.doc_count;
newSeries = { datapoints: [], metric: 'count', props, refId: target.refId };
for (let i = 0; i < esAgg.buckets.length; i++) {
const bucket = esAgg.buckets[i];
const value = bucket.doc_count;
newSeries.datapoints.push([value, bucket.key]);
}
seriesList.push(newSeries);
@ -56,8 +61,8 @@ export class ElasticResponse {
refId: target.refId,
};
for (i = 0; i < esAgg.buckets.length; i++) {
bucket = esAgg.buckets[i];
for (let i = 0; i < esAgg.buckets.length; i++) {
const bucket = esAgg.buckets[i];
const values = bucket[metric.id].values;
newSeries.datapoints.push([values[percentileName], bucket.key]);
}
@ -68,7 +73,7 @@ export class ElasticResponse {
}
case 'extended_stats': {
for (const statName in metric.meta) {
if (!metric.meta[statName]) {
if (!metric.meta[statName as ExtendedStatMetaType]) {
continue;
}
@ -80,8 +85,8 @@ export class ElasticResponse {
refId: target.refId,
};
for (i = 0; i < esAgg.buckets.length; i++) {
bucket = esAgg.buckets[i];
for (let i = 0; i < esAgg.buckets.length; i++) {
const bucket = esAgg.buckets[i];
const stats = bucket[metric.id];
// add stats that are in nested obj to top level obj
@ -100,15 +105,19 @@ export class ElasticResponse {
newSeries = {
datapoints: [],
metric: metric.type,
field: metric.field,
metricId: metric.id,
props: props,
refId: target.refId,
};
for (i = 0; i < esAgg.buckets.length; i++) {
bucket = esAgg.buckets[i];
value = bucket[metric.id];
if (isMetricAggregationWithField(metric)) {
newSeries.field = metric.field;
}
for (let i = 0; i < esAgg.buckets.length; i++) {
const bucket = esAgg.buckets[i];
const value = bucket[metric.id];
if (value !== undefined) {
if (value.normalized_value) {
newSeries.datapoints.push([value.normalized_value, bucket.key]);
@ -124,7 +133,13 @@ export class ElasticResponse {
}
}
processAggregationDocs(esAgg: any, aggDef: ElasticsearchAggregation, target: any, table: any, props: any) {
processAggregationDocs(
esAgg: any,
aggDef: ElasticsearchAggregation,
target: ElasticsearchQuery,
table: any,
props: any
) {
// add columns
if (table.columns.length === 0) {
for (const propKey of _.keys(props)) {
@ -149,7 +164,7 @@ export class ElasticResponse {
// add bucket key (value)
values.push(bucket.key);
for (const metric of target.metrics) {
for (const metric of target.metrics || []) {
switch (metric.type) {
case 'count': {
addMetricValue(values, this.getMetricName(metric.type), bucket.doc_count);
@ -157,7 +172,7 @@ export class ElasticResponse {
}
case 'extended_stats': {
for (const statName in metric.meta) {
if (!metric.meta[statName]) {
if (!metric.meta[statName as ExtendedStatMetaType]) {
continue;
}
@ -166,7 +181,7 @@ export class ElasticResponse {
stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper;
stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower;
addMetricValue(values, this.getMetricName(statName), stats[statName]);
addMetricValue(values, this.getMetricName(statName as ExtendedStatMetaType), stats[statName]);
}
break;
}
@ -184,10 +199,13 @@ export class ElasticResponse {
// if more of the same metric type include field field name in property
if (otherMetrics.length > 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;
}
const extendedStat = queryDef.extendedStats.find(e => e.value === metric);
if (extendedStat) {
return extendedStat.label;
}
return metricDef ? metricDef.text : metric;
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;

@ -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 (
<ElasticsearchProvider query={query} datasource={{} as any} onChange={() => {}}>
{children}
</ElasticsearchProvider>
);
};
const { result } = renderHook(() => useNextId(), {
wrapper,
});
expect(result.current).toBe('3');
});
});

@ -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 = <T extends { id: unknown }>(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]
);
};

@ -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 }) => (
<DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
);
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);
});
});

@ -0,0 +1,45 @@
import { createContext, useCallback, useContext } from 'react';
export interface Action<T extends string = string> {
type: T;
}
export type Reducer<S, A extends Action = Action> = (state: S, action: A) => S;
export const combineReducers = <S, A extends Action = Action>(reducers: { [P in keyof S]: Reducer<S[P], A> }) => (
state: S,
action: A
): Partial<S> => {
const newState = {} as S;
for (const key in reducers) {
newState[key] = reducers[key](state[key], action);
}
return newState;
};
export const useStatelessReducer = <State, A = Action>(
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 = <T extends Action = Action>(): ((action: T) => void) => {
const dispatch = useContext(DispatchContext);
if (!dispatch) {
throw new Error('Use DispatchContext first.');
}
return dispatch;
};

@ -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);

@ -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);

@ -1,239 +0,0 @@
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">
<span ng-show="isFirst">Group by</span>
<span ng-hide="isFirst">Then by</span>
</label>
<gf-form-dropdown
model="agg.type"
lookup-text="true"
get-options="getBucketAggTypes()"
on-change="onTypeChanged()"
allow-custom="false"
label-mode="true"
css-class="width-10"
>
</gf-form-dropdown>
<gf-form-dropdown
ng-if="agg.field"
model="agg.field"
get-options="getFieldsInternal()"
on-change="onChange()"
allow-custom="false"
label-mode="true"
css-class="width-12"
>
</gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow">
<label class="gf-form-label gf-form-label--grow">
<a ng-click="toggleOptions()">
<icon name="'angle-down'" ng-show="showOptions"></icon>
<icon name="'angle-right'" ng-hide="showOptions"></icon>
{{settingsLinkText}}
</a>
</label>
</div>
<div class="gf-form">
<label class="gf-form-label" ng-if="isFirst">
<a class="pointer" ng-click="addBucketAgg()"><icon name="'plus'"></icon></a>
</label>
<label class="gf-form-label" ng-if="bucketAggCount > 1">
<a class="pointer" ng-click="removeBucketAgg()"><icon name="'minus'"></icon></a>
</label>
</div>
</div>
<div class="gf-form-group" ng-if="showOptions">
<div ng-if="agg.type === 'date_histogram'">
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Interval</label>
<gf-form-dropdown
model="agg.settings.interval"
get-options="getIntervalOptions()"
on-change="onChangeInternal()"
allow-custom="true"
label-mode="true"
css-class="width-12"
>
</gf-form-dropdown>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Min Doc Count</label>
<input
type="text"
class="gf-form-input max-width-12"
ng-model="agg.settings.min_doc_count"
ng-blur="onChangeInternal()"
/>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">
Trim edges
<info-popover mode="right-normal">
Trim the edges on the timeseries datapoints
</info-popover>
</label>
<input
class="gf-form-input max-width-12"
type="number"
ng-model="agg.settings.trimEdges"
ng-change="onChangeInternal()"
/>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">
Offset
<info-popover mode="right-normal">
Change the start value of each bucket by the specified positive (+) or negative offset (-) duration, such as
1h for an hour, or 1d for a day
</info-popover>
</label>
<input
class="gf-form-input max-width-12"
type="text"
ng-model="agg.settings.offset"
ng-change="onChangeInternal()"
/>
</div>
</div>
<div ng-if="agg.type === 'histogram'">
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Interval</label>
<input
type="number"
class="gf-form-input max-width-12"
ng-model="agg.settings.interval"
ng-blur="onChangeInternal()"
/>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Min Doc Count</label>
<input
type="text"
class="gf-form-input max-width-12"
ng-model="agg.settings.min_doc_count"
ng-blur="onChangeInternal()"
/>
</div>
</div>
<div ng-if="agg.type === 'terms'">
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Order</label>
<gf-form-dropdown
model="agg.settings.order"
lookup-text="true"
get-options="getOrderOptions()"
on-change="onChangeInternal()"
label-mode="true"
css-class="width-12"
>
</gf-form-dropdown>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Size</label>
<gf-form-dropdown
model="agg.settings.size"
lookup-text="true"
get-options="getSizeOptions()"
on-change="onChangeInternal()"
label-mode="true"
allow-custom="true"
css-class="width-12"
>
</gf-form-dropdown>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Min Doc Count</label>
<input
type="text"
class="gf-form-input max-width-12"
ng-model="agg.settings.min_doc_count"
ng-blur="onChangeInternal()"
/>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Order By</label>
<gf-form-dropdown
model="agg.settings.orderBy"
lookup-text="true"
get-options="getOrderByOptions()"
on-change="onChangeInternal()"
label-mode="true"
css-class="width-12"
>
</gf-form-dropdown>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">
Missing
<info-popover mode="right-normal">
The missing parameter defines how documents that are missing a value should be treated. By default they will
be ignored but it is also possible to treat them as if they had a value
</info-popover>
</label>
<input
type="text"
class="gf-form-input max-width-12"
empty-to-null
ng-model="agg.settings.missing"
ng-blur="onChangeInternal()"
spellcheck="false"
/>
</div>
</div>
<div ng-if="agg.type === 'filters'">
<div class="gf-form-inline offset-width-7" ng-repeat="filter in agg.settings.filters">
<div class="gf-form">
<label class="gf-form-label width-10">Query {{$index + 1}}</label>
<input
type="text"
class="gf-form-input max-width-12"
ng-model="filter.query"
spellcheck="false"
placeholder="Lucene query"
ng-blur="onChangeInternal()"
/>
<label class="gf-form-label width-10">Label {{$index + 1}}</label>
<input
type="text"
class="gf-form-input max-width-12"
ng-model="filter.label"
spellcheck="false"
placeholder="Label"
ng-blur="onChangeInternal()"
/>
</div>
<div class="gf-form">
<label class="gf-form-label" ng-if="$first">
<a class="pointer" ng-click="addFiltersQuery()"><icon name="'plus'"></icon></a>
</label>
<label class="gf-form-label" ng-if="!$first">
<a class="pointer" ng-click="removeFiltersQuery(filter)"><icon name="'minus'"></icon></a>
</label>
</div>
</div>
</div>
<div ng-if="agg.type === 'geohash_grid'">
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Precision</label>
<input
type="number"
class="gf-form-input max-width-12"
ng-model="agg.settings.precision"
spellcheck="false"
placeholder="3"
ng-blur="onChangeInternal()"
/>
</div>
</div>
</div>

@ -1,161 +0,0 @@
<div class="gf-form-inline" ng-class="{'gf-form-disabled': agg.hide}">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">
Metric
&nbsp;
<a ng-click="toggleShowMetric()" bs-tooltip="'Click to toggle show / hide metric'" style="margin-top: 2px;">
<icon name="'eye'" size="'sm'" ng-hide="agg.hide"></icon>
<icon name="'eye-slash'" size="'sm'" ng-show="agg.hide"></icon>
</a>
</label>
</div>
<div class="gf-form" ng-if="isValidAgg">
<metric-segment-model property="agg.type" options="metricAggTypes" on-change="onTypeChange()" custom="false" css-class="width-10"></metric-segment-model>
<metric-segment-model ng-if="aggDef.requiresField" property="agg.field" get-options="getFieldsInternal()" on-change="onChange()" css-class="width-12"></metric-segment-model>
<metric-segment-model ng-if="aggDef.isPipelineAgg && !aggDef.supportsMultipleBucketPaths" property="agg.pipelineAgg" options="pipelineAggOptions" on-change="onChangeInternal()" custom="false" css-class="width-12"></metric-segment-model>
</div>
<div class="gf-form gf-form--grow" ng-if="!isValidAgg">
<label class="gf-form-label gf-form-label--grow">
<em>This aggregation is no longer supported by your version of Elasticsearch</em>
</label>
</div>
<div class="gf-form gf-form--grow" ng-if="aggDef.isPipelineAgg && aggDef.supportsMultipleBucketPaths">
<label class="gf-form-label gf-form-label--grow">
<a ng-click="toggleVariables()">
<icon name="'angle-down'" ng-show="showVariables"></icon>
<icon name="'angle-right'" ng-hide="showVariables"></icon>
{{variablesLinkText}}
</a>
</label>
</div>
<div class="gf-form gf-form--grow" ng-if="isValidAgg">
<label class="gf-form-label gf-form-label--grow">
<a ng-click="toggleOptions()" ng-if="settingsLinkText">
<icon name="'angle-down'" ng-show="showOptions"></icon>
<icon name="'angle-right'" ng-hide="showOptions"></icon>
{{settingsLinkText}}
</a>
</label>
</div>
<div class="gf-form">
<label class="gf-form-label" ng-if="isFirst">
<a class="pointer" ng-click="addMetricAgg()"><icon name="'plus'"></icon></a>
</label>
<label class="gf-form-label" ng-if="!isSingle">
<a class="pointer" ng-click="removeMetricAgg()"><icon name="'minus'"></icon></a>
</label>
</div>
</div>
<div class="gf-form-group" ng-if="showVariables">
<elastic-pipeline-variables variables="agg.pipelineVariables" options="pipelineAggOptions" on-change="onChangeInternal()"></elastic-pipeline-variables>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">
Script
<info-popover mode="right-normal">
Elasticsearch v5.0 and above: Scripting language is Painless. Use <i>params.&lt;var&gt;</i> to reference a variable.<br/><br/>
Elasticsearch pre-v5.0: Scripting language is per default Groovy if not changed. For Groovy use <i>&lt;var&gt;</i> to reference a variable.
</info-popover>
</label>
<input type="text" class="gf-form-input max-width-24" empty-to-null ng-model="agg.settings.script" ng-blur="onChangeInternal()" spellcheck='false' placeholder="params.var1 / params.var2">
</div>
</div>
<div class="gf-form-group" ng-if="showOptions">
<div class="gf-form offset-width-7" ng-if="agg.type === 'derivative'">
<label class="gf-form-label width-10">Unit</label>
<input type="text" class="gf-form-input max-width-12" ng-model="agg.settings.unit" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
<div class="gf-form offset-width-7" ng-if="agg.type === 'cumulative_sum'">
<label class="gf-form-label width-10">Format</label>
<input type="text" class="gf-form-input max-width-12" ng-model="agg.settings.format" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
<div ng-if="agg.type === 'moving_fn'">
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Window</label>
<input type="number" class="gf-form-input max-width-12" ng-model="agg.settings.window" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Script</label>
<input type="text" class="gf-form-input max-width-12" ng-model="agg.settings.script" ng-blur="onChangeInternal()" spellcheck='false' placeholder="eg. MovingFunctions.unweightedAvg(values)">
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Shift</label>
<input type="number" class="gf-form-input max-width-12" ng-model="agg.settings.shift" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
</div>
<div ng-if="agg.type === 'moving_avg'">
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Model</label>
<metric-segment-model property="agg.settings.model" options="movingAvgModelTypes" on-change="onChangeClearInternal()" custom="false" css-class="width-12"></metric-segment-model>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Window</label>
<input type="number" class="gf-form-input max-width-12" ng-model="agg.settings.window" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Predict</label>
<input type="number" class="gf-form-input max-width-12" ng-model="agg.settings.predict" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
<div class="gf-form offset-width-7" ng-repeat="setting in modelSettings">
<label class="gf-form-label width-10">{{setting.text}}</label>
<input type="number" class="gf-form-input max-width-12" ng-model="agg.settings.settings[setting.value]" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
<gf-form-switch ng-if="agg.settings.model == 'holt_winters'" class="gf-form offset-width-7" label="Pad" label-class="width-10" checked="agg.settings.settings.pad" on-change="onChangeInternal()"></gf-form-switch>
<gf-form-switch ng-if="agg.settings.model.match('ewma|holt_winters|holt') !== null" class="gf-form offset-width-7" label="Minimize" label-class="width-10" checked="agg.settings.minimize" on-change="onChangeInternal()"></gf-form-switch>
</div>
<div class="gf-form offset-width-7" ng-if="agg.type === 'percentiles'">
<label class="gf-form-label width-10">Percentiles</label>
<input type="text" class="gf-form-input max-width-12" ng-model="agg.settings.percents" array-join ng-blur="onChange()"></input>
</div>
<div class="gf-form offset-width-7" ng-if="agg.type === 'raw_document' || agg.type === 'raw_data'">
<label class="gf-form-label width-10">Size</label>
<input type="number" class="gf-form-input max-width-12" ng-model="agg.settings.size" ng-blur="onChange()"></input>
</div>
<div class="gf-form offset-width-7" ng-if="agg.type === 'cardinality'">
<label class="gf-form-label width-10">Precision threshold</label>
<input type="number" class="gf-form-input max-width-12" ng-model="agg.settings.precision_threshold" ng-blur="onChange()"></input>
</div>
<div ng-if="agg.type === 'extended_stats'">
<gf-form-switch ng-repeat="stat in extendedStats" class="gf-form offset-width-7" label="{{stat.text}}" label-class="width-10" checked="agg.meta[stat.value]" on-change="onChangeInternal()"></gf-form-switch>
<div class="gf-form offset-width-7">
<label class="gf-form-label width-10">Sigma</label>
<input type="number" class="gf-form-input max-width-12" placeholder="3" ng-model="agg.settings.sigma" ng-blur="onChange()"></input>
</div>
</div>
<div class="gf-form offset-width-7" ng-if="aggDef.supportsInlineScript">
<label class="gf-form-label width-10">Script</label>
<input type="text" class="gf-form-input max-width-12" empty-to-null ng-model="agg.inlineScript" ng-blur="onChangeInternal()" spellcheck='false' placeholder="_value * 1">
</div>
<div class="gf-form offset-width-7" ng-if="aggDef.supportsMissing">
<label class="gf-form-label width-10">
Missing
<tip>The missing parameter defines how documents that are missing a value should be treated. By default they will be ignored but it is also possible to treat them as if they had a value</tip>
</label>
<input type="number" class="gf-form-input max-width-12" empty-to-null ng-model="agg.settings.missing" ng-blur="onChangeInternal()" spellcheck='false'>
</div>
</div>

@ -1,46 +0,0 @@
<div ng-repeat="var in variables">
<div class="gf-form offset-width-7" ng-if="$index === 0">
<label class="gf-form-label width-10">Variables</label>
<input
type="text"
class="gf-form-input max-width-12"
ng-model="var.name"
placeholder="Variable name"
ng-blur="onChangeInternal()"
spellcheck="false"
/>
<metric-segment-model
property="var.pipelineAgg"
options="options"
on-change="onChangeInternal()"
custom="false"
css-class="width-12"
></metric-segment-model>
<label class="gf-form-label">
<a class="pointer" ng-click="remove($index)"><icon name="'minus'"></icon></a>
</label>
<label class="gf-form-label">
<a class="pointer" ng-click="add()"><icon name="'plus'"></icon></a>
</label>
</div>
<div class="gf-form offset-width-17" ng-if="$index !== 0">
<input
type="text"
class="gf-form-input max-width-12"
ng-model="var.name"
placeholder="Variable name"
ng-blur="onChangeInternal()"
spellcheck="false"
/>
<metric-segment-model
property="var.pipelineAgg"
options="options"
on-change="onChangeInternal()"
custom="false"
css-class="width-12"
></metric-segment-model>
<label class="gf-form-label">
<a class="pointer" ng-click="remove($index)"><icon name="'minus'"></icon></a>
</label>
</div>
</div>

@ -1,31 +0,0 @@
<query-editor-row query-ctrl="ctrl" can-collapse="true">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<label class="gf-form-label query-keyword width-7">Query</label>
<input type="text" class="gf-form-input" ng-model="ctrl.target.query" spellcheck='false' placeholder="Lucene query" ng-blur="ctrl.refresh()">
</div>
<div class="gf-form max-width-15">
<label class="gf-form-label query-keyword">Alias</label>
<input type="text" class="gf-form-input" ng-model="ctrl.target.alias" spellcheck='false' placeholder="alias patterns" ng-blur="ctrl.refresh()" pattern='[^<>&\\"]+'>
</div>
</div>
<div ng-repeat="agg in ctrl.target.metrics">
<elastic-metric-agg
target="ctrl.target" index="$index"
get-fields="ctrl.getFields($fieldType)"
on-change="ctrl.queryUpdated()"
es-version="ctrl.esVersion">
</elastic-metric-agg>
</div>
<div ng-repeat="agg in ctrl.target.bucketAggs">
<elastic-bucket-agg
target="ctrl.target" index="$index"
get-fields="ctrl.getFields($fieldType)"
on-change="ctrl.queryUpdated()">
</elastic-bucket-agg>
</div>
</query-editor-row>

@ -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);

@ -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<string, { query_string: { query: string; analyze_wildcard: boolean } }> = {};
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: {

@ -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 [];
}
}

@ -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 getPipelineOptions(metric: any) {
if (!isPipelineAgg(metric.type)) {
return [];
}
return pipelineOptions[metric.type];
}
export function isPipelineAgg(metricType: any) {
if (metricType) {
const po = pipelineOptions[metricType];
return po !== null && po !== undefined;
}
return false;
export function defaultMetricAgg(id = '1'): MetricAggregation {
return { type: 'count', id };
}
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 defaultBucketAgg(id = '1'): BucketAggregation {
return { type: 'date_histogram', id, settings: { interval: 'auto' } };
}
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 const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
metrics.find(metric => metric.id === id);
export function defaultMetricAgg() {
return { type: 'count', id: '1' };
export function hasMetricOfType(target: any, type: string): boolean {
return target && target.metrics && target.metrics.some((m: any) => m.type === type);
}
export function defaultBucketAgg() {
return { type: 'date_histogram', id: '2', settings: { interval: 'auto' } };
// 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 const findMetricById = (metrics: any[], id: any) => {
return _.find(metrics, { id: id });
};
export function hasMetricOfType(target: any, type: string): boolean {
return target && target.metrics && target.metrics.some((m: any) => m.type === type);
export function isPipelineAggWithMultipleBucketPaths(metricType: MetricAggregationType) {
return !!metricAggregationConfig[metricType].supportsMultipleBucketPaths;
}

@ -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' },

@ -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');

@ -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();
});
});
});
});

@ -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<T extends MetricAggregationType> {
label: string;
requiresField: boolean;
supportsInlineScript: boolean;
supportsMissing: boolean;
isPipelineAgg: boolean;
minVersion?: number;
maxVersion?: number;
supportsMultipleBucketPaths: boolean;
isSingleMetric?: boolean;
hasSettings: boolean;
hasMeta: boolean;
defaults: Omit<Extract<MetricAggregation, { type: T }>, 'id' | 'type'>;
}
type BucketConfiguration<T extends BucketAggregationType> = {
label: string;
requiresField: boolean;
defaultSettings: Extract<BucketAggregation, { type: T }>['settings'];
};
export type MetricsConfiguration = {
[P in MetricAggregationType]: MetricConfiguration<P>;
};
export type BucketsConfiguration = {
[P in BucketAggregationType]: BucketConfiguration<P>;
};
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 = {

@ -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);
});
});

@ -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 = <T>(obj: T): Partial<T> =>
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,
};
}, {});

@ -117,8 +117,6 @@ export const zoomOut = eventFactory<number>('zoom-out');
export const shiftTime = eventFactory<number>('shift-time');
export const elasticQueryUpdated = eventFactory('elastic-query-updated');
export const routeUpdated = eventFactory('$routeUpdate');
/**

Loading…
Cancel
Save