mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
6 Commits
bugfix/fil
...
elasticsea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d419c33970 | ||
|
|
9b6f306956 | ||
|
|
598be0cf49 | ||
|
|
bad5bd627d | ||
|
|
850963c8fe | ||
|
|
5e79510352 |
@@ -174,6 +174,8 @@ You can also override this setting in a dashboard panel under its data source op
|
||||
Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14.
|
||||
{{< /admonition >}}
|
||||
|
||||
- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`.
|
||||
|
||||
### Logs
|
||||
|
||||
In this section you can configure which fields the data source uses for log messages and log levels.
|
||||
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
} from './dataquery.gen';
|
||||
import {
|
||||
defaultBucketAgg,
|
||||
defaultMetricAgg,
|
||||
findMetricById,
|
||||
highlightTags,
|
||||
defaultGeoHashPrecisionString,
|
||||
queryTypeToMetricType,
|
||||
} from './queryDef';
|
||||
import { TermsQuery } from './types';
|
||||
import { QueryType, TermsQuery } from './types';
|
||||
import { convertOrderByToMetricId, getScriptValue } from './utils';
|
||||
|
||||
// Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval
|
||||
@@ -31,9 +31,11 @@ export const calendarIntervals: string[] = ['1w', '1M', '1q', '1y'];
|
||||
|
||||
export class ElasticQueryBuilder {
|
||||
timeField: string;
|
||||
defaultQueryMode?: QueryType;
|
||||
|
||||
constructor(options: { timeField: string }) {
|
||||
constructor(options: { timeField: string; defaultQueryMode?: QueryType }) {
|
||||
this.timeField = options.timeField;
|
||||
this.defaultQueryMode = options.defaultQueryMode;
|
||||
}
|
||||
|
||||
getRangeFilter() {
|
||||
@@ -174,7 +176,10 @@ export class ElasticQueryBuilder {
|
||||
|
||||
build(target: ElasticsearchDataQuery) {
|
||||
// make sure query has defaults;
|
||||
target.metrics = target.metrics || [defaultMetricAgg()];
|
||||
if (!target.metrics || target.metrics.length === 0) {
|
||||
const metricType = queryTypeToMetricType(this.defaultQueryMode);
|
||||
target.metrics = [{ type: metricType, id: '1' }];
|
||||
}
|
||||
target.bucketAggs = target.bucketAggs || [defaultBucketAgg()];
|
||||
target.timeField = this.timeField;
|
||||
let metric: MetricAggregation;
|
||||
|
||||
@@ -62,10 +62,10 @@ export const ElasticsearchProvider = ({
|
||||
// useStatelessReducer will then call `onChange` with the newly generated query
|
||||
useEffect(() => {
|
||||
if (shouldRunInit && isUninitialized) {
|
||||
dispatch(initQuery());
|
||||
dispatch(initQuery(datasource.defaultQueryMode));
|
||||
setShouldRunInit(false);
|
||||
}
|
||||
}, [shouldRunInit, dispatch, isUninitialized]);
|
||||
}, [shouldRunInit, dispatch, isUninitialized, datasource.defaultQueryMode]);
|
||||
|
||||
if (isUninitialized) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit';
|
||||
|
||||
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
|
||||
|
||||
import { defaultMetricAgg } from '../../../../queryDef';
|
||||
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
|
||||
import { removeEmpty } from '../../../../utils';
|
||||
import { initQuery } from '../../state';
|
||||
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
|
||||
@@ -162,7 +162,8 @@ export const reducer = (
|
||||
if (state && state.length > 0) {
|
||||
return state;
|
||||
}
|
||||
return [defaultMetricAgg('1')];
|
||||
const metricType = queryTypeToMetricType(action.payload);
|
||||
return [{ type: metricType, id: '1' }];
|
||||
}
|
||||
|
||||
return state;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { renderWithESProvider } from '../../test-helpers/render';
|
||||
|
||||
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
|
||||
import { QueryTypeSelector } from './QueryTypeSelector';
|
||||
|
||||
jest.mock('../../hooks/useStatelessReducer');
|
||||
|
||||
describe('QueryTypeSelector', () => {
|
||||
let dispatch: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn();
|
||||
jest.mocked(useDispatch).mockReturnValue(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render radio buttons with correct options', () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Metrics' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Logs' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Raw Data' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Raw Document' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should dispatch changeMetricType action when radio button is changed', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const logsRadio = screen.getByRole('radio', { name: 'Logs' });
|
||||
await userEvent.click(logsRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'logs' }));
|
||||
});
|
||||
|
||||
it('should convert query type to metric type correctly for raw_data', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const rawDataRadio = screen.getByRole('radio', { name: 'Raw Data' });
|
||||
await userEvent.click(rawDataRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_data' }));
|
||||
});
|
||||
|
||||
it('should convert query type to metric type correctly for raw_document', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const rawDocumentRadio = screen.getByRole('radio', { name: 'Raw Document' });
|
||||
await userEvent.click(rawDocumentRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_document' }));
|
||||
});
|
||||
|
||||
it('should convert metrics query type to count metric type', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'logs' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const metricsRadio = screen.getByRole('radio', { name: 'Metrics' });
|
||||
await userEvent.click(metricsRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'count' }));
|
||||
});
|
||||
|
||||
it('should return null when query has no metrics', () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
const { container } = renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,14 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { MetricAggregation } from '../../dataquery.gen';
|
||||
import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils';
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { queryTypeToMetricType } from '../../queryDef';
|
||||
import { QueryType } from '../../types';
|
||||
|
||||
import { useQuery } from './ElasticsearchQueryContext';
|
||||
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
|
||||
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
|
||||
|
||||
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 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();
|
||||
@@ -47,5 +26,12 @@ export const QueryTypeSelector = () => {
|
||||
dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) }));
|
||||
};
|
||||
|
||||
return <RadioButtonGroup<QueryType> fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />;
|
||||
return (
|
||||
<RadioButtonGroup<QueryType>
|
||||
fullWidth={false}
|
||||
options={QUERY_TYPE_SELECTOR_OPTIONS}
|
||||
value={queryType}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Action, createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { QueryType } from '../../types';
|
||||
|
||||
/**
|
||||
* When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present.
|
||||
* This means it won't override any existing value in place, but just ensure the query is in a "runnable" state.
|
||||
*/
|
||||
export const initQuery = createAction('init');
|
||||
export const initQuery = createAction<QueryType | undefined>('init');
|
||||
|
||||
export const changeQuery = createAction<ElasticsearchDataQuery['query']>('change_query');
|
||||
|
||||
|
||||
@@ -41,4 +41,16 @@ describe('ElasticDetails', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should change default query mode when selected', async () => {
|
||||
const onChangeMock = jest.fn();
|
||||
render(<ElasticDetails onChange={onChangeMock} value={createDefaultConfigOptions()} />);
|
||||
const selectEl = screen.getByLabelText('Default query mode');
|
||||
|
||||
await selectEvent.select(selectEl, 'Logs', { container: document.body });
|
||||
|
||||
expect(onChangeMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ jsonData: expect.objectContaining({ defaultQueryMode: 'logs' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
import type { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui';
|
||||
import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui';
|
||||
|
||||
import { ElasticsearchOptions, Interval } from '../types';
|
||||
import type { ElasticsearchOptions, Interval, QueryType } from '../types';
|
||||
|
||||
import { QUERY_TYPE_SELECTOR_OPTIONS } from './utils';
|
||||
|
||||
const indexPatternTypes: Array<SelectableValue<'none' | Interval>> = [
|
||||
{ label: 'No pattern', value: 'none' },
|
||||
@@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => {
|
||||
onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)}
|
||||
/>
|
||||
</InlineField>
|
||||
|
||||
<InlineField
|
||||
label="Default query mode"
|
||||
htmlFor="es_config_defaultQueryMode"
|
||||
labelWidth={29}
|
||||
tooltip="Default query mode to use for the data source. Defaults to 'Metrics'."
|
||||
>
|
||||
<Select
|
||||
inputId="es_config_defaultQueryMode"
|
||||
value={value.jsonData.defaultQueryMode}
|
||||
options={QUERY_TYPE_SELECTOR_OPTIONS}
|
||||
onChange={(selectedMode) => {
|
||||
onChange({
|
||||
...value,
|
||||
jsonData: {
|
||||
...value.jsonData,
|
||||
defaultQueryMode: selectedMode.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
width={24}
|
||||
/>
|
||||
</InlineField>
|
||||
</ConfigSubSection>
|
||||
);
|
||||
};
|
||||
@@ -209,3 +234,6 @@ const intervalHandler =
|
||||
export function defaultMaxConcurrentShardRequests() {
|
||||
return 5;
|
||||
}
|
||||
export function defaultQueryMode(): QueryType {
|
||||
return 'metrics';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createDefaultConfigOptions(): DataSourceSettings<ElasticsearchOp
|
||||
maxConcurrentShardRequests: 300,
|
||||
logMessageField: 'test.message',
|
||||
logLevelField: 'test.level',
|
||||
defaultQueryMode: 'metrics',
|
||||
},
|
||||
secureJsonFields: {},
|
||||
} as DataSourceSettings<ElasticsearchOptions>;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { ElasticsearchOptions } from '../types';
|
||||
import { ElasticsearchOptions, QueryType } from '../types';
|
||||
|
||||
import { defaultMaxConcurrentShardRequests } from './ElasticDetails';
|
||||
import { defaultMaxConcurrentShardRequests, defaultQueryMode } from './ElasticDetails';
|
||||
|
||||
export const QUERY_TYPE_SELECTOR_OPTIONS: Array<SelectableValue<QueryType>> = [
|
||||
{ value: 'metrics', label: 'Metrics' },
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'raw_data', label: 'Raw Data' },
|
||||
{ value: 'raw_document', label: 'Raw Document' },
|
||||
];
|
||||
|
||||
export const coerceOptions = (
|
||||
options: DataSourceSettings<ElasticsearchOptions, {}>
|
||||
@@ -16,6 +23,7 @@ export const coerceOptions = (
|
||||
logMessageField: options.jsonData.logMessageField || '',
|
||||
logLevelField: options.jsonData.logLevelField || '',
|
||||
includeFrozen: options.jsonData.includeFrozen ?? false,
|
||||
defaultQueryMode: options.jsonData.defaultQueryMode || defaultQueryMode(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
isElasticsearchResponseWithAggregations,
|
||||
isElasticsearchResponseWithHits,
|
||||
ElasticsearchHits,
|
||||
QueryType,
|
||||
} from './types';
|
||||
import { getScriptValue, isTimeSeriesQuery } from './utils';
|
||||
|
||||
@@ -127,6 +128,7 @@ export class ElasticDatasource
|
||||
includeFrozen: boolean;
|
||||
isProxyAccess: boolean;
|
||||
databaseVersion: SemVer | null;
|
||||
defaultQueryMode?: QueryType;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>,
|
||||
@@ -146,9 +148,6 @@ export class ElasticDatasource
|
||||
this.intervalPattern = settingsData.interval;
|
||||
this.interval = settingsData.timeInterval;
|
||||
this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests;
|
||||
this.queryBuilder = new ElasticQueryBuilder({
|
||||
timeField: this.timeField,
|
||||
});
|
||||
this.logLevelField = settingsData.logLevelField || '';
|
||||
this.dataLinks = settingsData.dataLinks || [];
|
||||
this.includeFrozen = settingsData.includeFrozen ?? false;
|
||||
@@ -157,7 +156,11 @@ export class ElasticDatasource
|
||||
this.annotations = {
|
||||
QueryEditor: ElasticsearchAnnotationsQueryEditor,
|
||||
};
|
||||
|
||||
this.defaultQueryMode = settingsData.defaultQueryMode;
|
||||
this.queryBuilder = new ElasticQueryBuilder({
|
||||
timeField: this.timeField,
|
||||
defaultQueryMode: this.defaultQueryMode,
|
||||
});
|
||||
if (this.logLevelField === '') {
|
||||
this.logLevelField = undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths } from './queryDef';
|
||||
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths, queryTypeToMetricType } from './queryDef';
|
||||
import type { QueryType } from './types';
|
||||
|
||||
describe('ElasticQueryDef', () => {
|
||||
describe('isPipelineMetric', () => {
|
||||
@@ -36,4 +37,49 @@ describe('ElasticQueryDef', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTypeToMetricType', () => {
|
||||
describe('when type is undefined', () => {
|
||||
test('should return count as default', () => {
|
||||
const result = queryTypeToMetricType(undefined);
|
||||
expect(result).toBe('count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is metrics', () => {
|
||||
test('should return count', () => {
|
||||
const result = queryTypeToMetricType('metrics' as QueryType);
|
||||
expect(result).toBe('count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is logs', () => {
|
||||
test('should return logs', () => {
|
||||
const result = queryTypeToMetricType('logs' as QueryType);
|
||||
expect(result).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is raw_data', () => {
|
||||
test('should return raw_data', () => {
|
||||
const result = queryTypeToMetricType('raw_data' as QueryType);
|
||||
expect(result).toBe('raw_data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is raw_document', () => {
|
||||
test('should return raw_document', () => {
|
||||
const result = queryTypeToMetricType('raw_document' as QueryType);
|
||||
expect(result).toBe('raw_document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is invalid', () => {
|
||||
test('should throw an error', () => {
|
||||
expect(() => {
|
||||
queryTypeToMetricType('invalid_type' as QueryType);
|
||||
}).toThrow('invalid query type: invalid_type');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MetricAggregationType,
|
||||
MovingAverageModelOption,
|
||||
} from './dataquery.gen';
|
||||
import type { QueryType } from './types';
|
||||
|
||||
export const extendedStats: ExtendedStat[] = [
|
||||
{ label: 'Avg', value: 'avg' },
|
||||
@@ -42,6 +43,24 @@ export function defaultBucketAgg(id = '1'): DateHistogram {
|
||||
return { type: 'date_histogram', id, settings: { interval: 'auto' } };
|
||||
}
|
||||
|
||||
export function queryTypeToMetricType(type?: QueryType): MetricAggregation['type'] {
|
||||
if (!type) {
|
||||
return 'count'; // Default fallback
|
||||
}
|
||||
|
||||
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 findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
|
||||
metrics.find((metric) => metric.id === id);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
|
||||
index?: string;
|
||||
sigV4Auth?: boolean;
|
||||
oauthPassThru?: boolean;
|
||||
defaultQueryMode?: QueryType;
|
||||
}
|
||||
|
||||
export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';
|
||||
|
||||
Reference in New Issue
Block a user