mirror of https://github.com/grafana/grafana
Elasticsearch: Migrate queryeditor to React (#28033)
Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com>pull/29622/head
parent
3d6380a0aa
commit
bb45f5fedc
@ -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 |
||||
>; |
@ -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,118 +0,0 @@ |
||||
import './bucket_agg'; |
||||
import './metric_agg'; |
||||
import './pipeline_variables'; |
||||
|
||||
import angular, { auto } from 'angular'; |
||||
import _ from 'lodash'; |
||||
import * as queryDef from './query_def'; |
||||
import { QueryCtrl } from 'app/plugins/sdk'; |
||||
import { ElasticsearchAggregation } from './types'; |
||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; |
||||
import { CoreEvents } from 'app/types'; |
||||
|
||||
export class ElasticQueryCtrl extends QueryCtrl { |
||||
static templateUrl = 'partials/query.editor.html'; |
||||
|
||||
esVersion: any; |
||||
rawQueryOld: string; |
||||
targetMetricsOld: string; |
||||
|
||||
/** @ngInject */ |
||||
constructor( |
||||
$scope: any, |
||||
$injector: auto.IInjectorService, |
||||
private $rootScope: GrafanaRootScope, |
||||
private uiSegmentSrv: any |
||||
) { |
||||
super($scope, $injector); |
||||
|
||||
this.esVersion = this.datasource.esVersion; |
||||
|
||||
this.target = this.target || {}; |
||||
this.target.metrics = this.target.metrics || [queryDef.defaultMetricAgg()]; |
||||
this.target.bucketAggs = this.target.bucketAggs || [queryDef.defaultBucketAgg()]; |
||||
|
||||
if (this.target.bucketAggs.length === 0) { |
||||
const metric = this.target.metrics[0]; |
||||
if (!metric || metric.type !== 'raw_document') { |
||||
this.target.bucketAggs = [queryDef.defaultBucketAgg()]; |
||||
} |
||||
this.refresh(); |
||||
} |
||||
|
||||
this.queryUpdated(); |
||||
} |
||||
|
||||
getFields(type: any) { |
||||
const jsonStr = angular.toJson({ find: 'fields', type: type }); |
||||
return this.datasource |
||||
.metricFindQuery(jsonStr) |
||||
.then(this.uiSegmentSrv.transformToSegments(false)) |
||||
.catch(this.handleQueryError.bind(this)); |
||||
} |
||||
|
||||
queryUpdated() { |
||||
const newJsonTargetMetrics = angular.toJson(this.target.metrics); |
||||
const newJsonRawQuery = angular.toJson(this.datasource.queryBuilder.build(this.target), true); |
||||
if ( |
||||
(this.rawQueryOld && newJsonRawQuery !== this.rawQueryOld) || |
||||
(this.targetMetricsOld && newJsonTargetMetrics !== this.targetMetricsOld) |
||||
) { |
||||
this.refresh(); |
||||
} |
||||
|
||||
this.rawQueryOld = newJsonRawQuery; |
||||
this.targetMetricsOld = newJsonTargetMetrics; |
||||
this.$rootScope.appEvent(CoreEvents.elasticQueryUpdated); |
||||
} |
||||
|
||||
getCollapsedText() { |
||||
const metricAggs: ElasticsearchAggregation[] = this.target.metrics; |
||||
const bucketAggs = this.target.bucketAggs; |
||||
const metricAggTypes = queryDef.getMetricAggTypes(this.esVersion); |
||||
const bucketAggTypes = queryDef.bucketAggTypes; |
||||
let text = ''; |
||||
|
||||
if (this.target.query) { |
||||
text += 'Query: ' + this.target.query + ', '; |
||||
} |
||||
|
||||
text += 'Metrics: '; |
||||
|
||||
_.each(metricAggs, (metric, index) => { |
||||
const aggDef: any = _.find(metricAggTypes, { value: metric.type }); |
||||
text += aggDef.text + '('; |
||||
if (aggDef.requiresField) { |
||||
text += metric.field; |
||||
} |
||||
if (aggDef.supportsMultipleBucketPaths) { |
||||
text += metric.settings.script.replace(new RegExp('params.', 'g'), ''); |
||||
} |
||||
text += '), '; |
||||
}); |
||||
|
||||
_.each(bucketAggs, (bucketAgg: any, index: number) => { |
||||
if (index === 0) { |
||||
text += ' Group by: '; |
||||
} |
||||
|
||||
const aggDef: any = _.find(bucketAggTypes, { value: bucketAgg.type }); |
||||
text += aggDef.text + '('; |
||||
if (aggDef.requiresField) { |
||||
text += bucketAgg.field; |
||||
} |
||||
text += '), '; |
||||
}); |
||||
|
||||
if (this.target.alias) { |
||||
text += 'Alias: ' + this.target.alias; |
||||
} |
||||
|
||||
return text; |
||||
} |
||||
|
||||
handleQueryError(err: any): any[] { |
||||
this.error = err.message || 'Failed to issue metric query'; |
||||
return []; |
||||
} |
||||
} |
@ -1,308 +1,52 @@ |
||||
import _ from 'lodash'; |
||||
import { ElasticsearchAggregation, ElasticsearchQuery } from './types'; |
||||
import { BucketAggregation } from './components/QueryEditor/BucketAggregationsEditor/aggregations'; |
||||
import { |
||||
ExtendedStat, |
||||
MetricAggregation, |
||||
MovingAverageModelOption, |
||||
MetricAggregationType, |
||||
} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; |
||||
import { metricAggregationConfig, pipelineOptions } from './components/QueryEditor/MetricAggregationsEditor/utils'; |
||||
|
||||
export const metricAggTypes = [ |
||||
{ text: 'Count', value: 'count', requiresField: false }, |
||||
{ |
||||
text: 'Average', |
||||
value: 'avg', |
||||
requiresField: true, |
||||
supportsInlineScript: true, |
||||
supportsMissing: true, |
||||
}, |
||||
{ |
||||
text: 'Sum', |
||||
value: 'sum', |
||||
requiresField: true, |
||||
supportsInlineScript: true, |
||||
supportsMissing: true, |
||||
}, |
||||
{ |
||||
text: 'Max', |
||||
value: 'max', |
||||
requiresField: true, |
||||
supportsInlineScript: true, |
||||
supportsMissing: true, |
||||
}, |
||||
{ |
||||
text: 'Min', |
||||
value: 'min', |
||||
requiresField: true, |
||||
supportsInlineScript: true, |
||||
supportsMissing: true, |
||||
}, |
||||
{ |
||||
text: 'Extended Stats', |
||||
value: 'extended_stats', |
||||
requiresField: true, |
||||
supportsMissing: true, |
||||
supportsInlineScript: true, |
||||
}, |
||||
{ |
||||
text: 'Percentiles', |
||||
value: 'percentiles', |
||||
requiresField: true, |
||||
supportsMissing: true, |
||||
supportsInlineScript: true, |
||||
}, |
||||
{ |
||||
text: 'Unique Count', |
||||
value: 'cardinality', |
||||
requiresField: true, |
||||
supportsMissing: true, |
||||
}, |
||||
{ |
||||
text: 'Moving Average', |
||||
value: 'moving_avg', |
||||
requiresField: false, |
||||
isPipelineAgg: true, |
||||
minVersion: 2, |
||||
maxVersion: 60, |
||||
}, |
||||
{ |
||||
text: 'Moving Function', |
||||
value: 'moving_fn', |
||||
requiresField: false, |
||||
isPipelineAgg: true, |
||||
minVersion: 70, |
||||
}, |
||||
{ |
||||
text: 'Derivative', |
||||
value: 'derivative', |
||||
requiresField: false, |
||||
isPipelineAgg: true, |
||||
minVersion: 2, |
||||
}, |
||||
{ |
||||
text: 'Cumulative Sum', |
||||
value: 'cumulative_sum', |
||||
requiresField: false, |
||||
isPipelineAgg: true, |
||||
minVersion: 2, |
||||
}, |
||||
{ |
||||
text: 'Bucket Script', |
||||
value: 'bucket_script', |
||||
requiresField: false, |
||||
isPipelineAgg: true, |
||||
supportsMultipleBucketPaths: true, |
||||
minVersion: 2, |
||||
}, |
||||
{ text: 'Raw Document (legacy)', value: 'raw_document', requiresField: false }, |
||||
{ text: 'Raw Data', value: 'raw_data', requiresField: false }, |
||||
{ text: 'Logs', value: 'logs', requiresField: false }, |
||||
export const extendedStats: ExtendedStat[] = [ |
||||
{ label: 'Avg', value: 'avg' }, |
||||
{ label: 'Min', value: 'min' }, |
||||
{ label: 'Max', value: 'max' }, |
||||
{ label: 'Sum', value: 'sum' }, |
||||
{ label: 'Count', value: 'count' }, |
||||
{ label: 'Std Dev', value: 'std_deviation' }, |
||||
{ label: 'Std Dev Upper', value: 'std_deviation_bounds_upper' }, |
||||
{ label: 'Std Dev Lower', value: 'std_deviation_bounds_lower' }, |
||||
]; |
||||
|
||||
export const bucketAggTypes = [ |
||||
{ text: 'Terms', value: 'terms', requiresField: true }, |
||||
{ text: 'Filters', value: 'filters' }, |
||||
{ text: 'Geo Hash Grid', value: 'geohash_grid', requiresField: true }, |
||||
{ text: 'Date Histogram', value: 'date_histogram', requiresField: true }, |
||||
{ text: 'Histogram', value: 'histogram', requiresField: true }, |
||||
export const movingAvgModelOptions: MovingAverageModelOption[] = [ |
||||
{ label: 'Simple', value: 'simple' }, |
||||
{ label: 'Linear', value: 'linear' }, |
||||
{ label: 'Exponentially Weighted', value: 'ewma' }, |
||||
{ label: 'Holt Linear', value: 'holt' }, |
||||
{ label: 'Holt Winters', value: 'holt_winters' }, |
||||
]; |
||||
|
||||
export const orderByOptions = [ |
||||
{ text: 'Doc Count', value: '_count' }, |
||||
{ text: 'Term value', value: '_term' }, |
||||
]; |
||||
|
||||
export const orderOptions = [ |
||||
{ text: 'Top', value: 'desc' }, |
||||
{ text: 'Bottom', value: 'asc' }, |
||||
]; |
||||
|
||||
export const sizeOptions = [ |
||||
{ text: 'No limit', value: '0' }, |
||||
{ text: '1', value: '1' }, |
||||
{ text: '2', value: '2' }, |
||||
{ text: '3', value: '3' }, |
||||
{ text: '5', value: '5' }, |
||||
{ text: '10', value: '10' }, |
||||
{ text: '15', value: '15' }, |
||||
{ text: '20', value: '20' }, |
||||
]; |
||||
|
||||
export const extendedStats = [ |
||||
{ text: 'Avg', value: 'avg' }, |
||||
{ text: 'Min', value: 'min' }, |
||||
{ text: 'Max', value: 'max' }, |
||||
{ text: 'Sum', value: 'sum' }, |
||||
{ text: 'Count', value: 'count' }, |
||||
{ text: 'Std Dev', value: 'std_deviation' }, |
||||
{ text: 'Std Dev Upper', value: 'std_deviation_bounds_upper' }, |
||||
{ text: 'Std Dev Lower', value: 'std_deviation_bounds_lower' }, |
||||
]; |
||||
|
||||
export const intervalOptions = [ |
||||
{ text: 'auto', value: 'auto' }, |
||||
{ text: '10s', value: '10s' }, |
||||
{ text: '1m', value: '1m' }, |
||||
{ text: '5m', value: '5m' }, |
||||
{ text: '10m', value: '10m' }, |
||||
{ text: '20m', value: '20m' }, |
||||
{ text: '1h', value: '1h' }, |
||||
{ text: '1d', value: '1d' }, |
||||
]; |
||||
|
||||
export const movingAvgModelOptions = [ |
||||
{ text: 'Simple', value: 'simple' }, |
||||
{ text: 'Linear', value: 'linear' }, |
||||
{ text: 'Exponentially Weighted', value: 'ewma' }, |
||||
{ text: 'Holt Linear', value: 'holt' }, |
||||
{ text: 'Holt Winters', value: 'holt_winters' }, |
||||
]; |
||||
|
||||
export const pipelineOptions: any = { |
||||
moving_avg: [ |
||||
{ text: 'window', default: 5 }, |
||||
{ text: 'model', default: 'simple' }, |
||||
{ text: 'predict', default: undefined }, |
||||
{ text: 'minimize', default: false }, |
||||
], |
||||
moving_fn: [{ text: 'window', default: 5 }, { text: 'script' }], |
||||
derivative: [{ text: 'unit', default: undefined }], |
||||
cumulative_sum: [{ text: 'format', default: undefined }], |
||||
bucket_script: [], |
||||
}; |
||||
|
||||
export const movingAvgModelSettings: any = { |
||||
simple: [], |
||||
linear: [], |
||||
ewma: [{ text: 'Alpha', value: 'alpha', default: undefined }], |
||||
holt: [ |
||||
{ text: 'Alpha', value: 'alpha', default: undefined }, |
||||
{ text: 'Beta', value: 'beta', default: undefined }, |
||||
], |
||||
holt_winters: [ |
||||
{ text: 'Alpha', value: 'alpha', default: undefined }, |
||||
{ text: 'Beta', value: 'beta', default: undefined }, |
||||
{ text: 'Gamma', value: 'gamma', default: undefined }, |
||||
{ text: 'Period', value: 'period', default: undefined }, |
||||
{ text: 'Pad', value: 'pad', default: undefined, isCheckbox: true }, |
||||
], |
||||
}; |
||||
|
||||
export function getMetricAggTypes(esVersion: any) { |
||||
return _.filter(metricAggTypes, f => { |
||||
if (f.minVersion || f.maxVersion) { |
||||
const minVersion = f.minVersion || 0; |
||||
const maxVersion = f.maxVersion || esVersion; |
||||
return esVersion >= minVersion && esVersion <= maxVersion; |
||||
} else { |
||||
return true; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
export function getPipelineOptions(metric: any) { |
||||
if (!isPipelineAgg(metric.type)) { |
||||
return []; |
||||
} |
||||
|
||||
return pipelineOptions[metric.type]; |
||||
} |
||||
|
||||
export function isPipelineAgg(metricType: any) { |
||||
if (metricType) { |
||||
const po = pipelineOptions[metricType]; |
||||
return po !== null && po !== undefined; |
||||
} |
||||
|
||||
return false; |
||||
export function defaultMetricAgg(id = '1'): MetricAggregation { |
||||
return { type: 'count', id }; |
||||
} |
||||
|
||||
export function isPipelineAggWithMultipleBucketPaths(metricType: any) { |
||||
if (metricType) { |
||||
return metricAggTypes.find(t => t.value === metricType && t.supportsMultipleBucketPaths) !== undefined; |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
export function getAncestors(target: ElasticsearchQuery, metric?: ElasticsearchAggregation) { |
||||
const { metrics } = target; |
||||
if (!metrics) { |
||||
return (metric && [metric.id]) || []; |
||||
} |
||||
const initialAncestors = metric != null ? [metric.id] : ([] as string[]); |
||||
return metrics.reduce((acc: string[], metric: ElasticsearchAggregation) => { |
||||
const includedInField = (metric.field && acc.includes(metric.field)) || false; |
||||
const includedInVariables = metric.pipelineVariables?.some(pv => acc.includes(pv?.pipelineAgg ?? '')); |
||||
return includedInField || includedInVariables ? [...acc, metric.id] : acc; |
||||
}, initialAncestors); |
||||
} |
||||
|
||||
export function getPipelineAggOptions(target: ElasticsearchQuery, metric?: ElasticsearchAggregation) { |
||||
const { metrics } = target; |
||||
if (!metrics) { |
||||
return []; |
||||
} |
||||
const ancestors = getAncestors(target, metric); |
||||
return metrics.filter(m => !ancestors.includes(m.id)).map(m => ({ text: describeMetric(m), value: m.id })); |
||||
} |
||||
|
||||
export function getMovingAvgSettings(model: any, filtered: boolean) { |
||||
const filteredResult: any[] = []; |
||||
if (filtered) { |
||||
_.each(movingAvgModelSettings[model], setting => { |
||||
if (!setting.isCheckbox) { |
||||
filteredResult.push(setting); |
||||
} |
||||
}); |
||||
return filteredResult; |
||||
} |
||||
return movingAvgModelSettings[model]; |
||||
} |
||||
|
||||
export function getOrderByOptions(target: any) { |
||||
const metricRefs: any[] = []; |
||||
_.each(target.metrics, metric => { |
||||
if (metric.type !== 'count' && !isPipelineAgg(metric.type)) { |
||||
metricRefs.push({ text: describeMetric(metric), value: metric.id }); |
||||
} |
||||
}); |
||||
|
||||
return orderByOptions.concat(metricRefs); |
||||
export function defaultBucketAgg(id = '1'): BucketAggregation { |
||||
return { type: 'date_histogram', id, settings: { interval: 'auto' } }; |
||||
} |
||||
|
||||
export function describeOrder(order: string) { |
||||
const def: any = _.find(orderOptions, { value: order }); |
||||
return def.text; |
||||
} |
||||
|
||||
export function describeMetric(metric: ElasticsearchAggregation) { |
||||
const def: any = _.find(metricAggTypes, { value: metric.type }); |
||||
if (!def.requiresField && !isPipelineAgg(metric.type)) { |
||||
return def.text; |
||||
} |
||||
return def.text + ' ' + metric.field; |
||||
} |
||||
|
||||
export function describeOrderBy(orderBy: any, target: any) { |
||||
const def: any = _.find(orderByOptions, { value: orderBy }); |
||||
if (def) { |
||||
return def.text; |
||||
} |
||||
const metric: any = _.find(target.metrics, { id: orderBy }); |
||||
if (metric) { |
||||
return describeMetric(metric); |
||||
} else { |
||||
return 'metric not found'; |
||||
} |
||||
} |
||||
export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) => |
||||
metrics.find(metric => metric.id === id); |
||||
|
||||
export function defaultMetricAgg() { |
||||
return { type: 'count', id: '1' }; |
||||
export function hasMetricOfType(target: any, type: string): boolean { |
||||
return target && target.metrics && target.metrics.some((m: any) => m.type === type); |
||||
} |
||||
|
||||
export function defaultBucketAgg() { |
||||
return { type: 'date_histogram', id: '2', settings: { interval: 'auto' } }; |
||||
// Even if we have type guards when building a query, we currently have no way of getting this information from the response.
|
||||
// We should try to find a better (type safe) way of doing the following 2.
|
||||
export function isPipelineAgg(metricType: MetricAggregationType) { |
||||
return metricType in pipelineOptions; |
||||
} |
||||
|
||||
export const findMetricById = (metrics: any[], id: any) => { |
||||
return _.find(metrics, { id: id }); |
||||
}; |
||||
|
||||
export function hasMetricOfType(target: any, type: string): boolean { |
||||
return target && target.metrics && target.metrics.some((m: any) => m.type === type); |
||||
export function isPipelineAggWithMultipleBucketPaths(metricType: MetricAggregationType) { |
||||
return !!metricAggregationConfig[metricType].supportsMultipleBucketPaths; |
||||
} |
||||
|
@ -0,0 +1,36 @@ |
||||
import { removeEmpty } from './utils'; |
||||
|
||||
describe('removeEmpty', () => { |
||||
it('Should remove all empty', () => { |
||||
const original = { |
||||
stringsShouldBeKept: 'Something', |
||||
unlessTheyAreEmpty: '', |
||||
nullToBeRemoved: null, |
||||
undefinedToBeRemoved: null, |
||||
zeroShouldBeKept: 0, |
||||
booleansShouldBeKept: false, |
||||
emptyObjectsShouldBeRemoved: {}, |
||||
emptyArrayShouldBeRemoved: [], |
||||
nonEmptyArraysShouldBeKept: [1, 2, 3], |
||||
nestedObjToBeRemoved: { |
||||
toBeRemoved: undefined, |
||||
}, |
||||
nestedObjectToKeep: { |
||||
thisShouldBeRemoved: null, |
||||
thisShouldBeKept: 'Hello, Grafana', |
||||
}, |
||||
}; |
||||
|
||||
const expectedResult = { |
||||
stringsShouldBeKept: 'Something', |
||||
zeroShouldBeKept: 0, |
||||
booleansShouldBeKept: false, |
||||
nonEmptyArraysShouldBeKept: [1, 2, 3], |
||||
nestedObjectToKeep: { |
||||
thisShouldBeKept: 'Hello, Grafana', |
||||
}, |
||||
}; |
||||
|
||||
expect(removeEmpty(original)).toStrictEqual(expectedResult); |
||||
}); |
||||
}); |
@ -0,0 +1,54 @@ |
||||
import { |
||||
isMetricAggregationWithField, |
||||
MetricAggregation, |
||||
} from './components/QueryEditor/MetricAggregationsEditor/aggregations'; |
||||
import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; |
||||
|
||||
export const describeMetric = (metric: MetricAggregation) => { |
||||
if (!isMetricAggregationWithField(metric)) { |
||||
return metricAggregationConfig[metric.type].label; |
||||
} |
||||
|
||||
// TODO: field might be undefined
|
||||
return `${metricAggregationConfig[metric.type].label} ${metric.field}`; |
||||
}; |
||||
|
||||
/** |
||||
* Utility function to clean up aggregations settings objects. |
||||
* It removes nullish values and empty strings, array and objects |
||||
* recursing over nested objects (not arrays). |
||||
* @param obj |
||||
*/ |
||||
export const removeEmpty = <T>(obj: T): Partial<T> => |
||||
Object.entries(obj).reduce((acc, [key, value]) => { |
||||
// Removing nullish values (null & undefined)
|
||||
if (value == null) { |
||||
return { ...acc }; |
||||
} |
||||
|
||||
// Removing empty arrays (This won't recurse the array)
|
||||
if (Array.isArray(value) && value.length === 0) { |
||||
return { ...acc }; |
||||
} |
||||
|
||||
// Removing empty strings
|
||||
if (value?.length === 0) { |
||||
return { ...acc }; |
||||
} |
||||
|
||||
// Recursing over nested objects
|
||||
if (!Array.isArray(value) && typeof value === 'object') { |
||||
const cleanObj = removeEmpty(value); |
||||
|
||||
if (Object.keys(cleanObj).length === 0) { |
||||
return { ...acc }; |
||||
} |
||||
|
||||
return { ...acc, [key]: cleanObj }; |
||||
} |
||||
|
||||
return { |
||||
...acc, |
||||
[key]: value, |
||||
}; |
||||
}, {}); |
Loading…
Reference in new issue