mirror of https://github.com/grafana/grafana.git
				
				
				
			Elasticsearch: Detect Elasticsearch version (#63341)
* elasticsearch: detect database version * more test-friendly code
This commit is contained in:
		
							parent
							
								
									f9abd8608e
								
							
						
					
					
						commit
						d73fdcfc11
					
				|  | @ -1,9 +1,9 @@ | ||||||
| import { cx } from '@emotion/css'; | import { cx } from '@emotion/css'; | ||||||
| import React, { useCallback } from 'react'; | import React, { useCallback } from 'react'; | ||||||
| import { satisfies } from 'semver'; | import { satisfies, SemVer } from 'semver'; | ||||||
| 
 | 
 | ||||||
| import { SelectableValue } from '@grafana/data'; | import { SelectableValue } from '@grafana/data'; | ||||||
| import { InlineSegmentGroup, Segment, SegmentAsync, useTheme2 } from '@grafana/ui'; | import { InlineSegmentGroup, SegmentAsync, useTheme2 } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { useFields } from '../../../hooks/useFields'; | import { useFields } from '../../../hooks/useFields'; | ||||||
| import { useDispatch } from '../../../hooks/useStatelessReducer'; | import { useDispatch } from '../../../hooks/useStatelessReducer'; | ||||||
|  | @ -41,15 +41,16 @@ const isBasicAggregation = (metric: MetricAggregation) => !metricAggregationConf | ||||||
| 
 | 
 | ||||||
| const getTypeOptions = ( | const getTypeOptions = ( | ||||||
|   previousMetrics: MetricAggregation[], |   previousMetrics: MetricAggregation[], | ||||||
|   esVersion: string |   esVersion: SemVer | null | ||||||
| ): Array<SelectableValue<MetricAggregationType>> => { | ): Array<SelectableValue<MetricAggregationType>> => { | ||||||
|   // we'll include Pipeline Aggregations only if at least one previous metric is a "Basic" one
 |   // we'll include Pipeline Aggregations only if at least one previous metric is a "Basic" one
 | ||||||
|   const includePipelineAggregations = previousMetrics.some(isBasicAggregation); |   const includePipelineAggregations = previousMetrics.some(isBasicAggregation); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     Object.entries(metricAggregationConfig) |     Object.entries(metricAggregationConfig) | ||||||
|       // Only showing metrics type supported by the configured version of ES
 |       // Only showing metrics type supported by the version of ES.
 | ||||||
|       .filter(([_, { versionRange = '*' }]) => satisfies(esVersion, versionRange)) |       // if we cannot determine the version, we assume it is suitable.
 | ||||||
|  |       .filter(([_, { versionRange = '*' }]) => (esVersion != null ? satisfies(esVersion, versionRange) : true)) | ||||||
|       // Filtering out Pipeline Aggregations if there's no basic metric selected before
 |       // Filtering out Pipeline Aggregations if there's no basic metric selected before
 | ||||||
|       .filter(([_, config]) => includePipelineAggregations || !config.isPipelineAgg) |       .filter(([_, config]) => includePipelineAggregations || !config.isPipelineAgg) | ||||||
|       .map(([key, { label }]) => ({ |       .map(([key, { label }]) => ({ | ||||||
|  | @ -66,6 +67,11 @@ export const MetricEditor = ({ value }: Props) => { | ||||||
|   const dispatch = useDispatch(); |   const dispatch = useDispatch(); | ||||||
|   const getFields = useFields(value.type); |   const getFields = useFields(value.type); | ||||||
| 
 | 
 | ||||||
|  |   const getTypeOptionsAsync = async (previousMetrics: MetricAggregation[]) => { | ||||||
|  |     const dbVersion = await datasource.getDatabaseVersion(); | ||||||
|  |     return getTypeOptions(previousMetrics, dbVersion); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|   const loadOptions = useCallback(async () => { |   const loadOptions = useCallback(async () => { | ||||||
|     const remoteFields = await getFields(); |     const remoteFields = await getFields(); | ||||||
| 
 | 
 | ||||||
|  | @ -85,9 +91,9 @@ export const MetricEditor = ({ value }: Props) => { | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <InlineSegmentGroup> |       <InlineSegmentGroup> | ||||||
|         <Segment |         <SegmentAsync | ||||||
|           className={cx(styles.color, segmentStyles)} |           className={cx(styles.color, segmentStyles)} | ||||||
|           options={getTypeOptions(previousMetrics, datasource.esVersion)} |           loadOptions={() => getTypeOptionsAsync(previousMetrics)} | ||||||
|           onChange={(e) => dispatch(changeMetricType({ id: value.id, type: e.value! }))} |           onChange={(e) => dispatch(changeMetricType({ id: value.id, type: e.value! }))} | ||||||
|           value={toOption(value)} |           value={toOption(value)} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ import { QueryEditor } from '.'; | ||||||
| const noop = () => void 0; | const noop = () => void 0; | ||||||
| const datasourceMock = { | const datasourceMock = { | ||||||
|   esVersion: '7.10.0', |   esVersion: '7.10.0', | ||||||
|  |   getDatabaseVersion: () => Promise.resolve(null), | ||||||
| } as ElasticDatasource; | } as ElasticDatasource; | ||||||
| 
 | 
 | ||||||
| describe('QueryEditor', () => { | describe('QueryEditor', () => { | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| import { css } from '@emotion/css'; | import { css } from '@emotion/css'; | ||||||
| import React from 'react'; | import React, { useEffect, useState } from 'react'; | ||||||
|  | import { SemVer } from 'semver'; | ||||||
| 
 | 
 | ||||||
| import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; | import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; | ||||||
| import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; | import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; | ||||||
|  | @ -8,7 +9,7 @@ import { ElasticDatasource } from '../../datasource'; | ||||||
| import { useNextId } from '../../hooks/useNextId'; | import { useNextId } from '../../hooks/useNextId'; | ||||||
| import { useDispatch } from '../../hooks/useStatelessReducer'; | import { useDispatch } from '../../hooks/useStatelessReducer'; | ||||||
| import { ElasticsearchOptions, ElasticsearchQuery } from '../../types'; | import { ElasticsearchOptions, ElasticsearchQuery } from '../../types'; | ||||||
| import { isSupportedVersion } from '../../utils'; | import { isSupportedVersion, unsupportedVersionMessage } from '../../utils'; | ||||||
| 
 | 
 | ||||||
| import { BucketAggregationsEditor } from './BucketAggregationsEditor'; | import { BucketAggregationsEditor } from './BucketAggregationsEditor'; | ||||||
| import { ElasticsearchProvider } from './ElasticsearchQueryContext'; | import { ElasticsearchProvider } from './ElasticsearchQueryContext'; | ||||||
|  | @ -18,14 +19,35 @@ import { changeAliasPattern, changeQuery } from './state'; | ||||||
| 
 | 
 | ||||||
| export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions>; | export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions>; | ||||||
| 
 | 
 | ||||||
| export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }: ElasticQueryEditorProps) => { | // a react hook that returns the elasticsearch database version,
 | ||||||
|   if (!isSupportedVersion(datasource.esVersion)) { | // or `null`, while loading, or if it is not possible to determine the value.
 | ||||||
|     return ( | function useElasticVersion(datasource: ElasticDatasource): SemVer | null { | ||||||
|       <Alert |   const [version, setVersion] = useState<SemVer | null>(null); | ||||||
|         title={`Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed`} |   useEffect(() => { | ||||||
|       ></Alert> |     let canceled = false; | ||||||
|  |     datasource.getDatabaseVersion().then( | ||||||
|  |       (version) => { | ||||||
|  |         if (!canceled) { | ||||||
|  |           setVersion(version); | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       (error) => { | ||||||
|  |         // we do nothing
 | ||||||
|  |         console.log(error); | ||||||
|  |       } | ||||||
|     ); |     ); | ||||||
|   } | 
 | ||||||
|  |     return () => { | ||||||
|  |       canceled = true; | ||||||
|  |     }; | ||||||
|  |   }, [datasource]); | ||||||
|  | 
 | ||||||
|  |   return version; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }: ElasticQueryEditorProps) => { | ||||||
|  |   const elasticVersion = useElasticVersion(datasource); | ||||||
|  |   const showUnsupportedMessage = elasticVersion != null && !isSupportedVersion(elasticVersion); | ||||||
|   return ( |   return ( | ||||||
|     <ElasticsearchProvider |     <ElasticsearchProvider | ||||||
|       datasource={datasource} |       datasource={datasource} | ||||||
|  | @ -34,6 +56,7 @@ export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }: | ||||||
|       query={query} |       query={query} | ||||||
|       range={range || getDefaultTimeRange()} |       range={range || getDefaultTimeRange()} | ||||||
|     > |     > | ||||||
|  |       {showUnsupportedMessage && <Alert title={unsupportedVersionMessage} />} | ||||||
|       <QueryEditorForm value={query} /> |       <QueryEditorForm value={query} /> | ||||||
|     </ElasticsearchProvider> |     </ElasticsearchProvider> | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|  | @ -6,7 +6,6 @@ import { Alert, DataSourceHttpSettings, SecureSocksProxySettings } from '@grafan | ||||||
| import { config } from 'app/core/config'; | import { config } from 'app/core/config'; | ||||||
| 
 | 
 | ||||||
| import { ElasticsearchOptions } from '../types'; | import { ElasticsearchOptions } from '../types'; | ||||||
| import { isSupportedVersion } from '../utils'; |  | ||||||
| 
 | 
 | ||||||
| import { DataLinks } from './DataLinks'; | import { DataLinks } from './DataLinks'; | ||||||
| import { ElasticDetails } from './ElasticDetails'; | import { ElasticDetails } from './ElasticDetails'; | ||||||
|  | @ -34,8 +33,6 @@ export const ConfigEditor = (props: Props) => { | ||||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 |     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||||
|   }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|   const supportedVersion = isSupportedVersion(options.jsonData.esVersion); |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       {options.access === 'direct' && ( |       {options.access === 'direct' && ( | ||||||
|  | @ -43,11 +40,6 @@ export const ConfigEditor = (props: Props) => { | ||||||
|           Browser access mode in the Elasticsearch datasource is no longer available. Switch to server access mode. |           Browser access mode in the Elasticsearch datasource is no longer available. Switch to server access mode. | ||||||
|         </Alert> |         </Alert> | ||||||
|       )} |       )} | ||||||
|       {!supportedVersion && ( |  | ||||||
|         <Alert title="Deprecation notice" severity="error"> |  | ||||||
|           {`Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed`} |  | ||||||
|         </Alert> |  | ||||||
|       )} |  | ||||||
|       <DataSourceHttpSettings |       <DataSourceHttpSettings | ||||||
|         defaultUrl="http://localhost:9200" |         defaultUrl="http://localhost:9200" | ||||||
|         dataSourceConfig={options} |         dataSourceConfig={options} | ||||||
|  |  | ||||||
|  | @ -181,14 +181,14 @@ describe('ElasticDatasource', () => { | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('When testing datasource with index pattern', () => { |   describe('When testing datasource with index pattern', () => { | ||||||
|     it('should translate index pattern to current day', () => { |     it('should translate index pattern to current day', async () => { | ||||||
|       const { ds, fetchMock } = getTestContext({ jsonData: { interval: 'Daily', esVersion: '7.10.0' } }); |       const { ds, fetchMock } = getTestContext({ jsonData: { interval: 'Daily', esVersion: '7.10.0' } }); | ||||||
| 
 | 
 | ||||||
|       ds.testDatasource(); |       await ds.testDatasource(); | ||||||
| 
 | 
 | ||||||
|       const today = toUtc().format('YYYY.MM.DD'); |       const today = toUtc().format('YYYY.MM.DD'); | ||||||
|       expect(fetchMock).toHaveBeenCalledTimes(1); |       const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; | ||||||
|       expect(fetchMock.mock.calls[0][0].url).toBe(`${ELASTICSEARCH_MOCK_URL}/test-${today}/_mapping`); |       expect(lastCall[0].url).toBe(`${ELASTICSEARCH_MOCK_URL}/test-${today}/_mapping`); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| import { cloneDeep, find, first as _first, isNumber, isObject, isString, map as _map } from 'lodash'; | import { cloneDeep, find, first as _first, isNumber, isObject, isString, map as _map } from 'lodash'; | ||||||
| import { generate, lastValueFrom, Observable, of, throwError } from 'rxjs'; | import { generate, lastValueFrom, Observable, of, throwError } from 'rxjs'; | ||||||
| import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty, tap } from 'rxjs/operators'; | import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty, tap } from 'rxjs/operators'; | ||||||
|  | import { SemVer } from 'semver'; | ||||||
| 
 | 
 | ||||||
| import { | import { | ||||||
|   DataFrame, |   DataFrame, | ||||||
|  | @ -49,7 +50,7 @@ import { metricAggregationConfig } from './components/QueryEditor/MetricAggregat | ||||||
| import { defaultBucketAgg, hasMetricOfType } from './queryDef'; | import { defaultBucketAgg, hasMetricOfType } from './queryDef'; | ||||||
| import { trackQuery } from './tracking'; | import { trackQuery } from './tracking'; | ||||||
| import { Logs, BucketAggregation, DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery, TermsQuery } from './types'; | import { Logs, BucketAggregation, DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery, TermsQuery } from './types'; | ||||||
| import { coerceESVersion, getScriptValue, isSupportedVersion } from './utils'; | import { coerceESVersion, getScriptValue, isSupportedVersion, unsupportedVersionMessage } from './utils'; | ||||||
| 
 | 
 | ||||||
| export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-'; | export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-'; | ||||||
| // Those are metadata fields as defined in https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-fields.html#_identity_metadata_fields.
 | // Those are metadata fields as defined in https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-fields.html#_identity_metadata_fields.
 | ||||||
|  | @ -92,6 +93,7 @@ export class ElasticDatasource | ||||||
|   includeFrozen: boolean; |   includeFrozen: boolean; | ||||||
|   isProxyAccess: boolean; |   isProxyAccess: boolean; | ||||||
|   timeSrv: TimeSrv; |   timeSrv: TimeSrv; | ||||||
|  |   databaseVersion: SemVer | null; | ||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>, |     instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>, | ||||||
|  | @ -119,6 +121,7 @@ export class ElasticDatasource | ||||||
|     this.logLevelField = settingsData.logLevelField || ''; |     this.logLevelField = settingsData.logLevelField || ''; | ||||||
|     this.dataLinks = settingsData.dataLinks || []; |     this.dataLinks = settingsData.dataLinks || []; | ||||||
|     this.includeFrozen = settingsData.includeFrozen ?? false; |     this.includeFrozen = settingsData.includeFrozen ?? false; | ||||||
|  |     this.databaseVersion = null; | ||||||
|     this.annotations = { |     this.annotations = { | ||||||
|       QueryEditor: ElasticsearchAnnotationsQueryEditor, |       QueryEditor: ElasticsearchAnnotationsQueryEditor, | ||||||
|     }; |     }; | ||||||
|  | @ -147,13 +150,6 @@ export class ElasticDatasource | ||||||
|       return throwError(() => error); |       return throwError(() => error); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!isSupportedVersion(this.esVersion)) { |  | ||||||
|       const error = new Error( |  | ||||||
|         'Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed.' |  | ||||||
|       ); |  | ||||||
|       return throwError(() => error); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const options: BackendSrvRequest = { |     const options: BackendSrvRequest = { | ||||||
|       url: this.url + '/' + url, |       url: this.url + '/' + url, | ||||||
|       method, |       method, | ||||||
|  | @ -395,16 +391,24 @@ export class ElasticDatasource | ||||||
|     return queries.map((q) => this.applyTemplateVariables(q, scopedVars)); |     return queries.map((q) => this.applyTemplateVariables(q, scopedVars)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   testDatasource() { |   async testDatasource() { | ||||||
|  |     // we explicitly ask for uncached, "fresh" data here
 | ||||||
|  |     const dbVersion = await this.getDatabaseVersion(false); | ||||||
|  |     // if we are not able to determine the elastic-version, we assume it is a good version.
 | ||||||
|  |     const isSupported = dbVersion != null ? isSupportedVersion(dbVersion) : true; | ||||||
|  |     const versionMessage = isSupported ? '' : `WARNING: ${unsupportedVersionMessage} `; | ||||||
|     // validate that the index exist and has date field
 |     // validate that the index exist and has date field
 | ||||||
|     return lastValueFrom( |     return lastValueFrom( | ||||||
|       this.getFields(['date']).pipe( |       this.getFields(['date']).pipe( | ||||||
|         mergeMap((dateFields) => { |         mergeMap((dateFields) => { | ||||||
|           const timeField: any = find(dateFields, { text: this.timeField }); |           const timeField: any = find(dateFields, { text: this.timeField }); | ||||||
|           if (!timeField) { |           if (!timeField) { | ||||||
|             return of({ status: 'error', message: 'No date field named ' + this.timeField + ' found' }); |             return of({ | ||||||
|  |               status: 'error', | ||||||
|  |               message: 'No date field named ' + this.timeField + ' found', | ||||||
|  |             }); | ||||||
|           } |           } | ||||||
|           return of({ status: 'success', message: 'Index OK. Time field name OK.' }); |           return of({ status: 'success', message: `${versionMessage}Index OK. Time field name OK` }); | ||||||
|         }), |         }), | ||||||
|         catchError((err) => { |         catchError((err) => { | ||||||
|           console.error(err); |           console.error(err); | ||||||
|  | @ -1040,6 +1044,41 @@ export class ElasticDatasource | ||||||
|     const finalQuery = JSON.parse(this.templateSrv.replace(JSON.stringify(expandedQuery), scopedVars)); |     const finalQuery = JSON.parse(this.templateSrv.replace(JSON.stringify(expandedQuery), scopedVars)); | ||||||
|     return finalQuery; |     return finalQuery; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   private getDatabaseVersionUncached(): Promise<SemVer | null> { | ||||||
|  |     // we want this function to never fail
 | ||||||
|  |     return lastValueFrom(this.request('GET', '/')).then( | ||||||
|  |       (data) => { | ||||||
|  |         const versionNumber = data?.version?.number; | ||||||
|  |         if (typeof versionNumber !== 'string') { | ||||||
|  |           return null; | ||||||
|  |         } | ||||||
|  |         try { | ||||||
|  |           return new SemVer(versionNumber); | ||||||
|  |         } catch (error) { | ||||||
|  |           console.error(error); | ||||||
|  |           return null; | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       (error) => { | ||||||
|  |         console.error(error); | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async getDatabaseVersion(useCachedData = true): Promise<SemVer | null> { | ||||||
|  |     if (useCachedData) { | ||||||
|  |       const cached = this.databaseVersion; | ||||||
|  |       if (cached != null) { | ||||||
|  |         return cached; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const freshDatabaseVersion = await this.getDatabaseVersionUncached(); | ||||||
|  |     this.databaseVersion = freshDatabaseVersion; | ||||||
|  |     return freshDatabaseVersion; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { valid, gte } from 'semver'; | import { valid, gte, SemVer } from 'semver'; | ||||||
| 
 | 
 | ||||||
| import { isMetricAggregationWithField } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; | import { isMetricAggregationWithField } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; | ||||||
| import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; | import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; | ||||||
|  | @ -117,10 +117,13 @@ export const coerceESVersion = (version: string | number | undefined): string => | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const isSupportedVersion = (version: string): boolean => { | export const isSupportedVersion = (version: SemVer): boolean => { | ||||||
|   if (gte(version, '7.10.0')) { |   if (gte(version, '7.10.0')) { | ||||||
|     return true; |     return true; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return false; |   return false; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | export const unsupportedVersionMessage = | ||||||
|  |   'Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed. Using unsupported version of Elasticsearch may lead to unexpected and incorrect results.'; | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue