mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Elasticsearch: Migrate queryeditor to React (#28033)
Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
parent
3d6380a0aa
commit
bb45f5fedc
@ -14,7 +14,6 @@ export const useExpandableLabel = (
|
|||||||
|
|
||||||
const Label: React.FC<LabelProps> = ({ Component, onClick }) => (
|
const Label: React.FC<LabelProps> = ({ Component, onClick }) => (
|
||||||
<div
|
<div
|
||||||
className="gf-form"
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpanded(true);
|
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', () => {
|
it('should set defaults', () => {
|
||||||
const options = createDefaultConfigOptions();
|
const options = createDefaultConfigOptions();
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
delete options.jsonData.esVersion;
|
delete options.jsonData.esVersion;
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
delete options.jsonData.timeField;
|
delete options.jsonData.timeField;
|
||||||
delete options.jsonData.maxConcurrentShardRequests;
|
delete options.jsonData.maxConcurrentShardRequests;
|
||||||
|
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import angular from 'angular';
|
|
||||||
import {
|
import {
|
||||||
ArrayVector,
|
ArrayVector,
|
||||||
CoreApp,
|
CoreApp,
|
||||||
DataQueryRequest,
|
DataQueryRequest,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
dateMath,
|
dateMath,
|
||||||
|
DateTime,
|
||||||
dateTime,
|
dateTime,
|
||||||
Field,
|
Field,
|
||||||
|
MetricFindValue,
|
||||||
MutableDataFrame,
|
MutableDataFrame,
|
||||||
|
TimeRange,
|
||||||
toUtc,
|
toUtc,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import _ from 'lodash';
|
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 { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
import { ElasticsearchOptions, ElasticsearchQuery } from './types';
|
import { ElasticsearchOptions, ElasticsearchQuery } from './types';
|
||||||
|
import { Filters } from './components/QueryEditor/BucketAggregationsEditor/aggregations';
|
||||||
|
|
||||||
const ELASTICSEARCH_MOCK_URL = 'http://elasticsearch.local';
|
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) {
|
describe('ElasticDatasource', function(this: any) {
|
||||||
const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest');
|
const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest');
|
||||||
|
|
||||||
@ -38,11 +50,6 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const $rootScope = {
|
|
||||||
$on: jest.fn(),
|
|
||||||
appEvent: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const templateSrv: any = {
|
const templateSrv: any = {
|
||||||
replace: jest.fn(text => {
|
replace: jest.fn(text => {
|
||||||
if (text.startsWith('$')) {
|
if (text.startsWith('$')) {
|
||||||
@ -56,9 +63,10 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
|
|
||||||
const timeSrv: any = createTimeSrv('now-1h');
|
const timeSrv: any = createTimeSrv('now-1h');
|
||||||
|
|
||||||
const ctx = {
|
interface TestContext {
|
||||||
$rootScope,
|
ds: ElasticDatasource;
|
||||||
} as any;
|
}
|
||||||
|
const ctx = {} as TestContext;
|
||||||
|
|
||||||
function createTimeSrv(from: string) {
|
function createTimeSrv(from: string) {
|
||||||
const srv: any = {
|
const srv: any = {
|
||||||
@ -164,7 +172,7 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
result = await ctx.ds.query(query);
|
result = await ctx.ds.query(query);
|
||||||
|
|
||||||
parts = requestOptions.data.split('\n');
|
parts = requestOptions.data.split('\n');
|
||||||
header = angular.fromJson(parts[0]);
|
header = JSON.parse(parts[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should translate index pattern to current day', () => {
|
it('should translate index pattern to current day', () => {
|
||||||
@ -180,7 +188,7 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should json escape lucene query', () => {
|
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');
|
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);
|
return Promise.resolve(logsResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = {
|
const query: DataQueryRequest<ElasticsearchQuery> = {
|
||||||
range: {
|
range: createTimeRange(toUtc([2015, 4, 30, 10]), toUtc([2019, 7, 1, 10])),
|
||||||
from: toUtc([2015, 4, 30, 10]),
|
|
||||||
to: toUtc([2019, 7, 1, 10]),
|
|
||||||
},
|
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
alias: '$varAlias',
|
alias: '$varAlias',
|
||||||
@ -214,12 +219,11 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
bucketAggs: [{ type: 'date_histogram', settings: { interval: 'auto' }, id: '2' }],
|
bucketAggs: [{ type: 'date_histogram', settings: { interval: 'auto' }, id: '2' }],
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
query: 'escape\\:test',
|
query: 'escape\\:test',
|
||||||
interval: '10s',
|
|
||||||
isLogsQuery: true,
|
isLogsQuery: true,
|
||||||
timeField: '@timestamp',
|
timeField: '@timestamp',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
} as DataQueryRequest<ElasticsearchQuery>;
|
||||||
|
|
||||||
const queryBuilderSpy = jest.spyOn(ctx.ds.queryBuilder, 'getLogsQuery');
|
const queryBuilderSpy = jest.spyOn(ctx.ds.queryBuilder, 'getLogsQuery');
|
||||||
const response = await ctx.ds.query(query);
|
const response = await ctx.ds.query(query);
|
||||||
@ -263,22 +267,21 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
return Promise.resolve({ data: { responses: [] } });
|
return Promise.resolve({ data: { responses: [] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.ds.query({
|
const query: DataQueryRequest<ElasticsearchQuery> = {
|
||||||
range: {
|
range: createTimeRange(dateTime([2015, 4, 30, 10]), dateTime([2015, 5, 1, 10])),
|
||||||
from: dateTime([2015, 4, 30, 10]),
|
|
||||||
to: dateTime([2015, 5, 1, 10]),
|
|
||||||
},
|
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
bucketAggs: [],
|
refId: 'A',
|
||||||
metrics: [{ type: 'raw_document' }],
|
metrics: [{ type: 'raw_document', id: '1' }],
|
||||||
query: 'test',
|
query: 'test',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
} as DataQueryRequest<ElasticsearchQuery>;
|
||||||
|
|
||||||
|
ctx.ds.query(query);
|
||||||
|
|
||||||
parts = requestOptions.data.split('\n');
|
parts = requestOptions.data.split('\n');
|
||||||
header = angular.fromJson(parts[0]);
|
header = JSON.parse(parts[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set search type to query_then_fetch', () => {
|
it('should set search type to query_then_fetch', () => {
|
||||||
@ -286,26 +289,24 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set size', () => {
|
it('should set size', () => {
|
||||||
const body = angular.fromJson(parts[1]);
|
const body = JSON.parse(parts[1]);
|
||||||
expect(body.size).toBe(500);
|
expect(body.size).toBe(500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When getting an error on response', () => {
|
describe('When getting an error on response', () => {
|
||||||
const query = {
|
const query: DataQueryRequest<ElasticsearchQuery> = {
|
||||||
range: {
|
range: createTimeRange(toUtc([2020, 1, 1, 10]), toUtc([2020, 2, 1, 10])),
|
||||||
from: toUtc([2020, 1, 1, 10]),
|
|
||||||
to: toUtc([2020, 2, 1, 10]),
|
|
||||||
},
|
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
alias: '$varAlias',
|
alias: '$varAlias',
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }],
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
query: 'escape\\:test',
|
query: 'escape\\:test',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
} as DataQueryRequest<ElasticsearchQuery>;
|
||||||
|
|
||||||
createDatasource({
|
createDatasource({
|
||||||
url: ELASTICSEARCH_MOCK_URL,
|
url: ELASTICSEARCH_MOCK_URL,
|
||||||
@ -431,11 +432,10 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return nested fields', async () => {
|
it('should return nested fields', async () => {
|
||||||
const fieldObjects = await ctx.ds.getFields({
|
const fieldObjects = await ctx.ds.getFields();
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
});
|
|
||||||
const fields = _.map(fieldObjects, 'text');
|
const fields = _.map(fieldObjects, 'text');
|
||||||
|
|
||||||
expect(fields).toEqual([
|
expect(fields).toEqual([
|
||||||
'@timestamp',
|
'@timestamp',
|
||||||
'__timestamp',
|
'__timestamp',
|
||||||
@ -451,24 +451,18 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return number fields', async () => {
|
it('should return number fields', async () => {
|
||||||
const fieldObjects = await ctx.ds.getFields({
|
const fieldObjects = await ctx.ds.getFields('number');
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
type: 'number',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fields = _.map(fieldObjects, 'text');
|
const fields = _.map(fieldObjects, 'text');
|
||||||
|
|
||||||
expect(fields).toEqual(['system.cpu.system', 'system.cpu.user', 'system.process.cpu.total']);
|
expect(fields).toEqual(['system.cpu.system', 'system.cpu.user', 'system.process.cpu.total']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return date fields', async () => {
|
it('should return date fields', async () => {
|
||||||
const fieldObjects = await ctx.ds.getFields({
|
const fieldObjects = await ctx.ds.getFields('date');
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
type: 'date',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fields = _.map(fieldObjects, 'text');
|
const fields = _.map(fieldObjects, 'text');
|
||||||
|
|
||||||
expect(fields).toEqual(['@timestamp', '__timestamp', '@timestampnano']);
|
expect(fields).toEqual(['@timestamp', '__timestamp', '@timestampnano']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -540,10 +534,8 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
return Promise.reject({ status: 404 });
|
return Promise.reject({ status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
const fieldObjects = await ctx.ds.getFields({
|
const fieldObjects = await ctx.ds.getFields();
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
});
|
|
||||||
const fields = _.map(fieldObjects, 'text');
|
const fields = _.map(fieldObjects, 'text');
|
||||||
expect(fields).toEqual(['@timestamp', 'beat.hostname']);
|
expect(fields).toEqual(['@timestamp', 'beat.hostname']);
|
||||||
});
|
});
|
||||||
@ -562,10 +554,7 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
|
|
||||||
expect.assertions(2);
|
expect.assertions(2);
|
||||||
try {
|
try {
|
||||||
await ctx.ds.getFields({
|
await ctx.ds.getFields();
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e).toStrictEqual({ status: 500 });
|
expect(e).toStrictEqual({ status: 500 });
|
||||||
expect(datasourceRequestMock).toBeCalledTimes(1);
|
expect(datasourceRequestMock).toBeCalledTimes(1);
|
||||||
@ -579,10 +568,7 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
|
|
||||||
expect.assertions(2);
|
expect.assertions(2);
|
||||||
try {
|
try {
|
||||||
await ctx.ds.getFields({
|
await ctx.ds.getFields();
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e).toStrictEqual({ status: 404 });
|
expect(e).toStrictEqual({ status: 404 });
|
||||||
expect(datasourceRequestMock).toBeCalledTimes(7);
|
expect(datasourceRequestMock).toBeCalledTimes(7);
|
||||||
@ -687,12 +673,10 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return nested fields', async () => {
|
it('should return nested fields', async () => {
|
||||||
const fieldObjects = await ctx.ds.getFields({
|
const fieldObjects = await ctx.ds.getFields();
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fields = _.map(fieldObjects, 'text');
|
const fields = _.map(fieldObjects, 'text');
|
||||||
|
|
||||||
expect(fields).toEqual([
|
expect(fields).toEqual([
|
||||||
'@timestamp_millis',
|
'@timestamp_millis',
|
||||||
'classification_terms',
|
'classification_terms',
|
||||||
@ -712,13 +696,10 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return number fields', async () => {
|
it('should return number fields', async () => {
|
||||||
const fieldObjects = await ctx.ds.getFields({
|
const fieldObjects = await ctx.ds.getFields('number');
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
type: 'number',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fields = _.map(fieldObjects, 'text');
|
const fields = _.map(fieldObjects, 'text');
|
||||||
|
|
||||||
expect(fields).toEqual([
|
expect(fields).toEqual([
|
||||||
'justification_blob.overall_vote_score',
|
'justification_blob.overall_vote_score',
|
||||||
'justification_blob.shallow.jsi.sdb.dsel2.bootlegged-gille.botness',
|
'justification_blob.shallow.jsi.sdb.dsel2.bootlegged-gille.botness',
|
||||||
@ -730,13 +711,10 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return date fields', async () => {
|
it('should return date fields', async () => {
|
||||||
const fieldObjects = await ctx.ds.getFields({
|
const fieldObjects = await ctx.ds.getFields('date');
|
||||||
find: 'fields',
|
|
||||||
query: '*',
|
|
||||||
type: 'date',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fields = _.map(fieldObjects, 'text');
|
const fields = _.map(fieldObjects, 'text');
|
||||||
|
|
||||||
expect(fields).toEqual(['@timestamp_millis']);
|
expect(fields).toEqual(['@timestamp_millis']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -756,22 +734,22 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
return Promise.resolve({ data: { responses: [] } });
|
return Promise.resolve({ data: { responses: [] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.ds.query({
|
const query: DataQueryRequest<ElasticsearchQuery> = {
|
||||||
range: {
|
range: createTimeRange(dateTime([2015, 4, 30, 10]), dateTime([2015, 5, 1, 10])),
|
||||||
from: dateTime([2015, 4, 30, 10]),
|
|
||||||
to: dateTime([2015, 5, 1, 10]),
|
|
||||||
},
|
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }],
|
||||||
metrics: [{ type: 'count' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
query: 'test',
|
query: 'test',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
} as DataQueryRequest<ElasticsearchQuery>;
|
||||||
|
|
||||||
|
ctx.ds.query(query);
|
||||||
|
|
||||||
parts = requestOptions.data.split('\n');
|
parts = requestOptions.data.split('\n');
|
||||||
header = angular.fromJson(parts[0]);
|
header = JSON.parse(parts[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set search type to count', () => {
|
it('should not set search type to count', () => {
|
||||||
@ -779,13 +757,14 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set size to 0', () => {
|
it('should set size to 0', () => {
|
||||||
const body = angular.fromJson(parts[1]);
|
const body = JSON.parse(parts[1]);
|
||||||
expect(body.size).toBe(0);
|
expect(body.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When issuing metricFind query on es5.x', () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
createDatasource({
|
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;
|
results = res;
|
||||||
});
|
});
|
||||||
|
|
||||||
parts = requestOptions.data.split('\n');
|
parts = requestOptions.data.split('\n');
|
||||||
header = angular.fromJson(parts[0]);
|
header = JSON.parse(parts[0]);
|
||||||
body = angular.fromJson(parts[1]);
|
body = JSON.parse(parts[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get results', () => {
|
it('should get results', () => {
|
||||||
@ -873,8 +852,8 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly interpolate variables in query', () => {
|
it('should correctly interpolate variables in query', () => {
|
||||||
const query = {
|
const query: ElasticsearchQuery = {
|
||||||
alias: '',
|
refId: 'A',
|
||||||
bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '$var', label: '' }] }, id: '1' }],
|
bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '$var', label: '' }] }, id: '1' }],
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
query: '$var',
|
query: '$var',
|
||||||
@ -883,12 +862,12 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
const interpolatedQuery = ctx.ds.interpolateVariablesInQueries([query], {})[0];
|
const interpolatedQuery = ctx.ds.interpolateVariablesInQueries([query], {})[0];
|
||||||
|
|
||||||
expect(interpolatedQuery.query).toBe('resolvedVariable');
|
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', () => {
|
it('should correctly handle empty query strings', () => {
|
||||||
const query = {
|
const query: ElasticsearchQuery = {
|
||||||
alias: '',
|
refId: 'A',
|
||||||
bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '', label: '' }] }, id: '1' }],
|
bucketAggs: [{ type: 'filters', settings: { filters: [{ query: '', label: '' }] }, id: '1' }],
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
query: '',
|
query: '',
|
||||||
@ -897,7 +876,7 @@ describe('ElasticDatasource', function(this: any) {
|
|||||||
const interpolatedQuery = ctx.ds.interpolateVariablesInQueries([query], {})[0];
|
const interpolatedQuery = ctx.ds.interpolateVariablesInQueries([query], {})[0];
|
||||||
|
|
||||||
expect(interpolatedQuery.query).toBe('*');
|
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 _ from 'lodash';
|
||||||
import {
|
import {
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
@ -10,17 +9,25 @@ import {
|
|||||||
DataLink,
|
DataLink,
|
||||||
PluginMeta,
|
PluginMeta,
|
||||||
DataQuery,
|
DataQuery,
|
||||||
|
MetricFindValue,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import LanguageProvider from './language_provider';
|
import LanguageProvider from './language_provider';
|
||||||
import { ElasticResponse } from './elastic_response';
|
import { ElasticResponse } from './elastic_response';
|
||||||
import { IndexPattern } from './index_pattern';
|
import { IndexPattern } from './index_pattern';
|
||||||
import { ElasticQueryBuilder } from './query_builder';
|
import { ElasticQueryBuilder } from './query_builder';
|
||||||
import { toUtc } from '@grafana/data';
|
import { toUtc } from '@grafana/data';
|
||||||
import * as queryDef from './query_def';
|
import { defaultBucketAgg, hasMetricOfType } from './query_def';
|
||||||
import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime';
|
import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
|
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||||
import { DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery } from './types';
|
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.
|
// 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.
|
// 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);
|
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) => {
|
return this.post('_msearch', payload).then((res: any) => {
|
||||||
const list = [];
|
const list = [];
|
||||||
@ -325,7 +332,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
|
|
||||||
for (let bucketAgg of query.bucketAggs || []) {
|
for (let bucketAgg of query.bucketAggs || []) {
|
||||||
if (bucketAgg.type === 'filters') {
|
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);
|
filter.query = this.interpolateLuceneQuery(filter.query, scopedVars);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -338,7 +345,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
|
|
||||||
testDatasource() {
|
testDatasource() {
|
||||||
// validate that the index exist and has date field
|
// validate that the index exist and has date field
|
||||||
return this.getFields({ type: 'date' }).then(
|
return this.getFields('date').then(
|
||||||
(dateFields: any) => {
|
(dateFields: any) => {
|
||||||
const timeField: any = _.find(dateFields, { text: this.timeField });
|
const timeField: any = _.find(dateFields, { text: this.timeField });
|
||||||
if (!timeField) {
|
if (!timeField) {
|
||||||
@ -371,7 +378,58 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
queryHeader['max_concurrent_shard_requests'] = this.maxConcurrentShardRequests;
|
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> {
|
query(options: DataQueryRequest<ElasticsearchQuery>): Promise<DataQueryResponse> {
|
||||||
@ -388,8 +446,8 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
}
|
}
|
||||||
|
|
||||||
let queryObj;
|
let queryObj;
|
||||||
if (target.isLogsQuery || queryDef.hasMetricOfType(target, 'logs')) {
|
if (target.isLogsQuery || hasMetricOfType(target, 'logs')) {
|
||||||
target.bucketAggs = [queryDef.defaultBucketAgg()];
|
target.bucketAggs = [defaultBucketAgg()];
|
||||||
target.metrics = [];
|
target.metrics = [];
|
||||||
// Setting this for metrics queries that are typed as logs
|
// Setting this for metrics queries that are typed as logs
|
||||||
target.isLogsQuery = true;
|
target.isLogsQuery = true;
|
||||||
@ -402,7 +460,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
queryObj = this.queryBuilder.build(target, adhocFilters, target.query);
|
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 searchType = queryObj.size === 0 && this.esVersion < 5 ? 'count' : 'query_then_fetch';
|
||||||
const header = this.getQueryHeader(searchType, options.range.from, options.range.to);
|
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);
|
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;
|
const configuredEsVersion = this.esVersion;
|
||||||
return this.get('/_mapping').then((result: any) => {
|
return this.get('/_mapping').then((result: any) => {
|
||||||
const typeMap: any = {
|
const typeMap: any = {
|
||||||
@ -462,17 +521,17 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
nested: 'nested',
|
nested: 'nested',
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldAddField = (obj: any, key: string, query: any) => {
|
const shouldAddField = (obj: any, key: string) => {
|
||||||
if (this.isMetadataField(key)) {
|
if (this.isMetadataField(key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query.type) {
|
if (!type) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// equal query type filter, or via typemap translation
|
// 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
|
// 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('.');
|
const fieldName = fieldNameParts.concat(key).join('.');
|
||||||
|
|
||||||
// Hide meta-fields and check field type
|
// Hide meta-fields and check field type
|
||||||
if (shouldAddField(subObj, key, query)) {
|
if (shouldAddField(subObj, key)) {
|
||||||
fields[fieldName] = {
|
fields[fieldName] = {
|
||||||
text: fieldName,
|
text: fieldName,
|
||||||
type: subObj.type,
|
type: subObj.type,
|
||||||
@ -537,7 +596,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
const range = this.timeSrv.timeRange();
|
const range = this.timeSrv.timeRange();
|
||||||
const searchType = this.esVersion >= 5 ? 'query_then_fetch' : 'count';
|
const searchType = this.esVersion >= 5 ? 'query_then_fetch' : 'count';
|
||||||
const header = this.getQueryHeader(searchType, range.from, range.to);
|
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(/\$timeFrom/g, range.from.valueOf().toString());
|
||||||
esQuery = esQuery.replace(/\$timeTo/g, range.to.valueOf().toString());
|
esQuery = esQuery.replace(/\$timeTo/g, range.to.valueOf().toString());
|
||||||
@ -568,17 +627,17 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
return '_msearch';
|
return '_msearch';
|
||||||
}
|
}
|
||||||
|
|
||||||
metricFindQuery(query: any) {
|
metricFindQuery(query: string): Promise<MetricFindValue[]> {
|
||||||
query = angular.fromJson(query);
|
const parsedQuery = JSON.parse(query);
|
||||||
if (query) {
|
if (query) {
|
||||||
if (query.find === 'fields') {
|
if (parsedQuery.find === 'fields') {
|
||||||
query.field = this.templateSrv.replace(query.field, {}, 'lucene');
|
parsedQuery.field = this.templateSrv.replace(parsedQuery.field, {}, 'lucene');
|
||||||
return this.getFields(query);
|
return this.getFields(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.find === 'terms') {
|
if (parsedQuery.find === 'terms') {
|
||||||
query.field = this.templateSrv.replace(query.field, {}, 'lucene');
|
parsedQuery.field = this.templateSrv.replace(parsedQuery.field, {}, 'lucene');
|
||||||
query.query = this.templateSrv.replace(query.query || '*', {}, 'lucene');
|
parsedQuery.query = this.templateSrv.replace(parsedQuery.query || '*', {}, 'lucene');
|
||||||
return this.getTerms(query);
|
return this.getTerms(query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -587,7 +646,7 @@ export class ElasticDatasource extends DataSourceApi<ElasticsearchQuery, Elastic
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTagKeys() {
|
getTagKeys() {
|
||||||
return this.getFields({});
|
return this.getFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTagValues(options: any) {
|
getTagValues(options: any) {
|
||||||
|
@ -10,30 +10,35 @@ import {
|
|||||||
MutableDataFrame,
|
MutableDataFrame,
|
||||||
PreferredVisualisationType,
|
PreferredVisualisationType,
|
||||||
} from '@grafana/data';
|
} 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 {
|
export class ElasticResponse {
|
||||||
constructor(private targets: any, private response: any) {
|
constructor(private targets: ElasticsearchQuery[], private response: any) {
|
||||||
this.targets = targets;
|
this.targets = targets;
|
||||||
this.response = response;
|
this.response = response;
|
||||||
}
|
}
|
||||||
|
|
||||||
processMetrics(esAgg: any, target: any, seriesList: any, props: any) {
|
processMetrics(esAgg: any, target: ElasticsearchQuery, seriesList: any, props: any) {
|
||||||
let metric, y, i, bucket, value;
|
|
||||||
let newSeries: any;
|
let newSeries: any;
|
||||||
|
|
||||||
for (y = 0; y < target.metrics.length; y++) {
|
for (let y = 0; y < target.metrics!.length; y++) {
|
||||||
metric = target.metrics[y];
|
const metric = target.metrics![y];
|
||||||
if (metric.hide) {
|
if (metric.hide) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (metric.type) {
|
switch (metric.type) {
|
||||||
case 'count': {
|
case 'count': {
|
||||||
newSeries = { datapoints: [], metric: 'count', props: props, refId: target.refId };
|
newSeries = { datapoints: [], metric: 'count', props, refId: target.refId };
|
||||||
for (i = 0; i < esAgg.buckets.length; i++) {
|
for (let i = 0; i < esAgg.buckets.length; i++) {
|
||||||
bucket = esAgg.buckets[i];
|
const bucket = esAgg.buckets[i];
|
||||||
value = bucket.doc_count;
|
const value = bucket.doc_count;
|
||||||
newSeries.datapoints.push([value, bucket.key]);
|
newSeries.datapoints.push([value, bucket.key]);
|
||||||
}
|
}
|
||||||
seriesList.push(newSeries);
|
seriesList.push(newSeries);
|
||||||
@ -56,8 +61,8 @@ export class ElasticResponse {
|
|||||||
refId: target.refId,
|
refId: target.refId,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (i = 0; i < esAgg.buckets.length; i++) {
|
for (let i = 0; i < esAgg.buckets.length; i++) {
|
||||||
bucket = esAgg.buckets[i];
|
const bucket = esAgg.buckets[i];
|
||||||
const values = bucket[metric.id].values;
|
const values = bucket[metric.id].values;
|
||||||
newSeries.datapoints.push([values[percentileName], bucket.key]);
|
newSeries.datapoints.push([values[percentileName], bucket.key]);
|
||||||
}
|
}
|
||||||
@ -68,7 +73,7 @@ export class ElasticResponse {
|
|||||||
}
|
}
|
||||||
case 'extended_stats': {
|
case 'extended_stats': {
|
||||||
for (const statName in metric.meta) {
|
for (const statName in metric.meta) {
|
||||||
if (!metric.meta[statName]) {
|
if (!metric.meta[statName as ExtendedStatMetaType]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,8 +85,8 @@ export class ElasticResponse {
|
|||||||
refId: target.refId,
|
refId: target.refId,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (i = 0; i < esAgg.buckets.length; i++) {
|
for (let i = 0; i < esAgg.buckets.length; i++) {
|
||||||
bucket = esAgg.buckets[i];
|
const bucket = esAgg.buckets[i];
|
||||||
const stats = bucket[metric.id];
|
const stats = bucket[metric.id];
|
||||||
|
|
||||||
// add stats that are in nested obj to top level obj
|
// add stats that are in nested obj to top level obj
|
||||||
@ -100,15 +105,19 @@ export class ElasticResponse {
|
|||||||
newSeries = {
|
newSeries = {
|
||||||
datapoints: [],
|
datapoints: [],
|
||||||
metric: metric.type,
|
metric: metric.type,
|
||||||
field: metric.field,
|
|
||||||
metricId: metric.id,
|
metricId: metric.id,
|
||||||
props: props,
|
props: props,
|
||||||
refId: target.refId,
|
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 !== undefined) {
|
||||||
if (value.normalized_value) {
|
if (value.normalized_value) {
|
||||||
newSeries.datapoints.push([value.normalized_value, bucket.key]);
|
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
|
// add columns
|
||||||
if (table.columns.length === 0) {
|
if (table.columns.length === 0) {
|
||||||
for (const propKey of _.keys(props)) {
|
for (const propKey of _.keys(props)) {
|
||||||
@ -149,7 +164,7 @@ export class ElasticResponse {
|
|||||||
// add bucket key (value)
|
// add bucket key (value)
|
||||||
values.push(bucket.key);
|
values.push(bucket.key);
|
||||||
|
|
||||||
for (const metric of target.metrics) {
|
for (const metric of target.metrics || []) {
|
||||||
switch (metric.type) {
|
switch (metric.type) {
|
||||||
case 'count': {
|
case 'count': {
|
||||||
addMetricValue(values, this.getMetricName(metric.type), bucket.doc_count);
|
addMetricValue(values, this.getMetricName(metric.type), bucket.doc_count);
|
||||||
@ -157,7 +172,7 @@ export class ElasticResponse {
|
|||||||
}
|
}
|
||||||
case 'extended_stats': {
|
case 'extended_stats': {
|
||||||
for (const statName in metric.meta) {
|
for (const statName in metric.meta) {
|
||||||
if (!metric.meta[statName]) {
|
if (!metric.meta[statName as ExtendedStatMetaType]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,7 +181,7 @@ export class ElasticResponse {
|
|||||||
stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper;
|
stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper;
|
||||||
stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@ -184,10 +199,13 @@ export class ElasticResponse {
|
|||||||
|
|
||||||
// if more of the same metric type include field field name in property
|
// if more of the same metric type include field field name in property
|
||||||
if (otherMetrics.length > 1) {
|
if (otherMetrics.length > 1) {
|
||||||
metricName += ' ' + metric.field;
|
if (isMetricAggregationWithField(metric)) {
|
||||||
|
metricName += ' ' + metric.field;
|
||||||
|
}
|
||||||
|
|
||||||
if (metric.type === 'bucket_script') {
|
if (metric.type === 'bucket_script') {
|
||||||
//Use the formula in the column name
|
//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
|
// This is quite complex
|
||||||
// need to recurse down the nested buckets to build series
|
// 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;
|
let bucket, aggDef: any, esAgg, aggId;
|
||||||
const maxDepth = target.bucketAggs.length - 1;
|
const maxDepth = target.bucketAggs!.length - 1;
|
||||||
|
|
||||||
for (aggId in aggs) {
|
for (aggId in aggs) {
|
||||||
aggDef = _.find(target.bucketAggs, { id: aggId });
|
aggDef = _.find(target.bucketAggs, { id: aggId });
|
||||||
@ -239,16 +257,24 @@ export class ElasticResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMetricName(metric: any) {
|
private getMetricName(metric: string): string {
|
||||||
let metricDef: any = _.find(queryDef.metricAggTypes, { value: metric });
|
const metricDef = Object.entries(metricAggregationConfig)
|
||||||
if (!metricDef) {
|
.filter(([key]) => key === metric)
|
||||||
metricDef = _.find(queryDef.extendedStats, { value: metric });
|
.map(([_, value]) => value)[0];
|
||||||
|
|
||||||
|
if (metricDef) {
|
||||||
|
return metricDef.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
return metricDef ? metricDef.text : metric;
|
const extendedStat = queryDef.extendedStats.find(e => e.value === metric);
|
||||||
|
if (extendedStat) {
|
||||||
|
return extendedStat.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metric;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSeriesName(series: any, target: any, metricTypeCount: any) {
|
private getSeriesName(series: any, target: ElasticsearchQuery, metricTypeCount: any) {
|
||||||
let metricName = this.getMetricName(series.metric);
|
let metricName = this.getMetricName(series.metric);
|
||||||
|
|
||||||
if (target.alias) {
|
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)) {
|
if (series.metric && queryDef.isPipelineAggWithMultipleBucketPaths(series.metric)) {
|
||||||
const agg: any = _.find(target.metrics, { id: series.metricId });
|
const agg: any = _.find(target.metrics, { id: series.metricId });
|
||||||
if (agg && agg.settings.script) {
|
if (agg && agg.settings.script) {
|
||||||
@ -283,7 +309,7 @@ export class ElasticResponse {
|
|||||||
for (const pv of agg.pipelineVariables) {
|
for (const pv of agg.pipelineVariables) {
|
||||||
const appliedAgg: any = _.find(target.metrics, { id: pv.pipelineAgg });
|
const appliedAgg: any = _.find(target.metrics, { id: pv.pipelineAgg });
|
||||||
if (appliedAgg) {
|
if (appliedAgg) {
|
||||||
metricName = metricName.replace('params.' + pv.name, queryDef.describeMetric(appliedAgg));
|
metricName = metricName.replace('params.' + pv.name, describeMetric(appliedAgg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -292,7 +318,7 @@ export class ElasticResponse {
|
|||||||
} else {
|
} else {
|
||||||
const appliedAgg: any = _.find(target.metrics, { id: series.field });
|
const appliedAgg: any = _.find(target.metrics, { id: series.field });
|
||||||
if (appliedAgg) {
|
if (appliedAgg) {
|
||||||
metricName += ' ' + queryDef.describeMetric(appliedAgg);
|
metricName += ' ' + describeMetric(appliedAgg);
|
||||||
} else {
|
} else {
|
||||||
metricName = 'Unset';
|
metricName = 'Unset';
|
||||||
}
|
}
|
||||||
@ -318,7 +344,7 @@ export class ElasticResponse {
|
|||||||
return name.trim() + ' ' + metricName;
|
return name.trim() + ' ' + metricName;
|
||||||
}
|
}
|
||||||
|
|
||||||
nameSeries(seriesList: any, target: any) {
|
nameSeries(seriesList: any, target: ElasticsearchQuery) {
|
||||||
const metricTypeCount = _.uniq(_.map(seriesList, 'metric')).length;
|
const metricTypeCount = _.uniq(_.map(seriesList, 'metric')).length;
|
||||||
|
|
||||||
for (let i = 0; i < seriesList.length; i++) {
|
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 hitsTotal = typeof hits.total === 'number' ? hits.total : hits.total.value; // <- Works with Elasticsearch 7.0+
|
||||||
|
|
||||||
const series: any = {
|
const series: any = {
|
||||||
@ -363,7 +389,7 @@ export class ElasticResponse {
|
|||||||
seriesList.push(series);
|
seriesList.push(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
trimDatapoints(aggregations: any, target: any) {
|
trimDatapoints(aggregations: any, target: ElasticsearchQuery) {
|
||||||
const histogram: any = _.find(target.bucketAggs, { type: 'date_histogram' });
|
const histogram: any = _.find(target.bucketAggs, { type: 'date_histogram' });
|
||||||
|
|
||||||
const shouldDropFirstAndLast = histogram && histogram.settings && histogram.settings.trimEdges;
|
const shouldDropFirstAndLast = histogram && histogram.settings && histogram.settings.trimEdges;
|
||||||
@ -395,7 +421,7 @@ export class ElasticResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTimeSeries() {
|
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.processResponseToDataFrames(false);
|
||||||
}
|
}
|
||||||
return this.processResponseToSeries();
|
return this.processResponseToSeries();
|
||||||
@ -423,7 +449,7 @@ export class ElasticResponse {
|
|||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
let series = createEmptyDataFrame(
|
let series = createEmptyDataFrame(
|
||||||
propNames,
|
propNames,
|
||||||
this.targets[0].timeField,
|
this.targets[0].timeField!,
|
||||||
isLogsRequest,
|
isLogsRequest,
|
||||||
logMessageField,
|
logMessageField,
|
||||||
logLevelField
|
logLevelField
|
||||||
@ -498,6 +524,7 @@ export class ElasticResponse {
|
|||||||
|
|
||||||
if (response.aggregations) {
|
if (response.aggregations) {
|
||||||
const aggregations = response.aggregations;
|
const aggregations = response.aggregations;
|
||||||
|
const target = this.targets[i];
|
||||||
const tmpSeriesList: any[] = [];
|
const tmpSeriesList: any[] = [];
|
||||||
const table = new TableModel();
|
const table = new TableModel();
|
||||||
table.refId = target.refId;
|
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 { DataSourcePlugin } from '@grafana/data';
|
||||||
import { ElasticDatasource } from './datasource';
|
import { ElasticDatasource } from './datasource';
|
||||||
import { ElasticQueryCtrl } from './query_ctrl';
|
|
||||||
import { ConfigEditor } from './configuration/ConfigEditor';
|
import { ConfigEditor } from './configuration/ConfigEditor';
|
||||||
|
import { QueryEditor } from './components/QueryEditor';
|
||||||
|
|
||||||
class ElasticAnnotationsQueryCtrl {
|
class ElasticAnnotationsQueryCtrl {
|
||||||
static templateUrl = 'partials/annotations.editor.html';
|
static templateUrl = 'partials/annotations.editor.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const plugin = new DataSourcePlugin(ElasticDatasource)
|
export const plugin = new DataSourcePlugin(ElasticDatasource)
|
||||||
.setQueryCtrl(ElasticQueryCtrl)
|
.setQueryEditor(QueryEditor)
|
||||||
.setConfigEditor(ConfigEditor)
|
.setConfigEditor(ConfigEditor)
|
||||||
.setAnnotationQueryCtrl(ElasticAnnotationsQueryCtrl);
|
.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
|
|
||||||
|
|
||||||
<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.<var></i> to reference a variable.<br/><br/>
|
|
||||||
Elasticsearch pre-v5.0: Scripting language is per default Groovy if not changed. For Groovy use <i><var></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 {
|
||||||
import { ElasticsearchAggregation } from './types';
|
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 {
|
export class ElasticQueryBuilder {
|
||||||
timeField: string;
|
timeField: string;
|
||||||
@ -21,15 +33,18 @@ export class ElasticQueryBuilder {
|
|||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTermsAgg(aggDef: ElasticsearchAggregation, queryNode: { terms?: any; aggs?: any }, target: { metrics: any[] }) {
|
buildTermsAgg(aggDef: Terms, queryNode: { terms?: any; aggs?: any }, target: ElasticsearchQuery) {
|
||||||
let metricRef, metric, y;
|
let metricRef;
|
||||||
queryNode.terms = { field: aggDef.field };
|
queryNode.terms = { field: aggDef.field };
|
||||||
|
|
||||||
if (!aggDef.settings) {
|
if (!aggDef.settings) {
|
||||||
return queryNode;
|
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) {
|
if (aggDef.settings.orderBy !== void 0) {
|
||||||
queryNode.terms.order = {};
|
queryNode.terms.order = {};
|
||||||
if (aggDef.settings.orderBy === '_term' && this.esVersion >= 60) {
|
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
|
// if metric ref, look it up and add it to this agg level
|
||||||
metricRef = parseInt(aggDef.settings.orderBy, 10);
|
metricRef = parseInt(aggDef.settings.orderBy, 10);
|
||||||
if (!isNaN(metricRef)) {
|
if (!isNaN(metricRef)) {
|
||||||
for (y = 0; y < target.metrics.length; y++) {
|
for (let metric of target.metrics || []) {
|
||||||
metric = target.metrics[y];
|
|
||||||
if (metric.id === aggDef.settings.orderBy) {
|
if (metric.id === aggDef.settings.orderBy) {
|
||||||
queryNode.aggs = {};
|
queryNode.aggs = {};
|
||||||
queryNode.aggs[metric.id] = {};
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,7 +84,7 @@ export class ElasticQueryBuilder {
|
|||||||
return queryNode;
|
return queryNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDateHistogramAgg(aggDef: ElasticsearchAggregation) {
|
getDateHistogramAgg(aggDef: DateHistogram) {
|
||||||
const esAgg: any = {};
|
const esAgg: any = {};
|
||||||
const settings = aggDef.settings || {};
|
const settings = aggDef.settings || {};
|
||||||
esAgg.interval = settings.interval;
|
esAgg.interval = settings.interval;
|
||||||
@ -85,33 +101,24 @@ export class ElasticQueryBuilder {
|
|||||||
esAgg.interval = '$__interval';
|
esAgg.interval = '$__interval';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.missing) {
|
|
||||||
esAgg.missing = settings.missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
return esAgg;
|
return esAgg;
|
||||||
}
|
}
|
||||||
|
|
||||||
getHistogramAgg(aggDef: ElasticsearchAggregation) {
|
getHistogramAgg(aggDef: Histogram) {
|
||||||
const esAgg: any = {};
|
const esAgg: any = {};
|
||||||
const settings = aggDef.settings || {};
|
const settings = aggDef.settings || {};
|
||||||
esAgg.interval = settings.interval;
|
esAgg.interval = settings.interval;
|
||||||
esAgg.field = aggDef.field;
|
esAgg.field = aggDef.field;
|
||||||
esAgg.min_doc_count = settings.min_doc_count || 0;
|
esAgg.min_doc_count = settings.min_doc_count || 0;
|
||||||
|
|
||||||
if (settings.missing) {
|
|
||||||
esAgg.missing = settings.missing;
|
|
||||||
}
|
|
||||||
return esAgg;
|
return esAgg;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFiltersAgg(aggDef: ElasticsearchAggregation) {
|
getFiltersAgg(aggDef: Filters) {
|
||||||
const filterObj: any = {};
|
const filterObj: Record<string, { query_string: { query: string; analyze_wildcard: boolean } }> = {};
|
||||||
for (let i = 0; i < aggDef.settings.filters.length; i++) {
|
|
||||||
const query = aggDef.settings.filters[i].query;
|
for (let { query, label } of aggDef.settings?.filters || []) {
|
||||||
let label = aggDef.settings.filters[i].label;
|
filterObj[label || query] = {
|
||||||
label = label === '' || label === undefined ? query : label;
|
|
||||||
filterObj[label] = {
|
|
||||||
query_string: {
|
query_string: {
|
||||||
query: query,
|
query: query,
|
||||||
analyze_wildcard: true,
|
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;
|
// make sure query has defaults;
|
||||||
target.metrics = target.metrics || [queryDef.defaultMetricAgg()];
|
target.metrics = target.metrics || [defaultMetricAgg()];
|
||||||
target.bucketAggs = target.bucketAggs || [queryDef.defaultBucketAgg()];
|
target.bucketAggs = target.bucketAggs || [defaultBucketAgg()];
|
||||||
target.timeField = this.timeField;
|
target.timeField = this.timeField;
|
||||||
|
|
||||||
let i, j, pv, nestedAggs, metric;
|
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') {
|
if (target.metrics?.[0]?.type === 'raw_document' || target.metrics?.[0]?.type === 'raw_data') {
|
||||||
metric = target.metrics[0];
|
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;
|
nestedAggs = query;
|
||||||
|
|
||||||
for (i = 0; i < target.bucketAggs.length; i++) {
|
for (i = 0; i < target.bucketAggs.length; i++) {
|
||||||
const aggDef: any = target.bucketAggs[i];
|
const aggDef = target.bucketAggs[i];
|
||||||
const esAgg: any = {};
|
const esAgg: any = {};
|
||||||
|
|
||||||
switch (aggDef.type) {
|
switch (aggDef.type) {
|
||||||
@ -254,7 +264,7 @@ export class ElasticQueryBuilder {
|
|||||||
case 'geohash_grid': {
|
case 'geohash_grid': {
|
||||||
esAgg['geohash_grid'] = {
|
esAgg['geohash_grid'] = {
|
||||||
field: aggDef.field,
|
field: aggDef.field,
|
||||||
precision: aggDef.settings.precision,
|
precision: aggDef.settings?.precision,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -276,8 +286,8 @@ export class ElasticQueryBuilder {
|
|||||||
const aggField: any = {};
|
const aggField: any = {};
|
||||||
let metricAgg: any = null;
|
let metricAgg: any = null;
|
||||||
|
|
||||||
if (queryDef.isPipelineAgg(metric.type)) {
|
if (isPipelineAggregation(metric)) {
|
||||||
if (queryDef.isPipelineAggWithMultipleBucketPaths(metric.type)) {
|
if (isPipelineAggregationWithMultipleBucketPaths(metric)) {
|
||||||
if (metric.pipelineVariables) {
|
if (metric.pipelineVariables) {
|
||||||
metricAgg = {
|
metricAgg = {
|
||||||
buckets_path: {},
|
buckets_path: {},
|
||||||
@ -287,7 +297,7 @@ export class ElasticQueryBuilder {
|
|||||||
pv = metric.pipelineVariables[j];
|
pv = metric.pipelineVariables[j];
|
||||||
|
|
||||||
if (pv.name && pv.pipelineAgg && /^\d*$/.test(pv.pipelineAgg)) {
|
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) {
|
||||||
if (appliedAgg.type === 'count') {
|
if (appliedAgg.type === 'count') {
|
||||||
metricAgg.buckets_path[pv.name] = '_count';
|
metricAgg.buckets_path[pv.name] = '_count';
|
||||||
@ -301,28 +311,27 @@ export class ElasticQueryBuilder {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (metric.pipelineAgg && /^\d*$/.test(metric.pipelineAgg)) {
|
if (metric.field && /^\d*$/.test(metric.field)) {
|
||||||
const appliedAgg = queryDef.findMetricById(target.metrics, metric.pipelineAgg);
|
const appliedAgg = findMetricById(target.metrics, metric.field);
|
||||||
if (appliedAgg) {
|
if (appliedAgg) {
|
||||||
if (appliedAgg.type === 'count') {
|
if (appliedAgg.type === 'count') {
|
||||||
metricAgg = { buckets_path: '_count' };
|
metricAgg = { buckets_path: '_count' };
|
||||||
} else {
|
} else {
|
||||||
metricAgg = { buckets_path: metric.pipelineAgg };
|
metricAgg = { buckets_path: metric.field };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (isMetricAggregationWithField(metric)) {
|
||||||
metricAgg = { field: metric.field };
|
metricAgg = { field: metric.field };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const prop in metric.settings) {
|
metricAgg = {
|
||||||
if (metric.settings.hasOwnProperty(prop) && metric.settings[prop] !== null) {
|
...metricAgg,
|
||||||
metricAgg[prop] = metric.settings[prop];
|
...(isMetricAggregationWithSettings(metric) && metric.settings),
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
aggField[metric.type] = metricAgg;
|
aggField[metric.type] = metricAgg;
|
||||||
nestedAggs.aggs[metric.id] = aggField;
|
nestedAggs.aggs[metric.id] = aggField;
|
||||||
@ -391,7 +400,7 @@ export class ElasticQueryBuilder {
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLogsQuery(target: any, adhocFilters?: any, querystring?: string) {
|
getLogsQuery(target: ElasticsearchQuery, adhocFilters?: any, querystring?: string) {
|
||||||
let query: any = {
|
let query: any = {
|
||||||
size: 0,
|
size: 0,
|
||||||
query: {
|
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 { BucketAggregation } from './components/QueryEditor/BucketAggregationsEditor/aggregations';
|
||||||
import { ElasticsearchAggregation, ElasticsearchQuery } from './types';
|
import {
|
||||||
|
ExtendedStat,
|
||||||
|
MetricAggregation,
|
||||||
|
MovingAverageModelOption,
|
||||||
|
MetricAggregationType,
|
||||||
|
} from './components/QueryEditor/MetricAggregationsEditor/aggregations';
|
||||||
|
import { metricAggregationConfig, pipelineOptions } from './components/QueryEditor/MetricAggregationsEditor/utils';
|
||||||
|
|
||||||
export const metricAggTypes = [
|
export const extendedStats: ExtendedStat[] = [
|
||||||
{ text: 'Count', value: 'count', requiresField: false },
|
{ label: 'Avg', value: 'avg' },
|
||||||
{
|
{ label: 'Min', value: 'min' },
|
||||||
text: 'Average',
|
{ label: 'Max', value: 'max' },
|
||||||
value: 'avg',
|
{ label: 'Sum', value: 'sum' },
|
||||||
requiresField: true,
|
{ label: 'Count', value: 'count' },
|
||||||
supportsInlineScript: true,
|
{ label: 'Std Dev', value: 'std_deviation' },
|
||||||
supportsMissing: true,
|
{ label: 'Std Dev Upper', value: 'std_deviation_bounds_upper' },
|
||||||
},
|
{ label: 'Std Dev Lower', value: 'std_deviation_bounds_lower' },
|
||||||
{
|
|
||||||
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 bucketAggTypes = [
|
export const movingAvgModelOptions: MovingAverageModelOption[] = [
|
||||||
{ text: 'Terms', value: 'terms', requiresField: true },
|
{ label: 'Simple', value: 'simple' },
|
||||||
{ text: 'Filters', value: 'filters' },
|
{ label: 'Linear', value: 'linear' },
|
||||||
{ text: 'Geo Hash Grid', value: 'geohash_grid', requiresField: true },
|
{ label: 'Exponentially Weighted', value: 'ewma' },
|
||||||
{ text: 'Date Histogram', value: 'date_histogram', requiresField: true },
|
{ label: 'Holt Linear', value: 'holt' },
|
||||||
{ text: 'Histogram', value: 'histogram', requiresField: true },
|
{ label: 'Holt Winters', value: 'holt_winters' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const orderByOptions = [
|
export function defaultMetricAgg(id = '1'): MetricAggregation {
|
||||||
{ text: 'Doc Count', value: '_count' },
|
return { type: 'count', id };
|
||||||
{ 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) {
|
export function defaultBucketAgg(id = '1'): BucketAggregation {
|
||||||
if (!isPipelineAgg(metric.type)) {
|
return { type: 'date_histogram', id, settings: { interval: 'auto' } };
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return pipelineOptions[metric.type];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPipelineAgg(metricType: any) {
|
export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
|
||||||
if (metricType) {
|
metrics.find(metric => metric.id === id);
|
||||||
const po = pipelineOptions[metricType];
|
|
||||||
return po !== null && po !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPipelineAggWithMultipleBucketPaths(metricType: any) {
|
|
||||||
if (metricType) {
|
|
||||||
return metricAggTypes.find(t => t.value === metricType && t.supportsMultipleBucketPaths) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAncestors(target: ElasticsearchQuery, metric?: ElasticsearchAggregation) {
|
|
||||||
const { metrics } = target;
|
|
||||||
if (!metrics) {
|
|
||||||
return (metric && [metric.id]) || [];
|
|
||||||
}
|
|
||||||
const initialAncestors = metric != null ? [metric.id] : ([] as string[]);
|
|
||||||
return metrics.reduce((acc: string[], metric: ElasticsearchAggregation) => {
|
|
||||||
const includedInField = (metric.field && acc.includes(metric.field)) || false;
|
|
||||||
const includedInVariables = metric.pipelineVariables?.some(pv => acc.includes(pv?.pipelineAgg ?? ''));
|
|
||||||
return includedInField || includedInVariables ? [...acc, metric.id] : acc;
|
|
||||||
}, initialAncestors);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPipelineAggOptions(target: ElasticsearchQuery, metric?: ElasticsearchAggregation) {
|
|
||||||
const { metrics } = target;
|
|
||||||
if (!metrics) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const ancestors = getAncestors(target, metric);
|
|
||||||
return metrics.filter(m => !ancestors.includes(m.id)).map(m => ({ text: describeMetric(m), value: m.id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMovingAvgSettings(model: any, filtered: boolean) {
|
|
||||||
const filteredResult: any[] = [];
|
|
||||||
if (filtered) {
|
|
||||||
_.each(movingAvgModelSettings[model], setting => {
|
|
||||||
if (!setting.isCheckbox) {
|
|
||||||
filteredResult.push(setting);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return filteredResult;
|
|
||||||
}
|
|
||||||
return movingAvgModelSettings[model];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrderByOptions(target: any) {
|
|
||||||
const metricRefs: any[] = [];
|
|
||||||
_.each(target.metrics, metric => {
|
|
||||||
if (metric.type !== 'count' && !isPipelineAgg(metric.type)) {
|
|
||||||
metricRefs.push({ text: describeMetric(metric), value: metric.id });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return orderByOptions.concat(metricRefs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeOrder(order: string) {
|
|
||||||
const def: any = _.find(orderOptions, { value: order });
|
|
||||||
return def.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeMetric(metric: ElasticsearchAggregation) {
|
|
||||||
const def: any = _.find(metricAggTypes, { value: metric.type });
|
|
||||||
if (!def.requiresField && !isPipelineAgg(metric.type)) {
|
|
||||||
return def.text;
|
|
||||||
}
|
|
||||||
return def.text + ' ' + metric.field;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeOrderBy(orderBy: any, target: any) {
|
|
||||||
const def: any = _.find(orderByOptions, { value: orderBy });
|
|
||||||
if (def) {
|
|
||||||
return def.text;
|
|
||||||
}
|
|
||||||
const metric: any = _.find(target.metrics, { id: orderBy });
|
|
||||||
if (metric) {
|
|
||||||
return describeMetric(metric);
|
|
||||||
} else {
|
|
||||||
return 'metric not found';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultMetricAgg() {
|
|
||||||
return { type: 'count', id: '1' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultBucketAgg() {
|
|
||||||
return { type: 'date_histogram', id: '2', settings: { interval: 'auto' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const findMetricById = (metrics: any[], id: any) => {
|
|
||||||
return _.find(metrics, { id: id });
|
|
||||||
};
|
|
||||||
|
|
||||||
export function hasMetricOfType(target: any, type: string): boolean {
|
export function hasMetricOfType(target: any, type: string): boolean {
|
||||||
return target && target.metrics && target.metrics.some((m: any) => m.type === type);
|
return target && target.metrics && target.metrics.some((m: any) => m.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Even if we have type guards when building a query, we currently have no way of getting this information from the response.
|
||||||
|
// We should try to find a better (type safe) way of doing the following 2.
|
||||||
|
export function isPipelineAgg(metricType: MetricAggregationType) {
|
||||||
|
return metricType in pipelineOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPipelineAggWithMultipleBucketPaths(metricType: MetricAggregationType) {
|
||||||
|
return !!metricAggregationConfig[metricType].supportsMultipleBucketPaths;
|
||||||
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { DataFrameView, FieldCache, KeyValue, MutableDataFrame } from '@grafana/data';
|
import { DataFrameView, FieldCache, KeyValue, MutableDataFrame } from '@grafana/data';
|
||||||
import { ElasticResponse } from '../elastic_response';
|
import { ElasticResponse } from '../elastic_response';
|
||||||
import flatten from 'app/core/utils/flatten';
|
import flatten from 'app/core/utils/flatten';
|
||||||
|
import { ElasticsearchQuery } from '../types';
|
||||||
|
|
||||||
describe('ElasticResponse', () => {
|
describe('ElasticResponse', () => {
|
||||||
let targets: any;
|
let targets: ElasticsearchQuery[];
|
||||||
let response: any;
|
let response: any;
|
||||||
let result: any;
|
let result: any;
|
||||||
|
|
||||||
@ -12,12 +13,17 @@ describe('ElasticResponse', () => {
|
|||||||
// therefore we only process responses as DataFrames when there's at least one
|
// therefore we only process responses as DataFrames when there's at least one
|
||||||
// raw_data (new) query type.
|
// raw_data (new) query type.
|
||||||
// We should test if refId gets populated wether there's such type of query or not
|
// 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: {
|
target: {
|
||||||
refId: 'COUNT_GROUPBY_DATE_HISTOGRAM',
|
refId: 'COUNT_GROUPBY_DATE_HISTOGRAM',
|
||||||
metrics: [{ type: 'count', id: 'c_1' }],
|
metrics: [{ type: 'count', id: 'c_1' }],
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: 'c_2' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: 'c_2' }],
|
||||||
},
|
} as ElasticsearchQuery,
|
||||||
response: {
|
response: {
|
||||||
aggregations: {
|
aggregations: {
|
||||||
c_2: {
|
c_2: {
|
||||||
@ -32,7 +38,7 @@ describe('ElasticResponse', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const countGroupByHistogramQuery = {
|
const countGroupByHistogramQuery: MockedQueryData = {
|
||||||
target: {
|
target: {
|
||||||
refId: 'COUNT_GROUPBY_HISTOGRAM',
|
refId: 'COUNT_GROUPBY_HISTOGRAM',
|
||||||
metrics: [{ type: 'count', id: 'h_3' }],
|
metrics: [{ type: 'count', id: 'h_3' }],
|
||||||
@ -47,7 +53,7 @@ describe('ElasticResponse', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawDocumentQuery = {
|
const rawDocumentQuery: MockedQueryData = {
|
||||||
target: {
|
target: {
|
||||||
refId: 'RAW_DOC',
|
refId: 'RAW_DOC',
|
||||||
metrics: [{ type: 'raw_document', id: 'r_5' }],
|
metrics: [{ type: 'raw_document', id: 'r_5' }],
|
||||||
@ -73,10 +79,10 @@ describe('ElasticResponse', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentilesQuery = {
|
const percentilesQuery: MockedQueryData = {
|
||||||
target: {
|
target: {
|
||||||
refId: 'PERCENTILE',
|
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' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: 'p_3' }],
|
||||||
},
|
},
|
||||||
response: {
|
response: {
|
||||||
@ -99,7 +105,7 @@ describe('ElasticResponse', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const extendedStatsQuery = {
|
const extendedStatsQuery: MockedQueryData = {
|
||||||
target: {
|
target: {
|
||||||
refId: 'EXTENDEDSTATS',
|
refId: 'EXTENDEDSTATS',
|
||||||
metrics: [
|
metrics: [
|
||||||
@ -475,7 +481,7 @@ describe('ElasticResponse', () => {
|
|||||||
targets = [
|
targets = [
|
||||||
{
|
{
|
||||||
refId: 'A',
|
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' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -508,8 +514,8 @@ describe('ElasticResponse', () => {
|
|||||||
it('should return 2 series', () => {
|
it('should return 2 series', () => {
|
||||||
expect(result.data.length).toBe(2);
|
expect(result.data.length).toBe(2);
|
||||||
expect(result.data[0].datapoints.length).toBe(2);
|
expect(result.data[0].datapoints.length).toBe(2);
|
||||||
expect(result.data[0].target).toBe('p75');
|
expect(result.data[0].target).toBe('p75 @value');
|
||||||
expect(result.data[1].target).toBe('p90');
|
expect(result.data[1].target).toBe('p90 @value');
|
||||||
expect(result.data[0].datapoints[0][0]).toBe(3.3);
|
expect(result.data[0].datapoints[0][0]).toBe(3.3);
|
||||||
expect(result.data[0].datapoints[0][1]).toBe(1000);
|
expect(result.data[0].datapoints[0][1]).toBe(1000);
|
||||||
expect(result.data[1].datapoints[1][0]).toBe(4.5);
|
expect(result.data[1].datapoints[1][0]).toBe(4.5);
|
||||||
@ -528,6 +534,7 @@ describe('ElasticResponse', () => {
|
|||||||
type: 'extended_stats',
|
type: 'extended_stats',
|
||||||
meta: { max: true, std_deviation_bounds_upper: true },
|
meta: { max: true, std_deviation_bounds_upper: true },
|
||||||
id: '1',
|
id: '1',
|
||||||
|
field: '@value',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bucketAggs: [
|
bucketAggs: [
|
||||||
@ -587,8 +594,8 @@ describe('ElasticResponse', () => {
|
|||||||
it('should return 4 series', () => {
|
it('should return 4 series', () => {
|
||||||
expect(result.data.length).toBe(4);
|
expect(result.data.length).toBe(4);
|
||||||
expect(result.data[0].datapoints.length).toBe(1);
|
expect(result.data[0].datapoints.length).toBe(1);
|
||||||
expect(result.data[0].target).toBe('server1 Max');
|
expect(result.data[0].target).toBe('server1 Max @value');
|
||||||
expect(result.data[1].target).toBe('server1 Std Dev Upper');
|
expect(result.data[1].target).toBe('server1 Std Dev Upper @value');
|
||||||
|
|
||||||
expect(result.data[0].datapoints[0][0]).toBe(10.2);
|
expect(result.data[0].datapoints[0][0]).toBe(10.2);
|
||||||
expect(result.data[1].datapoints[0][0]).toBe(3);
|
expect(result.data[1].datapoints[0][0]).toBe(3);
|
||||||
@ -714,7 +721,10 @@ describe('ElasticResponse', () => {
|
|||||||
id: '2',
|
id: '2',
|
||||||
type: 'filters',
|
type: 'filters',
|
||||||
settings: {
|
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' },
|
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
||||||
@ -766,13 +776,16 @@ describe('ElasticResponse', () => {
|
|||||||
targets = [
|
targets = [
|
||||||
{
|
{
|
||||||
refId: 'A',
|
refId: 'A',
|
||||||
metrics: [{ type: 'avg', id: '1' }, { type: 'count' }],
|
metrics: [
|
||||||
|
{ type: 'avg', id: '1', field: '@value' },
|
||||||
|
{ type: 'count', id: '3' },
|
||||||
|
],
|
||||||
bucketAggs: [
|
bucketAggs: [
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
type: 'date_histogram',
|
type: 'date_histogram',
|
||||||
field: 'host',
|
field: 'host',
|
||||||
settings: { trimEdges: 1 },
|
settings: { trimEdges: '1' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -820,7 +833,10 @@ describe('ElasticResponse', () => {
|
|||||||
targets = [
|
targets = [
|
||||||
{
|
{
|
||||||
refId: 'A',
|
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' }],
|
bucketAggs: [{ id: '2', type: 'terms', field: 'host' }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -871,8 +887,8 @@ describe('ElasticResponse', () => {
|
|||||||
targets = [
|
targets = [
|
||||||
{
|
{
|
||||||
refId: 'A',
|
refId: 'A',
|
||||||
metrics: [{ type: 'percentiles', field: 'value', settings: { percents: [75, 90] }, id: '1' }],
|
metrics: [{ type: 'percentiles', field: 'value', settings: { percents: ['75', '90'] }, id: '1' }],
|
||||||
bucketAggs: [{ type: 'term', field: 'id', id: '3' }],
|
bucketAggs: [{ type: 'terms', field: 'id', id: '3' }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
response = {
|
response = {
|
||||||
@ -1016,7 +1032,6 @@ describe('ElasticResponse', () => {
|
|||||||
{ id: '3', type: 'max', field: '@value' },
|
{ id: '3', type: 'max', field: '@value' },
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
field: 'select field',
|
|
||||||
pipelineVariables: [
|
pipelineVariables: [
|
||||||
{ name: 'var1', pipelineAgg: '1' },
|
{ name: 'var1', pipelineAgg: '1' },
|
||||||
{ name: 'var2', pipelineAgg: '3' },
|
{ name: 'var2', pipelineAgg: '3' },
|
||||||
@ -1084,7 +1099,6 @@ describe('ElasticResponse', () => {
|
|||||||
{ id: '3', type: 'max', field: '@value' },
|
{ id: '3', type: 'max', field: '@value' },
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
field: 'select field',
|
|
||||||
pipelineVariables: [
|
pipelineVariables: [
|
||||||
{ name: 'var1', pipelineAgg: '1' },
|
{ name: 'var1', pipelineAgg: '1' },
|
||||||
{ name: 'var2', pipelineAgg: '3' },
|
{ name: 'var2', pipelineAgg: '3' },
|
||||||
@ -1094,7 +1108,6 @@ describe('ElasticResponse', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
field: 'select field',
|
|
||||||
pipelineVariables: [
|
pipelineVariables: [
|
||||||
{ name: 'var1', pipelineAgg: '1' },
|
{ name: 'var1', pipelineAgg: '1' },
|
||||||
{ name: 'var2', pipelineAgg: '3' },
|
{ name: 'var2', pipelineAgg: '3' },
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ElasticQueryBuilder } from '../query_builder';
|
import { ElasticQueryBuilder } from '../query_builder';
|
||||||
|
import { ElasticsearchQuery } from '../types';
|
||||||
|
|
||||||
describe('ElasticQueryBuilder', () => {
|
describe('ElasticQueryBuilder', () => {
|
||||||
const builder = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 2 });
|
const builder = new ElasticQueryBuilder({ timeField: '@timestamp', esVersion: 2 });
|
||||||
@ -13,7 +14,8 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
describe(`version ${builder.esVersion}`, () => {
|
describe(`version ${builder.esVersion}`, () => {
|
||||||
it('should return query with defaults', () => {
|
it('should return query with defaults', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
metrics: [{ type: 'Count', id: '0' }],
|
refId: 'A',
|
||||||
|
metrics: [{ type: 'count', id: '0' }],
|
||||||
timeField: '@timestamp',
|
timeField: '@timestamp',
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }],
|
||||||
});
|
});
|
||||||
@ -24,6 +26,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with multiple bucket aggs', () => {
|
it('with multiple bucket aggs', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
timeField: '@timestamp',
|
timeField: '@timestamp',
|
||||||
bucketAggs: [
|
bucketAggs: [
|
||||||
@ -39,6 +42,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
it('with select field', () => {
|
it('with select field', () => {
|
||||||
const query = builder.build(
|
const query = builder.build(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
metrics: [{ type: 'avg', field: '@value', id: '1' }],
|
metrics: [{ type: 'avg', field: '@value', id: '1' }],
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '2' }],
|
||||||
},
|
},
|
||||||
@ -51,7 +55,8 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('term agg and order by term', () => {
|
it('term agg and order by term', () => {
|
||||||
const target = {
|
const target: ElasticsearchQuery = {
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{ type: 'count', id: '1' },
|
{ type: 'count', id: '1' },
|
||||||
{ type: 'avg', field: '@value', id: '5' },
|
{ type: 'avg', field: '@value', id: '5' },
|
||||||
@ -60,14 +65,16 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
{
|
{
|
||||||
type: 'terms',
|
type: 'terms',
|
||||||
field: '@host',
|
field: '@host',
|
||||||
settings: { size: 5, order: 'asc', orderBy: '_term' },
|
settings: { size: '5', order: 'asc', orderBy: '_term' },
|
||||||
id: '2',
|
id: '2',
|
||||||
},
|
},
|
||||||
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = builder.build(target, 100, '1000');
|
const query = builder.build(target, 100, '1000');
|
||||||
const firstLevel = query.aggs['2'];
|
const firstLevel = query.aggs['2'];
|
||||||
|
|
||||||
if (builder.esVersion >= 60) {
|
if (builder.esVersion >= 60) {
|
||||||
expect(firstLevel.terms.order._key).toBe('asc');
|
expect(firstLevel.terms.order._key).toBe('asc');
|
||||||
} else {
|
} else {
|
||||||
@ -78,6 +85,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
it('with term agg and order by metric agg', () => {
|
it('with term agg and order by metric agg', () => {
|
||||||
const query = builder.build(
|
const query = builder.build(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{ type: 'count', id: '1' },
|
{ type: 'count', id: '1' },
|
||||||
{ type: 'avg', field: '@value', id: '5' },
|
{ type: 'avg', field: '@value', id: '5' },
|
||||||
@ -86,7 +94,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
{
|
{
|
||||||
type: 'terms',
|
type: 'terms',
|
||||||
field: '@host',
|
field: '@host',
|
||||||
settings: { size: 5, order: 'asc', orderBy: '5' },
|
settings: { size: '5', order: 'asc', orderBy: '5' },
|
||||||
id: '2',
|
id: '2',
|
||||||
},
|
},
|
||||||
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
||||||
@ -106,12 +114,13 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
it('with term agg and valid min_doc_count', () => {
|
it('with term agg and valid min_doc_count', () => {
|
||||||
const query = builder.build(
|
const query = builder.build(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
bucketAggs: [
|
bucketAggs: [
|
||||||
{
|
{
|
||||||
type: 'terms',
|
type: 'terms',
|
||||||
field: '@host',
|
field: '@host',
|
||||||
settings: { min_doc_count: 1 },
|
settings: { min_doc_count: '1' },
|
||||||
id: '2',
|
id: '2',
|
||||||
},
|
},
|
||||||
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
{ type: 'date_histogram', field: '@timestamp', id: '3' },
|
||||||
@ -128,6 +137,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
it('with term agg and variable as min_doc_count', () => {
|
it('with term agg and variable as min_doc_count', () => {
|
||||||
const query = builder.build(
|
const query = builder.build(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
bucketAggs: [
|
bucketAggs: [
|
||||||
{
|
{
|
||||||
@ -148,15 +158,19 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('with metric percentiles', () => {
|
it('with metric percentiles', () => {
|
||||||
|
const percents = ['1', '2', '3', '4'];
|
||||||
|
const field = '@load_time';
|
||||||
|
|
||||||
const query = builder.build(
|
const query = builder.build(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
type: 'percentiles',
|
type: 'percentiles',
|
||||||
field: '@load_time',
|
field,
|
||||||
settings: {
|
settings: {
|
||||||
percents: [1, 2, 3, 4],
|
percents,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -168,12 +182,13 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
const firstLevel = query.aggs['3'];
|
const firstLevel = query.aggs['3'];
|
||||||
|
|
||||||
expect(firstLevel.aggs['1'].percentiles.field).toBe('@load_time');
|
expect(firstLevel.aggs['1'].percentiles.field).toBe(field);
|
||||||
expect(firstLevel.aggs['1'].percentiles.percents).toEqual([1, 2, 3, 4]);
|
expect(firstLevel.aggs['1'].percentiles.percents).toEqual(percents);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with filters aggs', () => {
|
it('with filters aggs', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [{ type: 'count', id: '1' }],
|
metrics: [{ type: 'count', id: '1' }],
|
||||||
timeField: '@timestamp',
|
timeField: '@timestamp',
|
||||||
bucketAggs: [
|
bucketAggs: [
|
||||||
@ -181,7 +196,10 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
id: '2',
|
id: '2',
|
||||||
type: 'filters',
|
type: 'filters',
|
||||||
settings: {
|
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' },
|
{ type: 'date_histogram', field: '@timestamp', id: '4' },
|
||||||
@ -194,7 +212,8 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return correct query for raw_document metric', () => {
|
it('should return correct query for raw_document metric', () => {
|
||||||
const target = {
|
const target: ElasticsearchQuery = {
|
||||||
|
refId: 'A',
|
||||||
metrics: [{ type: 'raw_document', id: '1', settings: {} }],
|
metrics: [{ type: 'raw_document', id: '1', settings: {} }],
|
||||||
timeField: '@timestamp',
|
timeField: '@timestamp',
|
||||||
bucketAggs: [] as any[],
|
bucketAggs: [] as any[],
|
||||||
@ -236,7 +255,8 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('should set query size from settings when raw_documents', () => {
|
it('should set query size from settings when raw_documents', () => {
|
||||||
const query = builder.build({
|
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',
|
timeField: '@timestamp',
|
||||||
bucketAggs: [],
|
bucketAggs: [],
|
||||||
});
|
});
|
||||||
@ -246,6 +266,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with moving average', () => {
|
it('with moving average', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@ -256,7 +277,6 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
id: '2',
|
id: '2',
|
||||||
type: 'moving_avg',
|
type: 'moving_avg',
|
||||||
field: '3',
|
field: '3',
|
||||||
pipelineAgg: '3',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
||||||
@ -271,17 +291,16 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with moving average doc count', () => {
|
it('with moving average doc count', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
type: 'count',
|
type: 'count',
|
||||||
field: 'select field',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
type: 'moving_avg',
|
type: 'moving_avg',
|
||||||
field: '3',
|
field: '3',
|
||||||
pipelineAgg: '3',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }],
|
||||||
@ -296,6 +315,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with broken moving average', () => {
|
it('with broken moving average', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@ -305,12 +325,11 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
type: 'moving_avg',
|
type: 'moving_avg',
|
||||||
pipelineAgg: '3',
|
field: '3',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
type: 'moving_avg',
|
type: 'moving_avg',
|
||||||
pipelineAgg: 'Metric to apply moving average',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
||||||
@ -326,6 +345,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with derivative', () => {
|
it('with derivative', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@ -335,7 +355,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
type: 'derivative',
|
type: 'derivative',
|
||||||
pipelineAgg: '3',
|
field: '3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
||||||
@ -350,16 +370,16 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with derivative doc count', () => {
|
it('with derivative doc count', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
type: 'count',
|
type: 'count',
|
||||||
field: 'select field',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
type: 'derivative',
|
type: 'derivative',
|
||||||
pipelineAgg: '3',
|
field: '3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '4' }],
|
||||||
@ -374,6 +394,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with bucket_script', () => {
|
it('with bucket_script', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
@ -386,9 +407,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
field: '@value',
|
field: '@value',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'select field',
|
|
||||||
id: '4',
|
id: '4',
|
||||||
meta: {},
|
|
||||||
pipelineVariables: [
|
pipelineVariables: [
|
||||||
{
|
{
|
||||||
name: 'var1',
|
name: 'var1',
|
||||||
@ -417,16 +436,14 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with bucket_script doc count', () => {
|
it('with bucket_script doc count', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
type: 'count',
|
type: 'count',
|
||||||
field: 'select field',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'select field',
|
|
||||||
id: '4',
|
id: '4',
|
||||||
meta: {},
|
|
||||||
pipelineVariables: [
|
pipelineVariables: [
|
||||||
{
|
{
|
||||||
name: 'var1',
|
name: 'var1',
|
||||||
@ -451,28 +468,32 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
it('with histogram', () => {
|
it('with histogram', () => {
|
||||||
const query = builder.build({
|
const query = builder.build({
|
||||||
|
refId: 'A',
|
||||||
metrics: [{ id: '1', type: 'count' }],
|
metrics: [{ id: '1', type: 'count' }],
|
||||||
bucketAggs: [
|
bucketAggs: [
|
||||||
{
|
{
|
||||||
type: 'histogram',
|
type: 'histogram',
|
||||||
field: 'bytes',
|
field: 'bytes',
|
||||||
id: '3',
|
id: '3',
|
||||||
settings: { interval: 10, min_doc_count: 2, missing: 5 },
|
settings: {
|
||||||
|
interval: '10',
|
||||||
|
min_doc_count: '2',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstLevel = query.aggs['3'];
|
const firstLevel = query.aggs['3'];
|
||||||
expect(firstLevel.histogram.field).toBe('bytes');
|
expect(firstLevel.histogram.field).toBe('bytes');
|
||||||
expect(firstLevel.histogram.interval).toBe(10);
|
expect(firstLevel.histogram.interval).toBe('10');
|
||||||
expect(firstLevel.histogram.min_doc_count).toBe(2);
|
expect(firstLevel.histogram.min_doc_count).toBe('2');
|
||||||
expect(firstLevel.histogram.missing).toBe(5);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with adhoc filters', () => {
|
it('with adhoc filters', () => {
|
||||||
const query = builder.build(
|
const query = builder.build(
|
||||||
{
|
{
|
||||||
metrics: [{ type: 'Count', id: '0' }],
|
refId: 'A',
|
||||||
|
metrics: [{ type: 'count', id: '0' }],
|
||||||
timeField: '@timestamp',
|
timeField: '@timestamp',
|
||||||
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '3' }],
|
||||||
},
|
},
|
||||||
@ -541,7 +562,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
|
|
||||||
describe('getLogsQuery', () => {
|
describe('getLogsQuery', () => {
|
||||||
it('should return query with defaults', () => {
|
it('should return query with defaults', () => {
|
||||||
const query = builder.getLogsQuery({}, null, '*');
|
const query = builder.getLogsQuery({ refId: 'A' }, null, '*');
|
||||||
|
|
||||||
expect(query.size).toEqual(500);
|
expect(query.size).toEqual(500);
|
||||||
|
|
||||||
@ -555,7 +576,9 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
expect(query.sort).toEqual({ '@timestamp': { order: 'desc', unmapped_type: 'boolean' } });
|
expect(query.sort).toEqual({ '@timestamp': { order: 'desc', unmapped_type: 'boolean' } });
|
||||||
|
|
||||||
const expectedAggs = {
|
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: {},
|
aggs: {},
|
||||||
date_histogram: {
|
date_histogram: {
|
||||||
extended_bounds: { max: '$timeTo', min: '$timeFrom' },
|
extended_bounds: { max: '$timeTo', min: '$timeFrom' },
|
||||||
@ -570,7 +593,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('with querystring', () => {
|
it('with querystring', () => {
|
||||||
const query = builder.getLogsQuery({ query: 'foo' }, null, 'foo');
|
const query = builder.getLogsQuery({ refId: 'A', query: 'foo' }, null, 'foo');
|
||||||
|
|
||||||
const expectedQuery = {
|
const expectedQuery = {
|
||||||
bool: {
|
bool: {
|
||||||
@ -584,6 +607,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('with adhoc filters', () => {
|
it('with adhoc filters', () => {
|
||||||
|
// TODO: Types for AdHocFilters
|
||||||
const adhocFilters = [
|
const adhocFilters = [
|
||||||
{ key: 'key1', operator: '=', value: 'value1' },
|
{ key: 'key1', operator: '=', value: 'value1' },
|
||||||
{ key: 'key2', operator: '!=', value: 'value2' },
|
{ key: 'key2', operator: '!=', value: 'value2' },
|
||||||
@ -592,7 +616,7 @@ describe('ElasticQueryBuilder', () => {
|
|||||||
{ key: 'key5', operator: '=~', value: 'value5' },
|
{ key: 'key5', operator: '=~', value: 'value5' },
|
||||||
{ key: 'key6', operator: '!~', value: 'value6' },
|
{ 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[0].match_phrase['key1'].query).toBe('value1');
|
||||||
expect(query.query.bool.must_not[0].match_phrase['key2'].query).toBe('value2');
|
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('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('isPipelineMetric', () => {
|
||||||
describe('moving_avg', () => {
|
describe('moving_avg', () => {
|
||||||
const result = queryDef.isPipelineAgg('moving_avg');
|
const result = isPipelineAgg('moving_avg');
|
||||||
|
|
||||||
test('is pipe line metric', () => {
|
test('is pipe line metric', () => {
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
@ -116,7 +11,7 @@ describe('ElasticQueryDef', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('count', () => {
|
describe('count', () => {
|
||||||
const result = queryDef.isPipelineAgg('count');
|
const result = isPipelineAgg('count');
|
||||||
|
|
||||||
test('is not pipe line metric', () => {
|
test('is not pipe line metric', () => {
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
@ -126,7 +21,7 @@ describe('ElasticQueryDef', () => {
|
|||||||
|
|
||||||
describe('isPipelineAggWithMultipleBucketPaths', () => {
|
describe('isPipelineAggWithMultipleBucketPaths', () => {
|
||||||
describe('bucket_script', () => {
|
describe('bucket_script', () => {
|
||||||
const result = queryDef.isPipelineAggWithMultipleBucketPaths('bucket_script');
|
const result = isPipelineAggWithMultipleBucketPaths('bucket_script');
|
||||||
|
|
||||||
test('should have multiple bucket paths support', () => {
|
test('should have multiple bucket paths support', () => {
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
@ -134,50 +29,11 @@ describe('ElasticQueryDef', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('moving_avg', () => {
|
describe('moving_avg', () => {
|
||||||
const result = queryDef.isPipelineAggWithMultipleBucketPaths('moving_avg');
|
const result = isPipelineAggWithMultipleBucketPaths('moving_avg');
|
||||||
|
|
||||||
test('should not have multiple bucket paths support', () => {
|
test('should not have multiple bucket paths support', () => {
|
||||||
expect(result).toBe(false);
|
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 { 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 {
|
export interface ElasticsearchOptions extends DataSourceJsonData {
|
||||||
timeField: string;
|
timeField: string;
|
||||||
@ -11,20 +19,50 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
|
|||||||
dataLinks?: DataLinkConfig[];
|
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 {
|
export interface ElasticsearchAggregation {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: MetricAggregationType | BucketAggregationType;
|
||||||
settings?: any;
|
settings?: unknown;
|
||||||
field?: string;
|
field?: string;
|
||||||
pipelineVariables?: Array<{ name?: string; pipelineAgg?: string }>;
|
hide: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElasticsearchQuery extends DataQuery {
|
export interface ElasticsearchQuery extends DataQuery {
|
||||||
isLogsQuery: boolean;
|
isLogsQuery?: boolean;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
bucketAggs?: ElasticsearchAggregation[];
|
bucketAggs?: BucketAggregation[];
|
||||||
metrics?: ElasticsearchAggregation[];
|
metrics?: MetricAggregation[];
|
||||||
|
timeField?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataLinkConfig = {
|
export type DataLinkConfig = {
|
||||||
|
36
public/app/plugins/datasource/elasticsearch/utils.test.ts
Normal file
36
public/app/plugins/datasource/elasticsearch/utils.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
54
public/app/plugins/datasource/elasticsearch/utils.ts
Normal file
54
public/app/plugins/datasource/elasticsearch/utils.ts
Normal file
@ -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 shiftTime = eventFactory<number>('shift-time');
|
||||||
|
|
||||||
export const elasticQueryUpdated = eventFactory('elastic-query-updated');
|
|
||||||
|
|
||||||
export const routeUpdated = eventFactory('$routeUpdate');
|
export const routeUpdated = eventFactory('$routeUpdate');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user