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 userEvent from '@testing-library/user-event';
import React, { PropsWithChildren } from 'react';
import { from } from 'rxjs';
@ -9,7 +10,7 @@ import { defaultBucketAgg } from '../../../queryDef';
import { ElasticsearchQuery } from '../../../types';
import { ElasticsearchProvider } from '../ElasticsearchQueryContext';
import { Average, UniqueCount } from './../../../types';
import { Average, Count, UniqueCount } from './../../../types';
import { MetricEditor } from './MetricEditor';
describe('Metric Editor', () => {
@ -85,4 +86,47 @@ describe('Metric Editor', () => {
expect(await screen.findByText('No options found')).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 (
Object.entries(metricAggregationConfig)
.filter(([_, config]) => !config.isSingleMetric)
// Only showing metrics type supported by the version of ES.
// if we cannot determine the version, we assume it is suitable.
.filter(([_, { versionRange = '*' }]) => (esVersion != null ? satisfies(esVersion, versionRange) : true))

View File

@ -4,6 +4,7 @@ import { useDispatch } from '../../../hooks/useStatelessReducer';
import { IconButton } from '../../IconButton';
import { useQuery } from '../ElasticsearchQueryContext';
import { QueryEditorRow } from '../QueryEditorRow';
import { QueryEditorSpecialMetricRow } from '../QueryEditorSpecialMetricRow';
import { MetricAggregation } from './../../../types';
import { MetricEditor } from './MetricEditor';
@ -21,7 +22,23 @@ export const MetricAggregationsEditor = ({ nextId }: Props) => {
return (
<>
{metrics?.map((metric, index) => (
{metrics?.map((metric, index) => {
switch (metric.type) {
case 'logs':
return <QueryEditorSpecialMetricRow key={`${metric.type}-${metric.id}`} name="Logs" metric={metric} />;
case 'raw_data':
return <QueryEditorSpecialMetricRow key={`${metric.type}-${metric.id}`} name="Raw Data" metric={metric} />;
case 'raw_document':
return (
<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})`}
@ -35,7 +52,9 @@ export const MetricAggregationsEditor = ({ nextId }: Props) => {
<IconButton iconName="plus" onClick={() => dispatch(addMetric(nextId))} label="add" />
)}
</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: [
{
id: '1',
type: 'raw_data',
type: 'count',
},
],
bucketAggs: [
{
type: 'date_histogram',
id: '2',
},
],
bucketAggs: [],
};
const onChange = jest.fn<void, [ElasticsearchQuery]>();
@ -51,7 +56,7 @@ describe('QueryEditor', () => {
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 = {
refId: 'A',
query: '',
@ -66,10 +71,10 @@ describe('QueryEditor', () => {
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 = {
refId: 'A',
query: '',

View File

@ -15,6 +15,7 @@ import { BucketAggregationsEditor } from './BucketAggregationsEditor';
import { ElasticsearchProvider } from './ElasticsearchQueryContext';
import { MetricAggregationsEditor } from './MetricAggregationsEditor';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
import { QueryTypeSelector } from './QueryTypeSelector';
import { changeAliasPattern, changeQuery } from './state';
export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions>;
@ -66,7 +67,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
root: css`
display: flex;
`,
queryFieldWrapper: css`
queryItem: css`
flex-grow: 1;
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);
return (
<div className={styles.queryFieldWrapper}>
<div className={styles.queryItem}>
<QueryField
query={value}
// 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={onChange}
placeholder="Lucene Query"
placeholder="Enter a lucene query"
portalOrigin="elasticsearch"
/>
</div>
@ -109,13 +110,19 @@ const QueryEditorForm = ({ value }: Props) => {
return (
<>
<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} />
{isTimeSeriesQuery && (
<InlineField
label="Alias"
labelWidth={15}
disabled={!isTimeSeriesQuery}
tooltip="Aliasing only works for timeseries queries (when the last group is 'Date Histogram'). For all other query types this field is ignored."
>
<Input
@ -125,6 +132,7 @@ const QueryEditorForm = ({ value }: Props) => {
defaultValue={value.alias}
/>
</InlineField>
)}
</div>
<MetricAggregationsEditor nextId={nextId} />