Elasticsearch: Improve query type selection (#63402)

* elastic: display query types explicitly

* fixed test

* fixed test

* updated test names

* fix react-key-issue
This commit is contained in:
Gábor Farkas 2023-05-05 15:00:39 +02:00 committed by GitHub
parent eb2eb65de8
commit af9176b3a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 214 additions and 37 deletions

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { PropsWithChildren } from 'react'; import React, { PropsWithChildren } from 'react';
import { from } from 'rxjs'; import { from } from 'rxjs';
@ -9,7 +10,7 @@ import { defaultBucketAgg } from '../../../queryDef';
import { ElasticsearchQuery } from '../../../types'; import { ElasticsearchQuery } from '../../../types';
import { ElasticsearchProvider } from '../ElasticsearchQueryContext'; import { ElasticsearchProvider } from '../ElasticsearchQueryContext';
import { Average, UniqueCount } from './../../../types'; import { Average, Count, UniqueCount } from './../../../types';
import { MetricEditor } from './MetricEditor'; import { MetricEditor } from './MetricEditor';
describe('Metric Editor', () => { describe('Metric Editor', () => {
@ -85,4 +86,47 @@ describe('Metric Editor', () => {
expect(await screen.findByText('No options found')).toBeInTheDocument(); expect(await screen.findByText('No options found')).toBeInTheDocument();
expect(screen.queryByText('None')).not.toBeInTheDocument(); expect(screen.queryByText('None')).not.toBeInTheDocument();
}); });
it('Should not list special metrics', async () => {
const count: Count = {
id: '1',
type: 'count',
};
const query: ElasticsearchQuery = {
refId: 'A',
query: '',
metrics: [count],
bucketAggs: [],
};
const getDatabaseVersion: ElasticDatasource['getDatabaseVersion'] = jest.fn(() => Promise.resolve(null));
const wrapper = ({ children }: PropsWithChildren<{}>) => (
<ElasticsearchProvider
datasource={{ getDatabaseVersion } as ElasticDatasource}
query={query}
range={getDefaultTimeRange()}
onChange={() => {}}
onRunQuery={() => {}}
>
{children}
</ElasticsearchProvider>
);
render(<MetricEditor value={count} />, { wrapper });
act(() => {
userEvent.click(screen.getByText('Count'));
});
// we check if the list-of-options is visible by
// checking for an item to exist
expect(await screen.findByText('Extended Stats')).toBeInTheDocument();
// now we make sure the should-not-be-shown items are not shown
expect(screen.queryByText('Logs')).toBeNull();
expect(screen.queryByText('Raw Data')).toBeNull();
expect(screen.queryByText('Raw Document (deprecated)')).toBeNull();
});
}); });

View File

@ -48,6 +48,7 @@ const getTypeOptions = (
return ( return (
Object.entries(metricAggregationConfig) Object.entries(metricAggregationConfig)
.filter(([_, config]) => !config.isSingleMetric)
// Only showing metrics type supported by the version of ES. // Only showing metrics type supported by the version of ES.
// if we cannot determine the version, we assume it is suitable. // if we cannot determine the version, we assume it is suitable.
.filter(([_, { versionRange = '*' }]) => (esVersion != null ? satisfies(esVersion, versionRange) : true)) .filter(([_, { versionRange = '*' }]) => (esVersion != null ? satisfies(esVersion, versionRange) : true))

View File

@ -4,6 +4,7 @@ import { useDispatch } from '../../../hooks/useStatelessReducer';
import { IconButton } from '../../IconButton'; import { IconButton } from '../../IconButton';
import { useQuery } from '../ElasticsearchQueryContext'; import { useQuery } from '../ElasticsearchQueryContext';
import { QueryEditorRow } from '../QueryEditorRow'; import { QueryEditorRow } from '../QueryEditorRow';
import { QueryEditorSpecialMetricRow } from '../QueryEditorSpecialMetricRow';
import { MetricAggregation } from './../../../types'; import { MetricAggregation } from './../../../types';
import { MetricEditor } from './MetricEditor'; import { MetricEditor } from './MetricEditor';
@ -21,21 +22,39 @@ export const MetricAggregationsEditor = ({ nextId }: Props) => {
return ( return (
<> <>
{metrics?.map((metric, index) => ( {metrics?.map((metric, index) => {
<QueryEditorRow switch (metric.type) {
key={`${metric.type}-${metric.id}`} case 'logs':
label={`Metric (${metric.id})`} return <QueryEditorSpecialMetricRow key={`${metric.type}-${metric.id}`} name="Logs" metric={metric} />;
hidden={metric.hide} case 'raw_data':
onHideClick={() => dispatch(toggleMetricVisibility(metric.id))} return <QueryEditorSpecialMetricRow key={`${metric.type}-${metric.id}`} name="Raw Data" metric={metric} />;
onRemoveClick={totalMetrics > 1 && (() => dispatch(removeMetric(metric.id)))} case 'raw_document':
> return (
<MetricEditor value={metric} /> <QueryEditorSpecialMetricRow
key={`${metric.type}-${metric.id}`}
name="Raw Document"
metric={metric}
info="(NOTE: Raw document query type is deprecated)"
/>
);
default:
return (
<QueryEditorRow
key={`${metric.type}-${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 && ( {!metricAggregationConfig[metric.type].isSingleMetric && index === 0 && (
<IconButton iconName="plus" onClick={() => dispatch(addMetric(nextId))} label="add" /> <IconButton iconName="plus" onClick={() => dispatch(addMetric(nextId))} label="add" />
)} )}
</QueryEditorRow> </QueryEditorRow>
))} );
}
})}
</> </>
); );
}; };

View File

@ -0,0 +1,36 @@
import React from 'react';
import { InlineFieldRow, InlineLabel, InlineSegmentGroup } from '@grafana/ui';
import { MetricAggregation } from '../../types';
import { SettingsEditor } from './MetricAggregationsEditor/SettingsEditor';
type Props = {
name: string;
metric: MetricAggregation;
info?: string;
};
export const QueryEditorSpecialMetricRow = ({ name, metric, info }: Props) => {
// this widget is only used in scenarios when there is only a single
// metric, so the array of "previousMetrics" (meaning all the metrics
// before the current metric), is an ampty-array
const previousMetrics: MetricAggregation[] = [];
return (
<InlineFieldRow>
<InlineSegmentGroup>
<InlineLabel width={17} as="div">
<span>{name}</span>
</InlineLabel>
</InlineSegmentGroup>
<SettingsEditor metric={metric} previousMetrics={previousMetrics} />
{info != null && (
<InlineSegmentGroup>
<InlineLabel>{info}</InlineLabel>
</InlineSegmentGroup>
)}
</InlineFieldRow>
);
};

View File

@ -0,0 +1,64 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { MetricAggregation } from '../../types';
import { useQuery } from './ElasticsearchQueryContext';
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';
const OPTIONS: Array<SelectableValue<QueryType>> = [
{ value: 'metrics', label: 'Metrics' },
{ value: 'logs', label: 'Logs' },
{ value: 'raw_data', label: 'Raw Data' },
{ value: 'raw_document', label: 'Raw Document' },
];
function metricTypeToQueryType(type: MetricAggregation['type']): QueryType {
switch (type) {
case 'logs':
case 'raw_data':
case 'raw_document':
return type;
default:
return 'metrics';
}
}
function queryTypeToMetricType(type: QueryType): MetricAggregation['type'] {
switch (type) {
case 'logs':
case 'raw_data':
case 'raw_document':
return type;
case 'metrics':
return 'count';
default:
// should never happen
throw new Error(`invalid query type: ${type}`);
}
}
export const QueryTypeSelector = () => {
const query = useQuery();
const dispatch = useDispatch();
const firstMetric = query.metrics?.[0];
if (firstMetric == null) {
// not sure if this can really happen, but we should handle it anyway
return null;
}
const queryType = metricTypeToQueryType(firstMetric.type);
const onChange = (newQueryType: QueryType) => {
dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) }));
};
return <RadioButtonGroup<QueryType> fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />;
};

View File

@ -22,10 +22,15 @@ describe('QueryEditor', () => {
metrics: [ metrics: [
{ {
id: '1', id: '1',
type: 'raw_data', type: 'count',
},
],
bucketAggs: [
{
type: 'date_histogram',
id: '2',
}, },
], ],
bucketAggs: [],
}; };
const onChange = jest.fn<void, [ElasticsearchQuery]>(); const onChange = jest.fn<void, [ElasticsearchQuery]>();
@ -51,7 +56,7 @@ describe('QueryEditor', () => {
expect(onChange.mock.calls[0][0].alias).toBe(newAlias); expect(onChange.mock.calls[0][0].alias).toBe(newAlias);
}); });
it('Should be disabled if last bucket aggregation is not Date Histogram', () => { it('Should not be shown if last bucket aggregation is not Date Histogram', () => {
const query: ElasticsearchQuery = { const query: ElasticsearchQuery = {
refId: 'A', refId: 'A',
query: '', query: '',
@ -66,10 +71,10 @@ describe('QueryEditor', () => {
render(<QueryEditor query={query} datasource={datasourceMock} onChange={noop} onRunQuery={noop} />); render(<QueryEditor query={query} datasource={datasourceMock} onChange={noop} onRunQuery={noop} />);
expect(screen.getByLabelText('Alias')).toBeDisabled(); expect(screen.queryByLabelText('Alias')).toBeNull();
}); });
it('Should be enabled if last bucket aggregation is Date Histogram', () => { it('Should be shown if last bucket aggregation is Date Histogram', () => {
const query: ElasticsearchQuery = { const query: ElasticsearchQuery = {
refId: 'A', refId: 'A',
query: '', query: '',

View File

@ -15,6 +15,7 @@ import { BucketAggregationsEditor } from './BucketAggregationsEditor';
import { ElasticsearchProvider } from './ElasticsearchQueryContext'; import { ElasticsearchProvider } from './ElasticsearchQueryContext';
import { MetricAggregationsEditor } from './MetricAggregationsEditor'; import { MetricAggregationsEditor } from './MetricAggregationsEditor';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
import { QueryTypeSelector } from './QueryTypeSelector';
import { changeAliasPattern, changeQuery } from './state'; import { changeAliasPattern, changeQuery } from './state';
export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions>; export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions>;
@ -66,7 +67,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
root: css` root: css`
display: flex; display: flex;
`, `,
queryFieldWrapper: css` queryItem: css`
flex-grow: 1; flex-grow: 1;
margin: 0 ${theme.spacing(0.5)} ${theme.spacing(0.5)} 0; margin: 0 ${theme.spacing(0.5)} ${theme.spacing(0.5)} 0;
`, `,
@ -80,14 +81,14 @@ export const ElasticSearchQueryField = ({ value, onChange }: { value?: string; o
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<div className={styles.queryFieldWrapper}> <div className={styles.queryItem}>
<QueryField <QueryField
query={value} query={value}
// By default QueryField calls onChange if onBlur is not defined, this will trigger a rerender // 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. // And slate will claim the focus, making it impossible to leave the field.
onBlur={() => {}} onBlur={() => {}}
onChange={onChange} onChange={onChange}
placeholder="Lucene Query" placeholder="Enter a lucene query"
portalOrigin="elasticsearch" portalOrigin="elasticsearch"
/> />
</div> </div>
@ -109,22 +110,29 @@ const QueryEditorForm = ({ value }: Props) => {
return ( return (
<> <>
<div className={styles.root}> <div className={styles.root}>
<InlineLabel width={17}>Query</InlineLabel> <InlineLabel width={17}>Query type</InlineLabel>
<div className={styles.queryItem}>
<QueryTypeSelector />
</div>
</div>
<div className={styles.root}>
<InlineLabel width={17}>Lucene Query</InlineLabel>
<ElasticSearchQueryField onChange={(query) => dispatch(changeQuery(query))} value={value?.query} /> <ElasticSearchQueryField onChange={(query) => dispatch(changeQuery(query))} value={value?.query} />
<InlineField {isTimeSeriesQuery && (
label="Alias" <InlineField
labelWidth={15} label="Alias"
disabled={!isTimeSeriesQuery} labelWidth={15}
tooltip="Aliasing only works for timeseries queries (when the last group is 'Date Histogram'). For all other query types this field is ignored." tooltip="Aliasing only works for timeseries queries (when the last group is 'Date Histogram'). For all other query types this field is ignored."
> >
<Input <Input
id={`ES-query-${value.refId}_alias`} id={`ES-query-${value.refId}_alias`}
placeholder="Alias Pattern" placeholder="Alias Pattern"
onBlur={(e) => dispatch(changeAliasPattern(e.currentTarget.value))} onBlur={(e) => dispatch(changeAliasPattern(e.currentTarget.value))}
defaultValue={value.alias} defaultValue={value.alias}
/> />
</InlineField> </InlineField>
)}
</div> </div>
<MetricAggregationsEditor nextId={nextId} /> <MetricAggregationsEditor nextId={nextId} />