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