mirror of https://github.com/grafana/grafana
CloudMonitoring: Add support for preprocessing (#33011)
* add support for handling preprocessors in the backend * add preprocessor tests * use uppercase for constants * add super label component * remove error message from query editor since its not working (probably cause onDataError doesnt work anymore) * use cheat sheet instead of help * add return type annotation for projects * add support for preprocessing. replace segment comp with select. change components names and refactoring * cleanup * more pr feedback * fix annotations editor * rename aggregation component * fix broken test * remove unnecessary cast * fix strict errors * fix more strict errors * remove not used prop * update docs * use same inline label for annotation editor * fix react prop warning * disable preprocessing for distribution types * using new default values for reducer * auto select 'rate' if metric kind is not gauge * fix create label format * pr feedback * more pr feedback * update imagespull/34357/head
parent
e3188458d5
commit
5042dc3b52
@ -0,0 +1,22 @@ |
||||
package cloudmonitoring |
||||
|
||||
type preprocessorType int |
||||
|
||||
const ( |
||||
PreprocessorTypeNone preprocessorType = iota |
||||
PreprocessorTypeRate |
||||
PreprocessorTypeDelta |
||||
) |
||||
|
||||
func toPreprocessorType(preprocessorTypeString string) preprocessorType { |
||||
switch preprocessorTypeString { |
||||
case "none": |
||||
return PreprocessorTypeNone |
||||
case "rate": |
||||
return PreprocessorTypeRate |
||||
case "delta": |
||||
return PreprocessorTypeDelta |
||||
default: |
||||
return PreprocessorTypeNone |
||||
} |
||||
} |
@ -0,0 +1,65 @@ |
||||
import React, { FC, useMemo } from 'react'; |
||||
|
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { QueryEditorField } from '.'; |
||||
import { getAggregationOptionsByMetric } from '../functions'; |
||||
import { MetricDescriptor, ValueTypes, MetricKind } from '../types'; |
||||
|
||||
export interface Props { |
||||
onChange: (metricDescriptor: string) => void; |
||||
metricDescriptor?: MetricDescriptor; |
||||
crossSeriesReducer: string; |
||||
groupBys: string[]; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
} |
||||
|
||||
export const Aggregation: FC<Props> = (props) => { |
||||
const aggOptions = useAggregationOptionsByMetric(props); |
||||
const selected = useSelectedFromOptions(aggOptions, props); |
||||
|
||||
return ( |
||||
<QueryEditorField labelWidth={18} label="Group by function" data-testid="cloud-monitoring-aggregation"> |
||||
<Select |
||||
width={16} |
||||
onChange={({ value }) => props.onChange(value!)} |
||||
value={selected} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: props.templateVariableOptions, |
||||
}, |
||||
{ |
||||
label: 'Aggregations', |
||||
expanded: true, |
||||
options: aggOptions, |
||||
}, |
||||
]} |
||||
placeholder="Select Reducer" |
||||
/> |
||||
</QueryEditorField> |
||||
); |
||||
}; |
||||
|
||||
const useAggregationOptionsByMetric = ({ metricDescriptor }: Props): Array<SelectableValue<string>> => { |
||||
const valueType = metricDescriptor?.valueType; |
||||
const metricKind = metricDescriptor?.metricKind; |
||||
|
||||
return useMemo(() => { |
||||
if (!valueType || !metricKind) { |
||||
return []; |
||||
} |
||||
|
||||
return getAggregationOptionsByMetric(valueType as ValueTypes, metricKind as MetricKind).map((a) => ({ |
||||
...a, |
||||
label: a.text, |
||||
})); |
||||
}, [valueType, metricKind]); |
||||
}; |
||||
|
||||
const useSelectedFromOptions = (aggOptions: Array<SelectableValue<string>>, props: Props) => { |
||||
return useMemo(() => { |
||||
const allOptions = [...aggOptions, ...props.templateVariableOptions]; |
||||
return allOptions.find((s) => s.value === props.crossSeriesReducer); |
||||
}, [aggOptions, props.crossSeriesReducer, props.templateVariableOptions]); |
||||
}; |
@ -1,79 +0,0 @@ |
||||
import React, { FC, useState, useMemo } from 'react'; |
||||
|
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Segment, Icon } from '@grafana/ui'; |
||||
import { getAggregationOptionsByMetric } from '../functions'; |
||||
import { ValueTypes, MetricKind } from '../constants'; |
||||
import { MetricDescriptor } from '../types'; |
||||
|
||||
export interface Props { |
||||
onChange: (metricDescriptor: string) => void; |
||||
metricDescriptor?: MetricDescriptor; |
||||
crossSeriesReducer: string; |
||||
groupBys: string[]; |
||||
children: (displayAdvancedOptions: boolean) => React.ReactNode; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
} |
||||
|
||||
export const Aggregations: FC<Props> = (props) => { |
||||
const [displayAdvancedOptions, setDisplayAdvancedOptions] = useState(false); |
||||
const aggOptions = useAggregationOptionsByMetric(props); |
||||
const selected = useSelectedFromOptions(aggOptions, props); |
||||
|
||||
return ( |
||||
<div data-testid="aggregations"> |
||||
<div className="gf-form-inline"> |
||||
<label className="gf-form-label query-keyword width-9">Aggregation</label> |
||||
<Segment |
||||
onChange={({ value }) => props.onChange(value!)} |
||||
value={selected} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: props.templateVariableOptions, |
||||
}, |
||||
{ |
||||
label: 'Aggregations', |
||||
expanded: true, |
||||
options: aggOptions, |
||||
}, |
||||
]} |
||||
placeholder="Select Reducer" |
||||
/> |
||||
<div className="gf-form gf-form--grow"> |
||||
<label className="gf-form-label gf-form-label--grow"> |
||||
<a onClick={() => setDisplayAdvancedOptions(!displayAdvancedOptions)}> |
||||
<> |
||||
<Icon name={displayAdvancedOptions ? 'angle-down' : 'angle-right'} /> Advanced Options |
||||
</> |
||||
</a> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
{props.children(displayAdvancedOptions)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const useAggregationOptionsByMetric = ({ metricDescriptor }: Props): Array<SelectableValue<string>> => { |
||||
const valueType = metricDescriptor?.valueType; |
||||
const metricKind = metricDescriptor?.metricKind; |
||||
|
||||
return useMemo(() => { |
||||
if (!valueType || !metricKind) { |
||||
return []; |
||||
} |
||||
|
||||
return getAggregationOptionsByMetric(valueType as ValueTypes, metricKind as MetricKind).map((a) => ({ |
||||
...a, |
||||
label: a.text, |
||||
})); |
||||
}, [valueType, metricKind]); |
||||
}; |
||||
|
||||
const useSelectedFromOptions = (aggOptions: Array<SelectableValue<string>>, props: Props) => { |
||||
return useMemo(() => { |
||||
const allOptions = [...aggOptions, ...props.templateVariableOptions]; |
||||
return allOptions.find((s) => s.value === props.crossSeriesReducer); |
||||
}, [aggOptions, props.crossSeriesReducer, props.templateVariableOptions]); |
||||
}; |
@ -0,0 +1,34 @@ |
||||
import React, { FC } from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { SELECT_WIDTH } from '../constants'; |
||||
import { CustomMetaData, MetricQuery } from '../types'; |
||||
import { AlignmentFunction, AlignmentPeriod, AlignmentPeriodLabel, QueryEditorField, QueryEditorRow } from '.'; |
||||
import CloudMonitoringDatasource from '../datasource'; |
||||
|
||||
export interface Props { |
||||
onChange: (query: MetricQuery) => void; |
||||
query: MetricQuery; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
customMetaData: CustomMetaData; |
||||
datasource: CloudMonitoringDatasource; |
||||
} |
||||
|
||||
export const Alignment: FC<Props> = ({ templateVariableOptions, onChange, query, customMetaData, datasource }) => { |
||||
return ( |
||||
<QueryEditorRow |
||||
label="Alignment function" |
||||
tooltip="The process of alignment consists of collecting all data points received in a fixed length of time, applying a function to combine those data points, and assigning a timestamp to the result." |
||||
fillComponent={<AlignmentPeriodLabel datasource={datasource} customMetaData={customMetaData} />} |
||||
> |
||||
<AlignmentFunction templateVariableOptions={templateVariableOptions} query={query} onChange={onChange} /> |
||||
<QueryEditorField label="Alignment period"> |
||||
<AlignmentPeriod |
||||
selectWidth={SELECT_WIDTH} |
||||
templateVariableOptions={templateVariableOptions} |
||||
query={query} |
||||
onChange={onChange} |
||||
/> |
||||
</QueryEditorField> |
||||
</QueryEditorRow> |
||||
); |
||||
}; |
@ -0,0 +1,40 @@ |
||||
import React, { FC, useMemo } from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { MetricQuery } from '../types'; |
||||
import { getAlignmentPickerData } from '../functions'; |
||||
import { SELECT_WIDTH } from '../constants'; |
||||
|
||||
export interface Props { |
||||
onChange: (query: MetricQuery) => void; |
||||
query: MetricQuery; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
} |
||||
|
||||
export const AlignmentFunction: FC<Props> = ({ query, templateVariableOptions, onChange }) => { |
||||
const { valueType, metricKind, perSeriesAligner: psa, preprocessor } = query; |
||||
const { perSeriesAligner, alignOptions } = useMemo( |
||||
() => getAlignmentPickerData(valueType, metricKind, psa, preprocessor), |
||||
[valueType, metricKind, psa, preprocessor] |
||||
); |
||||
|
||||
return ( |
||||
<Select |
||||
width={SELECT_WIDTH} |
||||
onChange={({ value }) => onChange({ ...query, perSeriesAligner: value! })} |
||||
value={[...alignOptions, ...templateVariableOptions].find((s) => s.value === perSeriesAligner)} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: templateVariableOptions, |
||||
}, |
||||
{ |
||||
label: 'Alignment options', |
||||
expanded: true, |
||||
options: alignOptions, |
||||
}, |
||||
]} |
||||
placeholder="Select Alignment" |
||||
></Select> |
||||
); |
||||
}; |
@ -0,0 +1,44 @@ |
||||
import React, { FC, useMemo } from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { ALIGNMENT_PERIODS } from '../constants'; |
||||
import { BaseQuery } from '../types'; |
||||
|
||||
export interface Props { |
||||
onChange: (query: BaseQuery) => void; |
||||
query: BaseQuery; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
selectWidth?: number; |
||||
} |
||||
|
||||
export const AlignmentPeriod: FC<Props> = ({ templateVariableOptions, onChange, query, selectWidth }) => { |
||||
const options = useMemo( |
||||
() => |
||||
ALIGNMENT_PERIODS.map((ap) => ({ |
||||
...ap, |
||||
label: ap.text, |
||||
})), |
||||
[] |
||||
); |
||||
const visibleOptions = useMemo(() => options.filter((ap) => !ap.hidden), [options]); |
||||
|
||||
return ( |
||||
<Select |
||||
width={selectWidth} |
||||
onChange={({ value }) => onChange({ ...query, alignmentPeriod: value! })} |
||||
value={[...options, ...templateVariableOptions].find((s) => s.value === query.alignmentPeriod)} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: templateVariableOptions, |
||||
}, |
||||
{ |
||||
label: 'Aggregations', |
||||
expanded: true, |
||||
options: visibleOptions, |
||||
}, |
||||
]} |
||||
placeholder="Select Alignment" |
||||
></Select> |
||||
); |
||||
}; |
@ -0,0 +1,26 @@ |
||||
import React, { FC, useMemo } from 'react'; |
||||
import { rangeUtil } from '@grafana/data'; |
||||
import { ALIGNMENTS } from '../constants'; |
||||
import CloudMonitoringDatasource from '../datasource'; |
||||
import { CustomMetaData } from '../types'; |
||||
|
||||
export interface Props { |
||||
customMetaData: CustomMetaData; |
||||
datasource: CloudMonitoringDatasource; |
||||
} |
||||
|
||||
export const AlignmentPeriodLabel: FC<Props> = ({ customMetaData, datasource }) => { |
||||
const { perSeriesAligner, alignmentPeriod } = customMetaData; |
||||
const formatAlignmentText = useMemo(() => { |
||||
if (!alignmentPeriod || !perSeriesAligner) { |
||||
return ''; |
||||
} |
||||
|
||||
const alignment = ALIGNMENTS.find((ap) => ap.value === datasource.templateSrv.replace(perSeriesAligner)); |
||||
const seconds = parseInt(alignmentPeriod ?? ''.replace(/[^0-9]/g, ''), 10); |
||||
const hms = rangeUtil.secondsToHms(seconds); |
||||
return `${hms} interval (${alignment?.text ?? ''})`; |
||||
}, [datasource, perSeriesAligner, alignmentPeriod]); |
||||
|
||||
return <label>{formatAlignmentText}</label>; |
||||
}; |
@ -1,61 +0,0 @@ |
||||
import React, { FC } from 'react'; |
||||
|
||||
import { TemplateSrv } from '@grafana/runtime'; |
||||
import { SelectableValue, rangeUtil } from '@grafana/data'; |
||||
import { Segment } from '@grafana/ui'; |
||||
import { alignmentPeriods, alignOptions } from '../constants'; |
||||
|
||||
export interface Props { |
||||
onChange: (alignmentPeriod: string) => void; |
||||
templateSrv: TemplateSrv; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
alignmentPeriod: string; |
||||
perSeriesAligner: string; |
||||
usedAlignmentPeriod?: number; |
||||
} |
||||
|
||||
export const AlignmentPeriods: FC<Props> = ({ |
||||
alignmentPeriod, |
||||
templateSrv, |
||||
templateVariableOptions, |
||||
onChange, |
||||
perSeriesAligner, |
||||
usedAlignmentPeriod, |
||||
}) => { |
||||
const alignment = alignOptions.find((ap) => ap.value === templateSrv.replace(perSeriesAligner)); |
||||
const formatAlignmentText = usedAlignmentPeriod |
||||
? `${rangeUtil.secondsToHms(usedAlignmentPeriod)} interval (${alignment ? alignment.text : ''})` |
||||
: ''; |
||||
const options = alignmentPeriods.map((ap) => ({ |
||||
...ap, |
||||
label: ap.text, |
||||
})); |
||||
const visibleOptions = options.filter((ap) => !ap.hidden); |
||||
|
||||
return ( |
||||
<> |
||||
<div className="gf-form-inline"> |
||||
<label className="gf-form-label query-keyword width-9">Alignment Period</label> |
||||
<Segment |
||||
onChange={({ value }) => onChange(value!)} |
||||
value={[...options, ...templateVariableOptions].find((s) => s.value === alignmentPeriod)} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: templateVariableOptions, |
||||
}, |
||||
{ |
||||
label: 'Aggregations', |
||||
expanded: true, |
||||
options: visibleOptions, |
||||
}, |
||||
]} |
||||
placeholder="Select Alignment" |
||||
></Segment> |
||||
<div className="gf-form gf-form--grow"> |
||||
{usedAlignmentPeriod && <label className="gf-form-label gf-form-label--grow">{formatAlignmentText}</label>} |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
}; |
@ -1,39 +0,0 @@ |
||||
import React, { FC } from 'react'; |
||||
|
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Segment } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
onChange: (perSeriesAligner: string) => void; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
alignOptions: Array<SelectableValue<string>>; |
||||
perSeriesAligner: string; |
||||
} |
||||
|
||||
export const Alignments: FC<Props> = ({ perSeriesAligner, templateVariableOptions, onChange, alignOptions }) => { |
||||
return ( |
||||
<> |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form offset-width-9"> |
||||
<label className="gf-form-label query-keyword width-15">Aligner</label> |
||||
<Segment |
||||
onChange={({ value }) => onChange(value!)} |
||||
value={[...alignOptions, ...templateVariableOptions].find((s) => s.value === perSeriesAligner)} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: templateVariableOptions, |
||||
}, |
||||
{ |
||||
label: 'Alignment options', |
||||
expanded: true, |
||||
options: alignOptions, |
||||
}, |
||||
]} |
||||
placeholder="Select Alignment" |
||||
></Segment> |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,72 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { QueryEditorHelpProps } from '@grafana/data'; |
||||
import { css } from '@emotion/css'; |
||||
|
||||
export default class CloudMonitoringCheatSheet extends PureComponent<QueryEditorHelpProps, { userExamples: string[] }> { |
||||
render() { |
||||
return ( |
||||
<div> |
||||
<h2>Cloud Monitoring alias patterns</h2> |
||||
<div> |
||||
<p> |
||||
Format the legend keys any way you want by using alias patterns. Format the legend keys any way you want by |
||||
using alias patterns. |
||||
</p> |
||||
Example: |
||||
<code>{`${'{{metric.name}} - {{metric.label.instance_name}}'}`}</code> |
||||
<br /> |
||||
Result: <code>cpu/usage_time - server1-europe-west-1</code> |
||||
<br /> |
||||
<br /> |
||||
<label>Patterns</label> |
||||
<br /> |
||||
<ul |
||||
className={css` |
||||
list-style: none; |
||||
`}
|
||||
> |
||||
<li> |
||||
<code>{`${'{{metric.type}}'}`}</code> = metric type e.g. compute.googleapis.com/instance/cpu/usage_time |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metric.name}}'}`}</code> = name part of metric e.g. instance/cpu/usage_time |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metric.service}}'}`}</code> = service part of metric e.g. compute |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metric.label.label_name}}'}`}</code> = Metric label metadata e.g. metric.label.instance_name |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{resource.label.label_name}}'}`}</code> = Resource label metadata e.g. resource.label.zone |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metadata.system_labels.name}}'}`}</code> = Meta data system labels e.g. |
||||
metadata.system_labels.name. For this to work, the needs to be included in the group by |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metadata.user_labels.name}}'}`}</code> = Meta data user labels e.g. |
||||
metadata.user_labels.name. For this to work, the needs to be included in the group by |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{bucket}}'}`}</code> = bucket boundary for distribution metrics when using a heatmap in |
||||
Grafana |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{project}}'}`}</code> = The project name that was specified in the query editor |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{service}}'}`}</code> = The service id that was specified in the SLO query editor |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{slo}}'}`}</code> = The SLO id that was specified in the SLO query editor |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{selector}}'}`}</code> = The Selector function that was specified in the SLO query editor |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,52 @@ |
||||
import React, { FunctionComponent, useMemo } from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { MultiSelect } from '@grafana/ui'; |
||||
import { labelsToGroupedOptions } from '../functions'; |
||||
import { SYSTEM_LABELS, INPUT_WIDTH } from '../constants'; |
||||
import { MetricDescriptor, MetricQuery } from '../types'; |
||||
import { Aggregation, QueryEditorRow } from '.'; |
||||
|
||||
export interface Props { |
||||
variableOptionGroup: SelectableValue<string>; |
||||
labels: string[]; |
||||
metricDescriptor?: MetricDescriptor; |
||||
onChange: (query: MetricQuery) => void; |
||||
query: MetricQuery; |
||||
} |
||||
|
||||
export const GroupBy: FunctionComponent<Props> = ({ |
||||
labels: groupBys = [], |
||||
query, |
||||
onChange, |
||||
variableOptionGroup, |
||||
metricDescriptor, |
||||
}) => { |
||||
const options = useMemo(() => [variableOptionGroup, ...labelsToGroupedOptions([...groupBys, ...SYSTEM_LABELS])], [ |
||||
groupBys, |
||||
variableOptionGroup, |
||||
]); |
||||
|
||||
return ( |
||||
<QueryEditorRow |
||||
label="Group by" |
||||
tooltip="You can reduce the amount of data returned for a metric by combining different time series. To combine multiple time series, you can specify a grouping and a function. Grouping is done on the basis of labels. The grouping function is used to combine the time series in the group into a single time series." |
||||
> |
||||
<MultiSelect |
||||
width={INPUT_WIDTH} |
||||
placeholder="Choose label" |
||||
options={options} |
||||
value={query.groupBys ?? []} |
||||
onChange={(options) => { |
||||
onChange({ ...query, groupBys: options.map((o) => o.value!) }); |
||||
}} |
||||
></MultiSelect> |
||||
<Aggregation |
||||
metricDescriptor={metricDescriptor} |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
crossSeriesReducer={query.crossSeriesReducer} |
||||
groupBys={query.groupBys ?? []} |
||||
onChange={(crossSeriesReducer) => onChange({ ...query, crossSeriesReducer })} |
||||
></Aggregation> |
||||
</QueryEditorRow> |
||||
); |
||||
}; |
@ -1,58 +0,0 @@ |
||||
import React, { FunctionComponent } from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Segment, Icon } from '@grafana/ui'; |
||||
import { labelsToGroupedOptions } from '../functions'; |
||||
import { systemLabels } from '../constants'; |
||||
|
||||
export interface Props { |
||||
values: string[]; |
||||
onChange: (values: string[]) => void; |
||||
variableOptionGroup: SelectableValue<string>; |
||||
groupBys: string[]; |
||||
} |
||||
|
||||
const removeText = '-- remove group by --'; |
||||
const removeOption: SelectableValue<string> = { label: removeText, value: removeText }; |
||||
|
||||
export const GroupBys: FunctionComponent<Props> = ({ groupBys = [], values = [], onChange, variableOptionGroup }) => { |
||||
const options = [removeOption, variableOptionGroup, ...labelsToGroupedOptions([...groupBys, ...systemLabels])]; |
||||
return ( |
||||
<div className="gf-form-inline"> |
||||
<label className="gf-form-label query-keyword width-9">Group By</label> |
||||
{values && |
||||
values.map((value, index) => ( |
||||
<Segment |
||||
allowCustomValue |
||||
key={value + index} |
||||
value={value} |
||||
options={options} |
||||
onChange={({ value = '' }) => |
||||
onChange( |
||||
value === removeText |
||||
? values.filter((_, i) => i !== index) |
||||
: values.map((v, i) => (i === index ? value : v)) |
||||
) |
||||
} |
||||
/> |
||||
))} |
||||
{values.length !== groupBys.length && ( |
||||
<Segment |
||||
Component={ |
||||
<a className="gf-form-label query-part"> |
||||
<Icon name="plus" /> |
||||
</a> |
||||
} |
||||
allowCustomValue |
||||
onChange={({ value = '' }) => onChange([...values, value])} |
||||
options={[ |
||||
variableOptionGroup, |
||||
...labelsToGroupedOptions([...groupBys.filter((groupBy) => !values.includes(groupBy)), ...systemLabels]), |
||||
]} |
||||
/> |
||||
)} |
||||
<div className="gf-form gf-form--grow"> |
||||
<label className="gf-form-label gf-form-label--grow"></label> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
@ -1,139 +0,0 @@ |
||||
import React from 'react'; |
||||
import { MetricDescriptor } from '../types'; |
||||
import { Icon } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
rawQuery: string; |
||||
lastQueryError?: string; |
||||
metricDescriptor?: MetricDescriptor; |
||||
} |
||||
|
||||
interface State { |
||||
displayHelp: boolean; |
||||
displaRawQuery: boolean; |
||||
} |
||||
|
||||
export class Help extends React.Component<Props, State> { |
||||
state: State = { |
||||
displayHelp: false, |
||||
displaRawQuery: false, |
||||
}; |
||||
|
||||
onHelpClicked = () => { |
||||
this.setState({ displayHelp: !this.state.displayHelp }); |
||||
}; |
||||
|
||||
onRawQueryClicked = () => { |
||||
this.setState({ displaRawQuery: !this.state.displaRawQuery }); |
||||
}; |
||||
|
||||
shouldComponentUpdate(nextProps: Props) { |
||||
return nextProps.metricDescriptor !== null; |
||||
} |
||||
|
||||
render() { |
||||
const { displayHelp, displaRawQuery } = this.state; |
||||
const { rawQuery, lastQueryError } = this.props; |
||||
|
||||
return ( |
||||
<> |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form" onClick={this.onHelpClicked}> |
||||
<label className="gf-form-label query-keyword pointer"> |
||||
Show Help <Icon name={displayHelp ? 'angle-down' : 'angle-right'} /> |
||||
</label> |
||||
</div> |
||||
|
||||
{rawQuery && ( |
||||
<div className="gf-form" onClick={this.onRawQueryClicked}> |
||||
<label className="gf-form-label query-keyword"> |
||||
Raw query |
||||
<Icon |
||||
name={displaRawQuery ? 'angle-down' : 'angle-right'} |
||||
ng-show="ctrl.showHelp" |
||||
style={{ marginTop: '3px' }} |
||||
/> |
||||
</label> |
||||
</div> |
||||
)} |
||||
|
||||
<div className="gf-form gf-form--grow"> |
||||
<div className="gf-form-label gf-form-label--grow" /> |
||||
</div> |
||||
</div> |
||||
{rawQuery && displaRawQuery && ( |
||||
<div className="gf-form"> |
||||
<pre className="gf-form-pre">{rawQuery}</pre> |
||||
</div> |
||||
)} |
||||
|
||||
{displayHelp && ( |
||||
<div className="gf-form grafana-info-box alert-info"> |
||||
<div> |
||||
<h5>Alias Patterns</h5>Format the legend keys any way you want by using alias patterns. Format the legend |
||||
keys any way you want by using alias patterns. |
||||
<br /> <br /> |
||||
Example: |
||||
<code>{`${'{{metric.name}} - {{metric.label.instance_name}}'}`}</code> |
||||
<br /> |
||||
Result: <code>cpu/usage_time - server1-europe-west-1</code> |
||||
<br /> |
||||
<br /> |
||||
<strong>Patterns</strong> |
||||
<br /> |
||||
<ul> |
||||
<li> |
||||
<code>{`${'{{metric.type}}'}`}</code> = metric type e.g. |
||||
compute.googleapis.com/instance/cpu/usage_time |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metric.name}}'}`}</code> = name part of metric e.g. instance/cpu/usage_time |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metric.service}}'}`}</code> = service part of metric e.g. compute |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metric.label.label_name}}'}`}</code> = Metric label metadata e.g. |
||||
metric.label.instance_name |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{resource.label.label_name}}'}`}</code> = Resource label metadata e.g. resource.label.zone |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metadata.system_labels.name}}'}`}</code> = Meta data system labels e.g. |
||||
metadata.system_labels.name. For this to work, the needs to be included in the group by |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{metadata.user_labels.name}}'}`}</code> = Meta data user labels e.g. |
||||
metadata.user_labels.name. For this to work, the needs to be included in the group by |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{bucket}}'}`}</code> = bucket boundary for distribution metrics when using a heatmap in |
||||
Grafana |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{project}}'}`}</code> = The project name that was specified in the query editor |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{service}}'}`}</code> = The service id that was specified in the SLO query editor |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{slo}}'}`}</code> = The SLO id that was specified in the SLO query editor |
||||
</li> |
||||
<li> |
||||
<code>{`${'{{selector}}'}`}</code> = The Selector function that was specified in the SLO query editor |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
{lastQueryError && ( |
||||
<div className="gf-form"> |
||||
<pre className="gf-form-pre alert alert-error">{lastQueryError}</pre> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,65 @@ |
||||
import React, { FunctionComponent, useMemo } from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { RadioButtonGroup } from '@grafana/ui'; |
||||
import { MetricDescriptor, MetricKind, MetricQuery, PreprocessorType, ValueTypes } from '../types'; |
||||
import { getAlignmentPickerData } from '../functions'; |
||||
import { QueryEditorRow } from '.'; |
||||
|
||||
const NONE_OPTION = { label: 'None', value: PreprocessorType.None }; |
||||
|
||||
export interface Props { |
||||
metricDescriptor?: MetricDescriptor; |
||||
onChange: (query: MetricQuery) => void; |
||||
query: MetricQuery; |
||||
} |
||||
|
||||
export const Preprocessor: FunctionComponent<Props> = ({ query, metricDescriptor, onChange }) => { |
||||
const options = useOptions(metricDescriptor); |
||||
return ( |
||||
<QueryEditorRow |
||||
label="Pre-processing" |
||||
tooltip="Preprocessing options are displayed when the selected metric has a metric kind of delta or cumulative. The specific options available are determined by the metic's value type. If you select 'Rate', data points are aligned and converted to a rate per time series. If you select 'Delta', data points are aligned by their delta (difference) per time series" |
||||
> |
||||
<RadioButtonGroup |
||||
onChange={(value: PreprocessorType) => { |
||||
const { valueType, metricKind, perSeriesAligner: psa } = query; |
||||
const { perSeriesAligner } = getAlignmentPickerData(valueType, metricKind, psa, value); |
||||
onChange({ ...query, preprocessor: value, perSeriesAligner }); |
||||
}} |
||||
value={query.preprocessor ?? PreprocessorType.None} |
||||
options={options} |
||||
></RadioButtonGroup> |
||||
</QueryEditorRow> |
||||
); |
||||
}; |
||||
|
||||
const useOptions = (metricDescriptor?: MetricDescriptor): Array<SelectableValue<string>> => { |
||||
const metricKind = metricDescriptor?.metricKind; |
||||
const valueType = metricDescriptor?.valueType; |
||||
|
||||
return useMemo(() => { |
||||
if (!metricKind || metricKind === MetricKind.GAUGE || valueType === ValueTypes.DISTRIBUTION) { |
||||
return [NONE_OPTION]; |
||||
} |
||||
|
||||
const options = [ |
||||
NONE_OPTION, |
||||
{ |
||||
label: 'Rate', |
||||
value: PreprocessorType.Rate, |
||||
description: 'Data points are aligned and converted to a rate per time series', |
||||
}, |
||||
]; |
||||
|
||||
return metricKind === MetricKind.CUMULATIVE |
||||
? [ |
||||
...options, |
||||
{ |
||||
label: 'Delta', |
||||
value: PreprocessorType.Delta, |
||||
description: 'Data points are aligned by their delta (difference) per time series', |
||||
}, |
||||
] |
||||
: options; |
||||
}, [metricKind, valueType]); |
||||
}; |
@ -0,0 +1,52 @@ |
||||
import React, { useEffect, useState } from 'react'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { QueryEditorRow } from '..'; |
||||
import CloudMonitoringDatasource from '../../datasource'; |
||||
import { SLOQuery } from '../../types'; |
||||
import { SELECT_WIDTH } from '../../constants'; |
||||
|
||||
export interface Props { |
||||
onChange: (query: SLOQuery) => void; |
||||
query: SLOQuery; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
datasource: CloudMonitoringDatasource; |
||||
} |
||||
|
||||
export const SLO: React.FC<Props> = ({ query, templateVariableOptions, onChange, datasource }) => { |
||||
const [slos, setSLOs] = useState<Array<SelectableValue<string>>>([]); |
||||
const { projectName, serviceId } = query; |
||||
|
||||
useEffect(() => { |
||||
if (!projectName || !serviceId) { |
||||
return; |
||||
} |
||||
|
||||
datasource.getServiceLevelObjectives(projectName, serviceId).then((sloIds: Array<SelectableValue<string>>) => { |
||||
setSLOs([ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: templateVariableOptions, |
||||
}, |
||||
...sloIds, |
||||
]); |
||||
}); |
||||
}, [datasource, projectName, serviceId, templateVariableOptions]); |
||||
|
||||
return ( |
||||
<QueryEditorRow label="SLO"> |
||||
<Select |
||||
width={SELECT_WIDTH} |
||||
allowCustomValue |
||||
value={query?.sloId && { value: query?.sloId, label: query?.sloName || query?.sloId }} |
||||
placeholder="Select SLO" |
||||
options={slos} |
||||
onChange={async ({ value: sloId = '', label: sloName = '' }) => { |
||||
const slos = await datasource.getServiceLevelObjectives(projectName, serviceId); |
||||
const slo = slos.find(({ value }) => value === datasource.templateSrv.replace(sloId)); |
||||
onChange({ ...query, sloId, sloName, goal: slo?.goal }); |
||||
}} |
||||
/> |
||||
</QueryEditorRow> |
||||
); |
||||
}; |
@ -0,0 +1,80 @@ |
||||
import React from 'react'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Project, AliasBy, AlignmentPeriod, AlignmentPeriodLabel, QueryEditorRow } from '..'; |
||||
import { AlignmentTypes, CustomMetaData, SLOQuery } from '../../types'; |
||||
import CloudMonitoringDatasource from '../../datasource'; |
||||
import { Selector, Service, SLO } from '.'; |
||||
import { SELECT_WIDTH } from '../../constants'; |
||||
|
||||
export interface Props { |
||||
customMetaData: CustomMetaData; |
||||
variableOptionGroup: SelectableValue<string>; |
||||
onChange: (query: SLOQuery) => void; |
||||
onRunQuery: () => void; |
||||
query: SLOQuery; |
||||
datasource: CloudMonitoringDatasource; |
||||
} |
||||
|
||||
export const defaultQuery: (dataSource: CloudMonitoringDatasource) => SLOQuery = (dataSource) => ({ |
||||
projectName: dataSource.getDefaultProject(), |
||||
alignmentPeriod: 'cloud-monitoring-auto', |
||||
perSeriesAligner: AlignmentTypes.ALIGN_MEAN, |
||||
aliasBy: '', |
||||
selectorName: 'select_slo_health', |
||||
serviceId: '', |
||||
serviceName: '', |
||||
sloId: '', |
||||
sloName: '', |
||||
}); |
||||
|
||||
export function SLOQueryEditor({ |
||||
query, |
||||
datasource, |
||||
onChange, |
||||
variableOptionGroup, |
||||
customMetaData, |
||||
}: React.PropsWithChildren<Props>) { |
||||
return ( |
||||
<> |
||||
<Project |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
projectName={query.projectName} |
||||
datasource={datasource} |
||||
onChange={(projectName) => onChange({ ...query, projectName })} |
||||
/> |
||||
<Service |
||||
datasource={datasource} |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
query={query} |
||||
onChange={onChange} |
||||
></Service> |
||||
<SLO |
||||
datasource={datasource} |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
query={query} |
||||
onChange={onChange} |
||||
></SLO> |
||||
<Selector |
||||
datasource={datasource} |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
query={query} |
||||
onChange={onChange} |
||||
></Selector> |
||||
|
||||
<QueryEditorRow label="Alignment period"> |
||||
<AlignmentPeriod |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
query={{ |
||||
...query, |
||||
perSeriesAligner: query.selectorName === 'select_slo_health' ? 'ALIGN_MEAN' : 'ALIGN_NEXT_OLDER', |
||||
}} |
||||
onChange={onChange} |
||||
selectWidth={SELECT_WIDTH} |
||||
/> |
||||
<AlignmentPeriodLabel datasource={datasource} customMetaData={customMetaData} /> |
||||
</QueryEditorRow> |
||||
|
||||
<AliasBy value={query.aliasBy} onChange={(aliasBy) => onChange({ ...query, aliasBy })} /> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,34 @@ |
||||
import React from 'react'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { QueryEditorRow } from '..'; |
||||
import CloudMonitoringDatasource from '../../datasource'; |
||||
import { SLOQuery } from '../../types'; |
||||
import { SELECT_WIDTH, SELECTORS } from '../../constants'; |
||||
|
||||
export interface Props { |
||||
onChange: (query: SLOQuery) => void; |
||||
query: SLOQuery; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
datasource: CloudMonitoringDatasource; |
||||
} |
||||
|
||||
export const Selector: React.FC<Props> = ({ query, templateVariableOptions, onChange, datasource }) => { |
||||
return ( |
||||
<QueryEditorRow label="Selector"> |
||||
<Select |
||||
width={SELECT_WIDTH} |
||||
allowCustomValue |
||||
value={[...SELECTORS, ...templateVariableOptions].find((s) => s.value === query?.selectorName ?? '')} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: templateVariableOptions, |
||||
}, |
||||
...SELECTORS, |
||||
]} |
||||
onChange={({ value: selectorName }) => onChange({ ...query, selectorName: selectorName ?? '' })} |
||||
/> |
||||
</QueryEditorRow> |
||||
); |
||||
}; |
@ -0,0 +1,50 @@ |
||||
import React, { useEffect, useState } from 'react'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { QueryEditorRow } from '..'; |
||||
import CloudMonitoringDatasource from '../../datasource'; |
||||
import { SLOQuery } from '../../types'; |
||||
import { SELECT_WIDTH } from '../../constants'; |
||||
|
||||
export interface Props { |
||||
onChange: (query: SLOQuery) => void; |
||||
query: SLOQuery; |
||||
templateVariableOptions: Array<SelectableValue<string>>; |
||||
datasource: CloudMonitoringDatasource; |
||||
} |
||||
|
||||
export const Service: React.FC<Props> = ({ query, templateVariableOptions, onChange, datasource }) => { |
||||
const [services, setServices] = useState<Array<SelectableValue<string>>>([]); |
||||
const { projectName } = query; |
||||
|
||||
useEffect(() => { |
||||
if (!projectName) { |
||||
return; |
||||
} |
||||
|
||||
datasource.getSLOServices(projectName).then((services: Array<SelectableValue<string>>) => { |
||||
setServices([ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: templateVariableOptions, |
||||
}, |
||||
...services, |
||||
]); |
||||
}); |
||||
}, [datasource, projectName, templateVariableOptions]); |
||||
|
||||
return ( |
||||
<QueryEditorRow label="Service"> |
||||
<Select |
||||
width={SELECT_WIDTH} |
||||
allowCustomValue |
||||
value={query?.serviceId && { value: query?.serviceId, label: query?.serviceName || query?.serviceId }} |
||||
placeholder="Select service" |
||||
options={services} |
||||
onChange={({ value: serviceId = '', label: serviceName = '' }) => |
||||
onChange({ ...query, serviceId, serviceName, sloId: '' }) |
||||
} |
||||
/> |
||||
</QueryEditorRow> |
||||
); |
||||
}; |
@ -0,0 +1,3 @@ |
||||
export { Service } from './Service'; |
||||
export { SLO } from './SLO'; |
||||
export { Selector } from './Selector'; |
@ -1,112 +0,0 @@ |
||||
import React from 'react'; |
||||
import { Segment, SegmentAsync } from '@grafana/ui'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { selectors } from '../constants'; |
||||
import { Project, AlignmentPeriods, AliasBy, QueryInlineField } from '.'; |
||||
import { SLOQuery } from '../types'; |
||||
import CloudMonitoringDatasource from '../datasource'; |
||||
|
||||
export interface Props { |
||||
usedAlignmentPeriod?: number; |
||||
variableOptionGroup: SelectableValue<string>; |
||||
onChange: (query: SLOQuery) => void; |
||||
onRunQuery: () => void; |
||||
query: SLOQuery; |
||||
datasource: CloudMonitoringDatasource; |
||||
} |
||||
|
||||
export const defaultQuery: (dataSource: CloudMonitoringDatasource) => SLOQuery = (dataSource) => ({ |
||||
projectName: dataSource.getDefaultProject(), |
||||
alignmentPeriod: 'cloud-monitoring-auto', |
||||
aliasBy: '', |
||||
selectorName: 'select_slo_health', |
||||
serviceId: '', |
||||
serviceName: '', |
||||
sloId: '', |
||||
sloName: '', |
||||
}); |
||||
|
||||
export function SLOQueryEditor({ |
||||
query, |
||||
datasource, |
||||
onChange, |
||||
variableOptionGroup, |
||||
usedAlignmentPeriod, |
||||
}: React.PropsWithChildren<Props>) { |
||||
return ( |
||||
<> |
||||
<Project |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
projectName={query.projectName} |
||||
datasource={datasource} |
||||
onChange={(projectName) => onChange({ ...query, projectName })} |
||||
/> |
||||
<QueryInlineField label="Service"> |
||||
<SegmentAsync |
||||
allowCustomValue |
||||
value={{ value: query?.serviceId, label: query?.serviceName || query?.serviceId }} |
||||
placeholder="Select service" |
||||
loadOptions={() => |
||||
datasource.getSLOServices(query.projectName).then((services) => [ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: variableOptionGroup.options, |
||||
}, |
||||
...services, |
||||
]) |
||||
} |
||||
onChange={({ value: serviceId = '', label: serviceName = '' }) => |
||||
onChange({ ...query, serviceId, serviceName, sloId: '' }) |
||||
} |
||||
/> |
||||
</QueryInlineField> |
||||
|
||||
<QueryInlineField label="SLO"> |
||||
<SegmentAsync |
||||
allowCustomValue |
||||
value={{ value: query?.sloId, label: query?.sloName || query?.sloId }} |
||||
placeholder="Select SLO" |
||||
loadOptions={() => |
||||
datasource.getServiceLevelObjectives(query.projectName, query.serviceId).then((sloIds) => [ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: variableOptionGroup.options, |
||||
}, |
||||
...sloIds, |
||||
]) |
||||
} |
||||
onChange={async ({ value: sloId = '', label: sloName = '' }) => { |
||||
const slos = await datasource.getServiceLevelObjectives(query.projectName, query.serviceId); |
||||
const slo = slos.find(({ value }) => value === datasource.templateSrv.replace(sloId)); |
||||
onChange({ ...query, sloId, sloName, goal: slo?.goal }); |
||||
}} |
||||
/> |
||||
</QueryInlineField> |
||||
|
||||
<QueryInlineField label="Selector"> |
||||
<Segment |
||||
allowCustomValue |
||||
value={[...selectors, ...variableOptionGroup.options].find((s) => s.value === query?.selectorName ?? '')} |
||||
options={[ |
||||
{ |
||||
label: 'Template Variables', |
||||
options: variableOptionGroup.options, |
||||
}, |
||||
...selectors, |
||||
]} |
||||
onChange={({ value: selectorName }) => onChange({ ...query, selectorName })} |
||||
/> |
||||
</QueryInlineField> |
||||
|
||||
<AlignmentPeriods |
||||
templateSrv={datasource.templateSrv} |
||||
templateVariableOptions={variableOptionGroup.options} |
||||
alignmentPeriod={query.alignmentPeriod || ''} |
||||
perSeriesAligner={query.selectorName === 'select_slo_health' ? 'ALIGN_MEAN' : 'ALIGN_NEXT_OLDER'} |
||||
usedAlignmentPeriod={usedAlignmentPeriod} |
||||
onChange={(alignmentPeriod) => onChange({ ...query, alignmentPeriod })} |
||||
/> |
||||
<AliasBy value={query.aliasBy} onChange={(aliasBy) => onChange({ ...query, aliasBy })} /> |
||||
</> |
||||
); |
||||
} |
@ -1,16 +1,18 @@ |
||||
export { Project } from './Project'; |
||||
export { Metrics } from './Metrics'; |
||||
export { Help } from './Help'; |
||||
export { GroupBys } from './GroupBys'; |
||||
export { GroupBy } from './GroupBy'; |
||||
export { Alignment } from './Alignment'; |
||||
export { LabelFilter } from './LabelFilter'; |
||||
export { AnnotationsHelp } from './AnnotationsHelp'; |
||||
export { Alignments } from './Alignments'; |
||||
export { AlignmentPeriods } from './AlignmentPeriods'; |
||||
export { AlignmentFunction } from './AlignmentFunction'; |
||||
export { AlignmentPeriod } from './AlignmentPeriod'; |
||||
export { AlignmentPeriodLabel } from './AlignmentPeriodLabel'; |
||||
export { AliasBy } from './AliasBy'; |
||||
export { Aggregations } from './Aggregations'; |
||||
export { Aggregation } from './Aggregation'; |
||||
export { MetricQueryEditor } from './MetricQueryEditor'; |
||||
export { SLOQueryEditor } from './SLOQueryEditor'; |
||||
export { SLOQueryEditor } from './SLO/SLOQueryEditor'; |
||||
export { MQLQueryEditor } from './MQLQueryEditor'; |
||||
export { QueryTypeSelector } from './QueryType'; |
||||
export { QueryInlineField, QueryField, VariableQueryField } from './Fields'; |
||||
export { VariableQueryField, QueryEditorRow, QueryEditorField } from './Fields'; |
||||
export { VisualMetricQueryEditor } from './VisualMetricQueryEditor'; |
||||
export { Preprocessor } from './Preprocessor'; |
||||
|
Loading…
Reference in new issue