mirror of https://github.com/grafana/grafana
Variables: Adds new Api that allows proper QueryEditors for Query variables (#28217)
* Initial * WIP * wip * Refactor: fixing types * Refactor: Fixed more typings * Feature: Moves TestData to new API * Feature: Moves CloudMonitoringDatasource to new API * Feature: Moves PrometheusDatasource to new Variables API * Refactor: Clean up comments * Refactor: changes to QueryEditorProps instead * Refactor: cleans up testdata, prometheus and cloud monitoring variable support * Refactor: adds variableQueryRunner * Refactor: adds props to VariableQueryEditor * Refactor: reverted Loki editor * Refactor: refactor queryrunner into smaller pieces * Refactor: adds upgrade query thunk * Tests: Updates old tests * Docs: fixes build errors for exported api * Tests: adds guard tests * Tests: adds QueryRunner tests * Tests: fixes broken tests * Tests: adds variableQueryObserver tests * Test: adds tests for operator functions * Test: adds VariableQueryRunner tests * Refactor: renames dataSource * Refactor: adds definition for standard variable support * Refactor: adds cancellation to OptionPicker * Refactor: changes according to Dominiks suggestion * Refactor:tt * Refactor: adds tests for factories * Refactor: restructuring a bit * Refactor: renames variableQueryRunner.ts * Refactor: adds quick exit when runRequest returns errors * Refactor: using TextArea from grafana/ui * Refactor: changed from interfaces to classes instead * Tests: fixes broken test * Docs: fixes doc issue count * Docs: fixes doc issue count * Refactor: Adds check for self referencing queries * Tests: fixed unused variable * Refactor: Changes commentspull/29212/head
parent
5ae7280249
commit
112a755e18
@ -0,0 +1,99 @@ |
|||||||
|
import { ComponentType } from 'react'; |
||||||
|
import { Observable } from 'rxjs'; |
||||||
|
|
||||||
|
import { |
||||||
|
DataQuery, |
||||||
|
DataQueryRequest, |
||||||
|
DataQueryResponse, |
||||||
|
DataSourceApi, |
||||||
|
DataSourceJsonData, |
||||||
|
DataSourceOptionsType, |
||||||
|
DataSourceQueryType, |
||||||
|
QueryEditorProps, |
||||||
|
} from './datasource'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Enum with the different variable support types |
||||||
|
* |
||||||
|
* @alpha -- experimental |
||||||
|
*/ |
||||||
|
export enum VariableSupportType { |
||||||
|
Legacy = 'legacy', |
||||||
|
Standard = 'standard', |
||||||
|
Custom = 'custom', |
||||||
|
Datasource = 'datasource', |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Base class for VariableSupport classes |
||||||
|
* |
||||||
|
* @alpha -- experimental |
||||||
|
*/ |
||||||
|
export abstract class VariableSupportBase< |
||||||
|
DSType extends DataSourceApi<TQuery, TOptions>, |
||||||
|
TQuery extends DataQuery = DataSourceQueryType<DSType>, |
||||||
|
TOptions extends DataSourceJsonData = DataSourceOptionsType<DSType> |
||||||
|
> { |
||||||
|
abstract getType(): VariableSupportType; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extend this class in a data source plugin to use the standard query editor for Query variables |
||||||
|
* |
||||||
|
* @alpha -- experimental |
||||||
|
*/ |
||||||
|
export abstract class StandardVariableSupport< |
||||||
|
DSType extends DataSourceApi<TQuery, TOptions>, |
||||||
|
TQuery extends DataQuery = DataSourceQueryType<DSType>, |
||||||
|
TOptions extends DataSourceJsonData = DataSourceOptionsType<DSType> |
||||||
|
> extends VariableSupportBase<DSType, TQuery, TOptions> { |
||||||
|
getType(): VariableSupportType { |
||||||
|
return VariableSupportType.Standard; |
||||||
|
} |
||||||
|
|
||||||
|
abstract toDataQuery(query: StandardVariableQuery): TQuery; |
||||||
|
query?(request: DataQueryRequest<TQuery>): Observable<DataQueryResponse>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extend this class in a data source plugin to use a customized query editor for Query variables |
||||||
|
* |
||||||
|
* @alpha -- experimental |
||||||
|
*/ |
||||||
|
export abstract class CustomVariableSupport< |
||||||
|
DSType extends DataSourceApi<TQuery, TOptions>, |
||||||
|
VariableQuery extends DataQuery = any, |
||||||
|
TQuery extends DataQuery = DataSourceQueryType<DSType>, |
||||||
|
TOptions extends DataSourceJsonData = DataSourceOptionsType<DSType> |
||||||
|
> extends VariableSupportBase<DSType, TQuery, TOptions> { |
||||||
|
getType(): VariableSupportType { |
||||||
|
return VariableSupportType.Custom; |
||||||
|
} |
||||||
|
|
||||||
|
abstract editor: ComponentType<QueryEditorProps<DSType, TQuery, TOptions, VariableQuery>>; |
||||||
|
abstract query(request: DataQueryRequest<VariableQuery>): Observable<DataQueryResponse>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extend this class in a data source plugin to use the query editor in the data source plugin for Query variables |
||||||
|
* |
||||||
|
* @alpha -- experimental |
||||||
|
*/ |
||||||
|
export abstract class DataSourceVariableSupport< |
||||||
|
DSType extends DataSourceApi<TQuery, TOptions>, |
||||||
|
TQuery extends DataQuery = DataSourceQueryType<DSType>, |
||||||
|
TOptions extends DataSourceJsonData = DataSourceOptionsType<DSType> |
||||||
|
> extends VariableSupportBase<DSType, TQuery, TOptions> { |
||||||
|
getType(): VariableSupportType { |
||||||
|
return VariableSupportType.Datasource; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Defines the standard DatQuery used by data source plugins that implement StandardVariableSupport |
||||||
|
* |
||||||
|
* @alpha -- experimental |
||||||
|
*/ |
||||||
|
export interface StandardVariableQuery extends DataQuery { |
||||||
|
query: string; |
||||||
|
} |
||||||
@ -1,46 +0,0 @@ |
|||||||
import React, { PureComponent } from 'react'; |
|
||||||
import { VariableQueryProps } from 'app/types/plugins'; |
|
||||||
import { selectors } from '@grafana/e2e-selectors'; |
|
||||||
|
|
||||||
export default class DefaultVariableQueryEditor extends PureComponent<VariableQueryProps, any> { |
|
||||||
constructor(props: VariableQueryProps) { |
|
||||||
super(props); |
|
||||||
this.state = { value: props.query }; |
|
||||||
} |
|
||||||
|
|
||||||
onChange = (event: React.FormEvent<HTMLTextAreaElement>) => { |
|
||||||
this.setState({ value: event.currentTarget.value }); |
|
||||||
}; |
|
||||||
|
|
||||||
onBlur = (event: React.FormEvent<HTMLTextAreaElement>) => { |
|
||||||
this.props.onChange(event.currentTarget.value, event.currentTarget.value); |
|
||||||
}; |
|
||||||
|
|
||||||
getLineCount() { |
|
||||||
const { value } = this.state; |
|
||||||
|
|
||||||
if (typeof value === 'string') { |
|
||||||
return value.split('\n').length; |
|
||||||
} |
|
||||||
|
|
||||||
return 1; |
|
||||||
} |
|
||||||
|
|
||||||
render() { |
|
||||||
return ( |
|
||||||
<div className="gf-form"> |
|
||||||
<span className="gf-form-label width-10">Query</span> |
|
||||||
<textarea |
|
||||||
rows={this.getLineCount()} |
|
||||||
className="gf-form-input" |
|
||||||
value={this.state.value} |
|
||||||
onChange={this.onChange} |
|
||||||
onBlur={this.onBlur} |
|
||||||
placeholder="metric name or tags query" |
|
||||||
required |
|
||||||
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,62 @@ |
|||||||
|
import React, { FC, useCallback, useState } from 'react'; |
||||||
|
import { selectors } from '@grafana/e2e-selectors'; |
||||||
|
|
||||||
|
import { VariableQueryProps } from 'app/types/plugins'; |
||||||
|
import { InlineField, TextArea, useStyles } from '@grafana/ui'; |
||||||
|
import { GrafanaTheme } from '@grafana/data'; |
||||||
|
import { css } from 'emotion'; |
||||||
|
|
||||||
|
export const LEGACY_VARIABLE_QUERY_EDITOR_NAME = 'Grafana-LegacyVariableQueryEditor'; |
||||||
|
|
||||||
|
export const LegacyVariableQueryEditor: FC<VariableQueryProps> = ({ onChange, query }) => { |
||||||
|
const styles = useStyles(getStyles); |
||||||
|
const [value, setValue] = useState(query); |
||||||
|
const onValueChange = useCallback( |
||||||
|
(event: React.FormEvent<HTMLTextAreaElement>) => { |
||||||
|
setValue(event.currentTarget.value); |
||||||
|
}, |
||||||
|
[onChange] |
||||||
|
); |
||||||
|
const onBlur = useCallback( |
||||||
|
(event: React.FormEvent<HTMLTextAreaElement>) => { |
||||||
|
onChange(event.currentTarget.value, event.currentTarget.value); |
||||||
|
}, |
||||||
|
[onChange] |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="gf-form"> |
||||||
|
<InlineField label="Query" labelWidth={20} grow={false} className={styles.inlineFieldOverride}> |
||||||
|
<span hidden /> |
||||||
|
</InlineField> |
||||||
|
<TextArea |
||||||
|
rows={getLineCount(value)} |
||||||
|
className="gf-form-input" |
||||||
|
value={value} |
||||||
|
onChange={onValueChange} |
||||||
|
onBlur={onBlur} |
||||||
|
placeholder="metric name or tags query" |
||||||
|
required |
||||||
|
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme) { |
||||||
|
return { |
||||||
|
inlineFieldOverride: css` |
||||||
|
margin: 0; |
||||||
|
`,
|
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
LegacyVariableQueryEditor.displayName = LEGACY_VARIABLE_QUERY_EDITOR_NAME; |
||||||
|
|
||||||
|
const getLineCount = (value: any) => { |
||||||
|
if (value && typeof value === 'string') { |
||||||
|
return value.split('\n').length; |
||||||
|
} |
||||||
|
|
||||||
|
return 1; |
||||||
|
}; |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
import { VariableSupportType } from '@grafana/data'; |
||||||
|
import { getVariableQueryEditor, StandardVariableQueryEditor } from './getVariableQueryEditor'; |
||||||
|
import { LegacyVariableQueryEditor } from './LegacyVariableQueryEditor'; |
||||||
|
|
||||||
|
describe('getVariableQueryEditor', () => { |
||||||
|
describe('happy cases', () => { |
||||||
|
describe('when called with a data source with custom variable support', () => { |
||||||
|
it('then it should return correct editor', async () => { |
||||||
|
const editor: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = { |
||||||
|
variables: { getType: () => VariableSupportType.Custom, query: () => undefined, editor }, |
||||||
|
}; |
||||||
|
|
||||||
|
const result = await getVariableQueryEditor(datasource); |
||||||
|
|
||||||
|
expect(result).toBe(editor); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with standard variable support', () => { |
||||||
|
it('then it should return correct editor', async () => { |
||||||
|
const editor: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = { |
||||||
|
variables: { getType: () => VariableSupportType.Standard, toDataQuery: () => undefined }, |
||||||
|
}; |
||||||
|
|
||||||
|
const result = await getVariableQueryEditor(datasource); |
||||||
|
|
||||||
|
expect(result).toBe(editor); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with datasource variable support', () => { |
||||||
|
it('then it should return correct editor', async () => { |
||||||
|
const editor: any = StandardVariableQueryEditor; |
||||||
|
const plugin = { components: { QueryEditor: editor } }; |
||||||
|
const importDataSourcePluginFunc = jest.fn().mockResolvedValue(plugin); |
||||||
|
const datasource: any = { variables: { getType: () => VariableSupportType.Datasource }, meta: {} }; |
||||||
|
|
||||||
|
const result = await getVariableQueryEditor(datasource, importDataSourcePluginFunc); |
||||||
|
|
||||||
|
expect(result).toBe(editor); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledTimes(1); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledWith({}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with legacy variable support', () => { |
||||||
|
it('then it should return correct editor', async () => { |
||||||
|
const editor: any = StandardVariableQueryEditor; |
||||||
|
const plugin = { components: { VariableQueryEditor: editor } }; |
||||||
|
const importDataSourcePluginFunc = jest.fn().mockResolvedValue(plugin); |
||||||
|
const datasource: any = { metricFindQuery: () => undefined, meta: {} }; |
||||||
|
|
||||||
|
const result = await getVariableQueryEditor(datasource, importDataSourcePluginFunc); |
||||||
|
|
||||||
|
expect(result).toBe(editor); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledTimes(1); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledWith({}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('negative cases', () => { |
||||||
|
describe('when variable support is not recognized', () => { |
||||||
|
it('then it should return null', async () => { |
||||||
|
const datasource: any = {}; |
||||||
|
|
||||||
|
const result = await getVariableQueryEditor(datasource); |
||||||
|
|
||||||
|
expect(result).toBeNull(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with datasource variable support but missing QueryEditor', () => { |
||||||
|
it('then it should return throw', async () => { |
||||||
|
const plugin = { components: {} }; |
||||||
|
const importDataSourcePluginFunc = jest.fn().mockResolvedValue(plugin); |
||||||
|
const datasource: any = { variables: { getType: () => VariableSupportType.Datasource }, meta: {} }; |
||||||
|
|
||||||
|
await expect(getVariableQueryEditor(datasource, importDataSourcePluginFunc)).rejects.toThrow( |
||||||
|
new Error('Missing QueryEditor in plugin definition.') |
||||||
|
); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledTimes(1); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledWith({}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with legacy variable support but missing VariableQueryEditor', () => { |
||||||
|
it('then it should return LegacyVariableQueryEditor', async () => { |
||||||
|
const plugin = { components: {} }; |
||||||
|
const importDataSourcePluginFunc = jest.fn().mockResolvedValue(plugin); |
||||||
|
const datasource: any = { metricFindQuery: () => undefined, meta: {} }; |
||||||
|
|
||||||
|
const result = await getVariableQueryEditor(datasource, importDataSourcePluginFunc); |
||||||
|
|
||||||
|
expect(result).toBe(LegacyVariableQueryEditor); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledTimes(1); |
||||||
|
expect(importDataSourcePluginFunc).toHaveBeenCalledWith({}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
import React, { useCallback } from 'react'; |
||||||
|
import { DataQuery, DataSourceApi, DataSourceJsonData, QueryEditorProps, StandardVariableQuery } from '@grafana/data'; |
||||||
|
import { getTemplateSrv } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { LegacyVariableQueryEditor } from './LegacyVariableQueryEditor'; |
||||||
|
import { |
||||||
|
hasCustomVariableSupport, |
||||||
|
hasDatasourceVariableSupport, |
||||||
|
hasLegacyVariableSupport, |
||||||
|
hasStandardVariableSupport, |
||||||
|
} from '../guard'; |
||||||
|
import { importDataSourcePlugin } from '../../plugins/plugin_loader'; |
||||||
|
import { VariableQueryEditorType } from '../types'; |
||||||
|
|
||||||
|
export async function getVariableQueryEditor< |
||||||
|
TQuery extends DataQuery = DataQuery, |
||||||
|
TOptions extends DataSourceJsonData = DataSourceJsonData, |
||||||
|
VariableQuery extends DataQuery = TQuery |
||||||
|
>( |
||||||
|
datasource: DataSourceApi<TQuery, TOptions>, |
||||||
|
importDataSourcePluginFunc = importDataSourcePlugin |
||||||
|
): Promise<VariableQueryEditorType> { |
||||||
|
if (hasCustomVariableSupport(datasource)) { |
||||||
|
return datasource.variables.editor; |
||||||
|
} |
||||||
|
|
||||||
|
if (hasDatasourceVariableSupport(datasource)) { |
||||||
|
const dsPlugin = await importDataSourcePluginFunc(datasource.meta!); |
||||||
|
|
||||||
|
if (!dsPlugin.components.QueryEditor) { |
||||||
|
throw new Error('Missing QueryEditor in plugin definition.'); |
||||||
|
} |
||||||
|
|
||||||
|
return dsPlugin.components.QueryEditor ?? null; |
||||||
|
} |
||||||
|
|
||||||
|
if (hasStandardVariableSupport(datasource)) { |
||||||
|
return StandardVariableQueryEditor; |
||||||
|
} |
||||||
|
|
||||||
|
if (hasLegacyVariableSupport(datasource)) { |
||||||
|
const dsPlugin = await importDataSourcePluginFunc(datasource.meta!); |
||||||
|
return dsPlugin.components.VariableQueryEditor ?? LegacyVariableQueryEditor; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
export function StandardVariableQueryEditor< |
||||||
|
TQuery extends DataQuery = DataQuery, |
||||||
|
TOptions extends DataSourceJsonData = DataSourceJsonData |
||||||
|
>({ |
||||||
|
datasource: propsDatasource, |
||||||
|
query: propsQuery, |
||||||
|
onChange: propsOnChange, |
||||||
|
}: QueryEditorProps<any, TQuery, TOptions, StandardVariableQuery>) { |
||||||
|
const onChange = useCallback( |
||||||
|
(query: any) => { |
||||||
|
propsOnChange({ refId: 'StandardVariableQuery', query }); |
||||||
|
}, |
||||||
|
[propsOnChange] |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<LegacyVariableQueryEditor |
||||||
|
query={propsQuery.query} |
||||||
|
onChange={onChange} |
||||||
|
datasource={propsDatasource} |
||||||
|
templateSrv={getTemplateSrv()} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,243 @@ |
|||||||
|
import { |
||||||
|
hasCustomVariableSupport, |
||||||
|
hasDatasourceVariableSupport, |
||||||
|
hasLegacyVariableSupport, |
||||||
|
hasStandardVariableSupport, |
||||||
|
isLegacyQueryEditor, |
||||||
|
isQueryEditor, |
||||||
|
} from './guard'; |
||||||
|
import { LegacyVariableQueryEditor } from './editor/LegacyVariableQueryEditor'; |
||||||
|
import { StandardVariableQueryEditor } from './editor/getVariableQueryEditor'; |
||||||
|
import { VariableSupportType } from '@grafana/data'; |
||||||
|
|
||||||
|
describe('type guards', () => { |
||||||
|
describe('hasLegacyVariableSupport', () => { |
||||||
|
describe('when called with a legacy data source', () => { |
||||||
|
it('should return true', () => { |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
expect(hasLegacyVariableSupport(datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with data source without metricFindQuery function', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = {}; |
||||||
|
expect(hasLegacyVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a legacy data source with variable support', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = { metricFindQuery: () => undefined, variables: {} }; |
||||||
|
expect(hasLegacyVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('hasStandardVariableSupport', () => { |
||||||
|
describe('when called with a data source with standard variable support', () => { |
||||||
|
it('should return true', () => { |
||||||
|
const datasource: any = { |
||||||
|
metricFindQuery: () => undefined, |
||||||
|
variables: { getType: () => VariableSupportType.Standard, toDataQuery: () => undefined }, |
||||||
|
}; |
||||||
|
expect(hasStandardVariableSupport(datasource)).toBe(true); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and with a custom query', () => { |
||||||
|
it('should return true', () => { |
||||||
|
const datasource: any = { |
||||||
|
metricFindQuery: () => undefined, |
||||||
|
variables: { |
||||||
|
getType: () => VariableSupportType.Standard, |
||||||
|
toDataQuery: () => undefined, |
||||||
|
query: () => undefined, |
||||||
|
}, |
||||||
|
}; |
||||||
|
expect(hasStandardVariableSupport(datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with partial standard variable support', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = { |
||||||
|
metricFindQuery: () => undefined, |
||||||
|
variables: { getType: () => VariableSupportType.Standard, query: () => undefined }, |
||||||
|
}; |
||||||
|
expect(hasStandardVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source without standard variable support', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
expect(hasStandardVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('hasCustomVariableSupport', () => { |
||||||
|
describe('when called with a data source with custom variable support', () => { |
||||||
|
it('should return true', () => { |
||||||
|
const datasource: any = { |
||||||
|
metricFindQuery: () => undefined, |
||||||
|
variables: { getType: () => VariableSupportType.Custom, query: () => undefined, editor: {} }, |
||||||
|
}; |
||||||
|
expect(hasCustomVariableSupport(datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with custom variable support but without editor', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = { |
||||||
|
metricFindQuery: () => undefined, |
||||||
|
variables: { getType: () => VariableSupportType.Custom, query: () => undefined }, |
||||||
|
}; |
||||||
|
expect(hasCustomVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source with custom variable support but without query', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = { |
||||||
|
metricFindQuery: () => undefined, |
||||||
|
variables: { getType: () => VariableSupportType.Custom, editor: {} }, |
||||||
|
}; |
||||||
|
expect(hasCustomVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source without custom variable support', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
expect(hasCustomVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('hasDatasourceVariableSupport', () => { |
||||||
|
describe('when called with a data source with datasource variable support', () => { |
||||||
|
it('should return true', () => { |
||||||
|
const datasource: any = { |
||||||
|
metricFindQuery: () => undefined, |
||||||
|
variables: { getType: () => VariableSupportType.Datasource }, |
||||||
|
}; |
||||||
|
expect(hasDatasourceVariableSupport(datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a data source without datasource variable support', () => { |
||||||
|
it('should return false', () => { |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
expect(hasDatasourceVariableSupport(datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('isLegacyQueryEditor', () => { |
||||||
|
describe('happy cases', () => { |
||||||
|
describe('when called with a legacy query editor but without a legacy data source', () => { |
||||||
|
it('then is should return true', () => { |
||||||
|
const component: any = LegacyVariableQueryEditor; |
||||||
|
const datasource: any = {}; |
||||||
|
|
||||||
|
expect(isLegacyQueryEditor(component, datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a legacy data source but without a legacy query editor', () => { |
||||||
|
it('then is should return true', () => { |
||||||
|
const component: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
|
||||||
|
expect(isLegacyQueryEditor(component, datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('negative cases', () => { |
||||||
|
describe('when called without component', () => { |
||||||
|
it('then is should return false', () => { |
||||||
|
const component: any = null; |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
|
||||||
|
expect(isLegacyQueryEditor(component, datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called without a legacy query editor and without a legacy data source', () => { |
||||||
|
it('then is should return false', () => { |
||||||
|
const component: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = {}; |
||||||
|
|
||||||
|
expect(isLegacyQueryEditor(component, datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('isQueryEditor', () => { |
||||||
|
describe('happy cases', () => { |
||||||
|
describe('when called without a legacy editor and with a data source with standard variable support', () => { |
||||||
|
it('then is should return true', () => { |
||||||
|
const component: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = { |
||||||
|
variables: { getType: () => VariableSupportType.Standard, toDataQuery: () => undefined }, |
||||||
|
}; |
||||||
|
|
||||||
|
expect(isQueryEditor(component, datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called without a legacy editor and with a data source with custom variable support', () => { |
||||||
|
it('then is should return true', () => { |
||||||
|
const component: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = { |
||||||
|
variables: { getType: () => VariableSupportType.Custom, query: () => undefined, editor: {} }, |
||||||
|
}; |
||||||
|
|
||||||
|
expect(isQueryEditor(component, datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called without a legacy editor and with a data source with datasource variable support', () => { |
||||||
|
it('then is should return true', () => { |
||||||
|
const component: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = { variables: { getType: () => VariableSupportType.Datasource } }; |
||||||
|
|
||||||
|
expect(isQueryEditor(component, datasource)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('negative cases', () => { |
||||||
|
describe('when called without component', () => { |
||||||
|
it('then is should return false', () => { |
||||||
|
const component: any = null; |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
|
||||||
|
expect(isQueryEditor(component, datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a legacy query editor', () => { |
||||||
|
it('then is should return false', () => { |
||||||
|
const component: any = LegacyVariableQueryEditor; |
||||||
|
const datasource: any = { variables: { getType: () => VariableSupportType.Datasource } }; |
||||||
|
|
||||||
|
expect(isQueryEditor(component, datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called without a legacy query editor but with a legacy data source', () => { |
||||||
|
it('then is should return false', () => { |
||||||
|
const component: any = StandardVariableQueryEditor; |
||||||
|
const datasource: any = { metricFindQuery: () => undefined }; |
||||||
|
|
||||||
|
expect(isQueryEditor(component, datasource)).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,396 @@ |
|||||||
|
import { of, throwError } from 'rxjs'; |
||||||
|
import { DefaultTimeRange, LoadingState, VariableSupportType } from '@grafana/data'; |
||||||
|
import { delay } from 'rxjs/operators'; |
||||||
|
|
||||||
|
import { UpdateOptionsResults, VariableQueryRunner } from './VariableQueryRunner'; |
||||||
|
import { queryBuilder } from '../shared/testing/builders'; |
||||||
|
import { QueryRunner, QueryRunners } from './queryRunners'; |
||||||
|
import { toVariableIdentifier, VariableIdentifier } from '../state/types'; |
||||||
|
import { QueryVariableModel } from '../types'; |
||||||
|
import { updateVariableOptions, updateVariableTags } from './reducer'; |
||||||
|
|
||||||
|
type DoneCallback = { |
||||||
|
(...args: any[]): any; |
||||||
|
fail(error?: string | { message: string }): any; |
||||||
|
}; |
||||||
|
|
||||||
|
function expectOnResults(args: { |
||||||
|
runner: VariableQueryRunner; |
||||||
|
identifier: VariableIdentifier; |
||||||
|
done: DoneCallback; |
||||||
|
expect: (results: UpdateOptionsResults[]) => void; |
||||||
|
}) { |
||||||
|
const { runner, identifier, done, expect: expectCallback } = args; |
||||||
|
const results: UpdateOptionsResults[] = []; |
||||||
|
const subscription = runner.getResponse(identifier).subscribe({ |
||||||
|
next: value => { |
||||||
|
results.push(value); |
||||||
|
if (value.state === LoadingState.Done || value.state === LoadingState.Error) { |
||||||
|
try { |
||||||
|
expectCallback(results); |
||||||
|
subscription.unsubscribe(); |
||||||
|
done(); |
||||||
|
} catch (err) { |
||||||
|
subscription.unsubscribe(); |
||||||
|
done.fail(err); |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function getTestContext(variable?: QueryVariableModel) { |
||||||
|
variable = |
||||||
|
variable ?? |
||||||
|
queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.build(); |
||||||
|
const getTimeSrv = jest.fn().mockReturnValue({ |
||||||
|
timeRange: jest.fn().mockReturnValue(DefaultTimeRange), |
||||||
|
}); |
||||||
|
const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([]) }; |
||||||
|
const identifier = toVariableIdentifier(variable); |
||||||
|
const searchFilter = undefined; |
||||||
|
const getTemplatedRegex = jest.fn().mockReturnValue('getTemplatedRegex result'); |
||||||
|
const dispatch = jest.fn().mockResolvedValue({}); |
||||||
|
const getState = jest.fn().mockReturnValue({ |
||||||
|
templating: { |
||||||
|
transaction: { |
||||||
|
uid: '0123456789', |
||||||
|
}, |
||||||
|
}, |
||||||
|
variables: { |
||||||
|
[variable.id]: variable, |
||||||
|
}, |
||||||
|
}); |
||||||
|
const queryRunner: QueryRunner = { |
||||||
|
type: VariableSupportType.Standard, |
||||||
|
canRun: jest.fn().mockReturnValue(true), |
||||||
|
getTarget: jest.fn().mockReturnValue({ refId: 'A', query: 'A query' }), |
||||||
|
runRequest: jest.fn().mockReturnValue(of({ series: [], state: LoadingState.Done })), |
||||||
|
}; |
||||||
|
const queryRunners = ({ |
||||||
|
getRunnerForDatasource: jest.fn().mockReturnValue(queryRunner), |
||||||
|
} as unknown) as QueryRunners; |
||||||
|
const getVariable = jest.fn().mockReturnValue(variable); |
||||||
|
const runRequest = jest.fn().mockReturnValue(of({})); |
||||||
|
const runner = new VariableQueryRunner({ |
||||||
|
getTimeSrv, |
||||||
|
getTemplatedRegex, |
||||||
|
dispatch, |
||||||
|
getState, |
||||||
|
getVariable, |
||||||
|
queryRunners, |
||||||
|
runRequest, |
||||||
|
}); |
||||||
|
|
||||||
|
return { |
||||||
|
identifier, |
||||||
|
datasource, |
||||||
|
runner, |
||||||
|
searchFilter, |
||||||
|
getTemplatedRegex, |
||||||
|
dispatch, |
||||||
|
getState, |
||||||
|
queryRunner, |
||||||
|
queryRunners, |
||||||
|
getVariable, |
||||||
|
runRequest, |
||||||
|
variable, |
||||||
|
getTimeSrv, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
describe('VariableQueryRunner', () => { |
||||||
|
describe('happy case', () => { |
||||||
|
it('then it should work as expected', done => { |
||||||
|
const { |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
datasource, |
||||||
|
getState, |
||||||
|
getVariable, |
||||||
|
queryRunners, |
||||||
|
queryRunner, |
||||||
|
dispatch, |
||||||
|
} = getTestContext(); |
||||||
|
|
||||||
|
expectOnResults({ |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
expect: results => { |
||||||
|
// verify that the observable works as expected
|
||||||
|
expect(results).toEqual([ |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Done, identifier }, |
||||||
|
]); |
||||||
|
|
||||||
|
// verify that mocks have been called as expected
|
||||||
|
expect(getState).toHaveBeenCalledTimes(3); |
||||||
|
expect(getVariable).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.getTarget).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).not.toHaveBeenCalled(); |
||||||
|
|
||||||
|
// updateVariableOptions and validateVariableSelectionState
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(2); |
||||||
|
expect(dispatch.mock.calls[0][0]).toEqual( |
||||||
|
updateVariableOptions({ |
||||||
|
id: 'query', |
||||||
|
type: 'query', |
||||||
|
data: { results: [], templatedRegex: 'getTemplatedRegex result' }, |
||||||
|
}) |
||||||
|
); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
|
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('tags case', () => { |
||||||
|
it('then it should work as expected', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.withTags(true) |
||||||
|
.withTagsQuery('A tags query') |
||||||
|
.build(); |
||||||
|
const { |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
datasource, |
||||||
|
getState, |
||||||
|
getVariable, |
||||||
|
queryRunners, |
||||||
|
queryRunner, |
||||||
|
dispatch, |
||||||
|
} = getTestContext(variable); |
||||||
|
|
||||||
|
expectOnResults({ |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
expect: results => { |
||||||
|
// verify that the observable works as expected
|
||||||
|
expect(results).toEqual([ |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Done, identifier }, |
||||||
|
]); |
||||||
|
|
||||||
|
// verify that mocks have been called as expected
|
||||||
|
expect(getState).toHaveBeenCalledTimes(3); |
||||||
|
expect(getVariable).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.getTarget).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1); |
||||||
|
|
||||||
|
// updateVariableOptions, updateVariableTags and validateVariableSelectionState
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(3); |
||||||
|
expect(dispatch.mock.calls[0][0]).toEqual( |
||||||
|
updateVariableOptions({ |
||||||
|
id: 'query', |
||||||
|
type: 'query', |
||||||
|
data: { results: [], templatedRegex: 'getTemplatedRegex result' }, |
||||||
|
}) |
||||||
|
); |
||||||
|
expect(dispatch.mock.calls[1][0]).toEqual(updateVariableTags({ id: 'query', type: 'query', data: [] })); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
|
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('error cases', () => { |
||||||
|
describe('queryRunners.getRunnerForDatasource throws', () => { |
||||||
|
it('then it should work as expected', done => { |
||||||
|
const { |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
datasource, |
||||||
|
getState, |
||||||
|
getVariable, |
||||||
|
queryRunners, |
||||||
|
queryRunner, |
||||||
|
dispatch, |
||||||
|
} = getTestContext(); |
||||||
|
|
||||||
|
queryRunners.getRunnerForDatasource = jest.fn().mockImplementation(() => { |
||||||
|
throw new Error('getRunnerForDatasource error'); |
||||||
|
}); |
||||||
|
|
||||||
|
expectOnResults({ |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
expect: results => { |
||||||
|
// verify that the observable works as expected
|
||||||
|
expect(results).toEqual([ |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Error, identifier, error: new Error('getRunnerForDatasource error') }, |
||||||
|
]); |
||||||
|
|
||||||
|
// verify that mocks have been called as expected
|
||||||
|
expect(getState).toHaveBeenCalledTimes(2); |
||||||
|
expect(getVariable).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.getTarget).not.toHaveBeenCalled(); |
||||||
|
expect(queryRunner.runRequest).not.toHaveBeenCalled(); |
||||||
|
expect(datasource.metricFindQuery).not.toHaveBeenCalled(); |
||||||
|
expect(dispatch).not.toHaveBeenCalled(); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
|
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('runRequest throws', () => { |
||||||
|
it('then it should work as expected', done => { |
||||||
|
const { |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
datasource, |
||||||
|
getState, |
||||||
|
getVariable, |
||||||
|
queryRunners, |
||||||
|
queryRunner, |
||||||
|
dispatch, |
||||||
|
} = getTestContext(); |
||||||
|
|
||||||
|
queryRunner.runRequest = jest.fn().mockReturnValue(throwError(new Error('runRequest error'))); |
||||||
|
|
||||||
|
expectOnResults({ |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
expect: results => { |
||||||
|
// verify that the observable works as expected
|
||||||
|
expect(results).toEqual([ |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Error, identifier, error: new Error('runRequest error') }, |
||||||
|
]); |
||||||
|
|
||||||
|
// verify that mocks have been called as expected
|
||||||
|
expect(getState).toHaveBeenCalledTimes(2); |
||||||
|
expect(getVariable).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.getTarget).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).not.toHaveBeenCalled(); |
||||||
|
expect(dispatch).not.toHaveBeenCalled(); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
|
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('metricFindQuery throws', () => { |
||||||
|
it('then it should work as expected', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.withTags(true) |
||||||
|
.withTagsQuery('A tags query') |
||||||
|
.build(); |
||||||
|
const { |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
datasource, |
||||||
|
getState, |
||||||
|
getVariable, |
||||||
|
queryRunners, |
||||||
|
queryRunner, |
||||||
|
dispatch, |
||||||
|
} = getTestContext(variable); |
||||||
|
|
||||||
|
datasource.metricFindQuery = jest.fn().mockRejectedValue(new Error('metricFindQuery error')); |
||||||
|
|
||||||
|
expectOnResults({ |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
expect: results => { |
||||||
|
// verify that the observable works as expected
|
||||||
|
expect(results).toEqual([ |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Error, identifier, error: new Error('metricFindQuery error') }, |
||||||
|
]); |
||||||
|
|
||||||
|
// verify that mocks have been called as expected
|
||||||
|
expect(getState).toHaveBeenCalledTimes(3); |
||||||
|
expect(getVariable).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.getTarget).toHaveBeenCalledTimes(1); |
||||||
|
expect(queryRunner.runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1); |
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
|
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('cancellation cases', () => { |
||||||
|
describe('long running request is cancelled', () => { |
||||||
|
it('then it should work as expected', done => { |
||||||
|
const { identifier, datasource, runner, queryRunner } = getTestContext(); |
||||||
|
|
||||||
|
queryRunner.runRequest = jest |
||||||
|
.fn() |
||||||
|
.mockReturnValue(of({ series: [], state: LoadingState.Done }).pipe(delay(10000))); |
||||||
|
|
||||||
|
expectOnResults({ |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
expect: results => { |
||||||
|
// verify that the observable works as expected
|
||||||
|
expect(results).toEqual([ |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Loading, identifier, cancelled: true }, |
||||||
|
{ state: LoadingState.Done, identifier }, |
||||||
|
]); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
|
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
runner.cancelRequest(identifier); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('an identical request is triggered before first request is finished', () => { |
||||||
|
it('then it should work as expected', done => { |
||||||
|
const { identifier, datasource, runner, queryRunner } = getTestContext(); |
||||||
|
|
||||||
|
queryRunner.runRequest = jest |
||||||
|
.fn() |
||||||
|
.mockReturnValueOnce(of({ series: [], state: LoadingState.Done }).pipe(delay(10000))) |
||||||
|
.mockReturnValue(of({ series: [], state: LoadingState.Done })); |
||||||
|
|
||||||
|
expectOnResults({ |
||||||
|
identifier, |
||||||
|
runner, |
||||||
|
expect: results => { |
||||||
|
// verify that the observable works as expected
|
||||||
|
expect(results).toEqual([ |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Loading, identifier }, |
||||||
|
{ state: LoadingState.Loading, identifier, cancelled: true }, |
||||||
|
{ state: LoadingState.Done, identifier }, |
||||||
|
]); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
|
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
runner.queueRequest({ identifier, datasource }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,206 @@ |
|||||||
|
import { merge, Observable, of, Subject, throwError, Unsubscribable } from 'rxjs'; |
||||||
|
import { catchError, filter, finalize, first, mergeMap, takeUntil } from 'rxjs/operators'; |
||||||
|
import { |
||||||
|
CoreApp, |
||||||
|
DataQuery, |
||||||
|
DataQueryRequest, |
||||||
|
DataSourceApi, |
||||||
|
DefaultTimeRange, |
||||||
|
LoadingState, |
||||||
|
ScopedVars, |
||||||
|
} from '@grafana/data'; |
||||||
|
|
||||||
|
import { VariableIdentifier } from '../state/types'; |
||||||
|
import { getVariable } from '../state/selectors'; |
||||||
|
import { QueryVariableModel, VariableRefresh } from '../types'; |
||||||
|
import { StoreState, ThunkDispatch } from '../../../types'; |
||||||
|
import { dispatch, getState } from '../../../store/store'; |
||||||
|
import { getTemplatedRegex } from '../utils'; |
||||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||||
|
import { getTimeSrv } from '../../dashboard/services/TimeSrv'; |
||||||
|
import { QueryRunners } from './queryRunners'; |
||||||
|
import { runRequest } from '../../dashboard/state/runRequest'; |
||||||
|
import { |
||||||
|
runUpdateTagsRequest, |
||||||
|
toMetricFindValues, |
||||||
|
updateOptionsState, |
||||||
|
updateTagsState, |
||||||
|
validateVariableSelection, |
||||||
|
} from './operators'; |
||||||
|
|
||||||
|
interface UpdateOptionsArgs { |
||||||
|
identifier: VariableIdentifier; |
||||||
|
datasource: DataSourceApi; |
||||||
|
searchFilter?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface UpdateOptionsResults { |
||||||
|
state: LoadingState; |
||||||
|
identifier: VariableIdentifier; |
||||||
|
error?: any; |
||||||
|
cancelled?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
interface VariableQueryRunnerArgs { |
||||||
|
dispatch: ThunkDispatch; |
||||||
|
getState: () => StoreState; |
||||||
|
getVariable: typeof getVariable; |
||||||
|
getTemplatedRegex: typeof getTemplatedRegex; |
||||||
|
getTimeSrv: typeof getTimeSrv; |
||||||
|
queryRunners: QueryRunners; |
||||||
|
runRequest: typeof runRequest; |
||||||
|
} |
||||||
|
|
||||||
|
export class VariableQueryRunner { |
||||||
|
private readonly updateOptionsRequests: Subject<UpdateOptionsArgs>; |
||||||
|
private readonly updateOptionsResults: Subject<UpdateOptionsResults>; |
||||||
|
private readonly cancelRequests: Subject<{ identifier: VariableIdentifier }>; |
||||||
|
private readonly subscription: Unsubscribable; |
||||||
|
|
||||||
|
constructor( |
||||||
|
private dependencies: VariableQueryRunnerArgs = { |
||||||
|
dispatch, |
||||||
|
getState, |
||||||
|
getVariable, |
||||||
|
getTemplatedRegex, |
||||||
|
getTimeSrv, |
||||||
|
queryRunners: new QueryRunners(), |
||||||
|
runRequest, |
||||||
|
} |
||||||
|
) { |
||||||
|
this.updateOptionsRequests = new Subject<UpdateOptionsArgs>(); |
||||||
|
this.updateOptionsResults = new Subject<UpdateOptionsResults>(); |
||||||
|
this.cancelRequests = new Subject<{ identifier: VariableIdentifier }>(); |
||||||
|
this.onNewRequest = this.onNewRequest.bind(this); |
||||||
|
this.subscription = this.updateOptionsRequests.subscribe(this.onNewRequest); |
||||||
|
} |
||||||
|
|
||||||
|
queueRequest(args: UpdateOptionsArgs): void { |
||||||
|
this.updateOptionsRequests.next(args); |
||||||
|
} |
||||||
|
|
||||||
|
getResponse(identifier: VariableIdentifier): Observable<UpdateOptionsResults> { |
||||||
|
return this.updateOptionsResults.asObservable().pipe(filter(result => result.identifier === identifier)); |
||||||
|
} |
||||||
|
|
||||||
|
cancelRequest(identifier: VariableIdentifier): void { |
||||||
|
this.cancelRequests.next({ identifier }); |
||||||
|
} |
||||||
|
|
||||||
|
destroy(): void { |
||||||
|
this.subscription.unsubscribe(); |
||||||
|
} |
||||||
|
|
||||||
|
private onNewRequest(args: UpdateOptionsArgs): void { |
||||||
|
const { datasource, identifier, searchFilter } = args; |
||||||
|
try { |
||||||
|
const { |
||||||
|
dispatch, |
||||||
|
runRequest, |
||||||
|
getTemplatedRegex: getTemplatedRegexFunc, |
||||||
|
getVariable, |
||||||
|
queryRunners, |
||||||
|
getTimeSrv, |
||||||
|
getState, |
||||||
|
} = this.dependencies; |
||||||
|
|
||||||
|
const beforeUid = getState().templating.transaction.uid; |
||||||
|
|
||||||
|
this.updateOptionsResults.next({ identifier, state: LoadingState.Loading }); |
||||||
|
|
||||||
|
const variable = getVariable<QueryVariableModel>(identifier.id, getState()); |
||||||
|
const timeSrv = getTimeSrv(); |
||||||
|
const runnerArgs = { variable, datasource, searchFilter, timeSrv, runRequest }; |
||||||
|
const runner = queryRunners.getRunnerForDatasource(datasource); |
||||||
|
const target = runner.getTarget({ datasource, variable }); |
||||||
|
const request = this.getRequest(variable, args, target); |
||||||
|
|
||||||
|
runner |
||||||
|
.runRequest(runnerArgs, request) |
||||||
|
.pipe( |
||||||
|
filter(() => { |
||||||
|
// lets check if we started another batch during the execution of the observable. If so we just want to abort the rest.
|
||||||
|
const afterUid = getState().templating.transaction.uid; |
||||||
|
return beforeUid === afterUid; |
||||||
|
}), |
||||||
|
first(data => data.state === LoadingState.Done || data.state === LoadingState.Error), |
||||||
|
mergeMap(data => { |
||||||
|
if (data.state === LoadingState.Error) { |
||||||
|
return throwError(data.error); |
||||||
|
} |
||||||
|
|
||||||
|
return of(data); |
||||||
|
}), |
||||||
|
toMetricFindValues(), |
||||||
|
updateOptionsState({ variable, dispatch, getTemplatedRegexFunc }), |
||||||
|
runUpdateTagsRequest({ variable, datasource, searchFilter }), |
||||||
|
updateTagsState({ variable, dispatch }), |
||||||
|
validateVariableSelection({ variable, dispatch, searchFilter }), |
||||||
|
takeUntil( |
||||||
|
merge(this.updateOptionsRequests, this.cancelRequests).pipe( |
||||||
|
filter(args => { |
||||||
|
let cancelRequest = false; |
||||||
|
|
||||||
|
if (args.identifier.id === identifier.id) { |
||||||
|
cancelRequest = true; |
||||||
|
this.updateOptionsResults.next({ identifier, state: LoadingState.Loading, cancelled: cancelRequest }); |
||||||
|
} |
||||||
|
|
||||||
|
return cancelRequest; |
||||||
|
}) |
||||||
|
) |
||||||
|
), |
||||||
|
catchError(error => { |
||||||
|
if (error.cancelled) { |
||||||
|
return of({}); |
||||||
|
} |
||||||
|
|
||||||
|
this.updateOptionsResults.next({ identifier, state: LoadingState.Error, error }); |
||||||
|
return throwError(error); |
||||||
|
}), |
||||||
|
finalize(() => { |
||||||
|
this.updateOptionsResults.next({ identifier, state: LoadingState.Done }); |
||||||
|
}) |
||||||
|
) |
||||||
|
.subscribe(); |
||||||
|
} catch (error) { |
||||||
|
this.updateOptionsResults.next({ identifier, state: LoadingState.Error, error }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private getRequest(variable: QueryVariableModel, args: UpdateOptionsArgs, target: DataQuery) { |
||||||
|
const { searchFilter } = args; |
||||||
|
const variableAsVars = { variable: { text: variable.current.text, value: variable.current.value } }; |
||||||
|
const searchFilterScope = { searchFilter: { text: searchFilter, value: searchFilter } }; |
||||||
|
const searchFilterAsVars = searchFilter ? searchFilterScope : {}; |
||||||
|
const scopedVars = { ...searchFilterAsVars, ...variableAsVars } as ScopedVars; |
||||||
|
const range = |
||||||
|
variable.refresh === VariableRefresh.onTimeRangeChanged |
||||||
|
? this.dependencies.getTimeSrv().timeRange() |
||||||
|
: DefaultTimeRange; |
||||||
|
|
||||||
|
const request: DataQueryRequest = { |
||||||
|
app: CoreApp.Dashboard, |
||||||
|
requestId: uuidv4(), |
||||||
|
timezone: '', |
||||||
|
range, |
||||||
|
interval: '', |
||||||
|
intervalMs: 0, |
||||||
|
targets: [target], |
||||||
|
scopedVars, |
||||||
|
startTime: Date.now(), |
||||||
|
}; |
||||||
|
|
||||||
|
return request; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let singleton: VariableQueryRunner; |
||||||
|
|
||||||
|
export function setVariableQueryRunner(runner: VariableQueryRunner): void { |
||||||
|
singleton = runner; |
||||||
|
} |
||||||
|
|
||||||
|
export function getVariableQueryRunner(): VariableQueryRunner { |
||||||
|
return singleton; |
||||||
|
} |
||||||
@ -0,0 +1,332 @@ |
|||||||
|
import { of } from 'rxjs'; |
||||||
|
import { queryBuilder } from '../shared/testing/builders'; |
||||||
|
import { FieldType, observableTester, toDataFrame } from '@grafana/data'; |
||||||
|
import { initialQueryVariableModelState, updateVariableOptions, updateVariableTags } from './reducer'; |
||||||
|
import { toVariablePayload } from '../state/types'; |
||||||
|
import { VariableRefresh } from '../types'; |
||||||
|
import { |
||||||
|
areMetricFindValues, |
||||||
|
runUpdateTagsRequest, |
||||||
|
toMetricFindValues, |
||||||
|
updateOptionsState, |
||||||
|
updateTagsState, |
||||||
|
validateVariableSelection, |
||||||
|
} from './operators'; |
||||||
|
|
||||||
|
describe('operators', () => { |
||||||
|
describe('validateVariableSelection', () => { |
||||||
|
describe('when called', () => { |
||||||
|
it('then the correct observable should be created', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.build(); |
||||||
|
const dispatch = jest.fn().mockResolvedValue({}); |
||||||
|
const observable = of(undefined).pipe( |
||||||
|
validateVariableSelection({ variable, dispatch, searchFilter: 'A search filter' }) |
||||||
|
); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual({}); |
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('updateTagsState', () => { |
||||||
|
describe('when called with a variable that uses Tags', () => { |
||||||
|
it('then the correct observable should be created', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.withTags(true) |
||||||
|
.build(); |
||||||
|
const dispatch = jest.fn().mockResolvedValue({}); |
||||||
|
const observable = of([{ text: 'A text' }]).pipe(updateTagsState({ variable, dispatch })); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual(undefined); |
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1); |
||||||
|
expect(dispatch).toHaveBeenCalledWith( |
||||||
|
updateVariableTags(toVariablePayload(variable, [{ text: 'A text' }])) |
||||||
|
); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a variable that does not use Tags', () => { |
||||||
|
it('then the correct observable should be created', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.withTags(false) |
||||||
|
.build(); |
||||||
|
const dispatch = jest.fn().mockResolvedValue({}); |
||||||
|
const observable = of([{ text: 'A text' }]).pipe(updateTagsState({ variable, dispatch })); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual(undefined); |
||||||
|
expect(dispatch).not.toHaveBeenCalled(); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('runUpdateTagsRequest', () => { |
||||||
|
describe('when called with a datasource with metricFindQuery and variable that uses Tags and refreshes on time range changes', () => { |
||||||
|
it('then the correct observable should be created', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.withTags(true) |
||||||
|
.withTagsQuery('A tags query') |
||||||
|
.withRefresh(VariableRefresh.onTimeRangeChanged) |
||||||
|
.build(); |
||||||
|
const timeSrv: any = { |
||||||
|
timeRange: jest.fn(), |
||||||
|
}; |
||||||
|
const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([{ text: 'A text' }]) }; |
||||||
|
const searchFilter = 'A search filter'; |
||||||
|
const observable = of(undefined).pipe(runUpdateTagsRequest({ variable, datasource, searchFilter }, timeSrv)); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
const { index, global, ...rest } = initialQueryVariableModelState; |
||||||
|
expect(value).toEqual([{ text: 'A text' }]); |
||||||
|
expect(timeSrv.timeRange).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledWith('A tags query', { |
||||||
|
range: undefined, |
||||||
|
searchFilter: 'A search filter', |
||||||
|
variable: { |
||||||
|
...rest, |
||||||
|
id: 'query', |
||||||
|
name: 'query', |
||||||
|
useTags: true, |
||||||
|
tagsQuery: 'A tags query', |
||||||
|
refresh: VariableRefresh.onTimeRangeChanged, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a datasource without metricFindQuery and variable that uses Tags and refreshes on time range changes', () => { |
||||||
|
it('then the correct observable should be created', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.withTags(true) |
||||||
|
.withTagsQuery('A tags query') |
||||||
|
.withRefresh(VariableRefresh.onTimeRangeChanged) |
||||||
|
.build(); |
||||||
|
const timeSrv: any = { |
||||||
|
timeRange: jest.fn(), |
||||||
|
}; |
||||||
|
const datasource: any = {}; |
||||||
|
const searchFilter = 'A search filter'; |
||||||
|
const observable = of(undefined).pipe(runUpdateTagsRequest({ variable, datasource, searchFilter }, timeSrv)); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual([]); |
||||||
|
expect(timeSrv.timeRange).not.toHaveBeenCalled(); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called with a datasource with metricFindQuery and variable that does not use Tags but refreshes on time range changes', () => { |
||||||
|
it('then the correct observable should be created', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.withTags(false) |
||||||
|
.withRefresh(VariableRefresh.onTimeRangeChanged) |
||||||
|
.build(); |
||||||
|
const timeSrv: any = { |
||||||
|
timeRange: jest.fn(), |
||||||
|
}; |
||||||
|
const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([{ text: 'A text' }]) }; |
||||||
|
const searchFilter = 'A search filter'; |
||||||
|
const observable = of(undefined).pipe(runUpdateTagsRequest({ variable, datasource, searchFilter }, timeSrv)); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual([]); |
||||||
|
expect(timeSrv.timeRange).not.toHaveBeenCalled(); |
||||||
|
expect(datasource.metricFindQuery).not.toHaveBeenCalled(); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('updateOptionsState', () => { |
||||||
|
describe('when called', () => { |
||||||
|
it('then the correct observable should be created', done => { |
||||||
|
const variable = queryBuilder() |
||||||
|
.withId('query') |
||||||
|
.build(); |
||||||
|
const dispatch = jest.fn(); |
||||||
|
const getTemplatedRegexFunc = jest.fn().mockReturnValue('getTemplatedRegexFunc result'); |
||||||
|
|
||||||
|
const observable = of([{ text: 'A' }]).pipe(updateOptionsState({ variable, dispatch, getTemplatedRegexFunc })); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual(undefined); |
||||||
|
expect(getTemplatedRegexFunc).toHaveBeenCalledTimes(1); |
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1); |
||||||
|
expect(dispatch).toHaveBeenCalledWith( |
||||||
|
updateVariableOptions({ |
||||||
|
id: 'query', |
||||||
|
type: 'query', |
||||||
|
data: { results: [{ text: 'A' }], templatedRegex: 'getTemplatedRegexFunc result' }, |
||||||
|
}) |
||||||
|
); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('toMetricFindValues', () => { |
||||||
|
const frameWithTextField = toDataFrame({ |
||||||
|
fields: [{ name: 'text', type: FieldType.string, values: ['A', 'B', 'C'] }], |
||||||
|
}); |
||||||
|
const frameWithValueField = toDataFrame({ |
||||||
|
fields: [{ name: 'value', type: FieldType.string, values: ['A', 'B', 'C'] }], |
||||||
|
}); |
||||||
|
const frameWithTextAndValueField = toDataFrame({ |
||||||
|
fields: [ |
||||||
|
{ name: 'text', type: FieldType.string, values: ['TA', 'TB', 'TC'] }, |
||||||
|
{ name: 'value', type: FieldType.string, values: ['VA', 'VB', 'VC'] }, |
||||||
|
], |
||||||
|
}); |
||||||
|
const frameWithAStringField = toDataFrame({ |
||||||
|
fields: [{ name: 'label', type: FieldType.string, values: ['A', 'B', 'C'] }], |
||||||
|
}); |
||||||
|
const frameWithExpandableField = toDataFrame({ |
||||||
|
fields: [ |
||||||
|
{ name: 'label', type: FieldType.string, values: ['A', 'B', 'C'] }, |
||||||
|
{ name: 'expandable', type: FieldType.boolean, values: [true, false, true] }, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
// it.each wouldn't work here as we need the done callback
|
||||||
|
[ |
||||||
|
{ series: null, expected: [] }, |
||||||
|
{ series: undefined, expected: [] }, |
||||||
|
{ series: [], expected: [] }, |
||||||
|
{ series: [{ text: '' }], expected: [{ text: '' }] }, |
||||||
|
{ series: [{ value: '' }], expected: [{ value: '' }] }, |
||||||
|
{ |
||||||
|
series: [frameWithTextField], |
||||||
|
expected: [ |
||||||
|
{ text: 'A', value: 'A' }, |
||||||
|
{ text: 'B', value: 'B' }, |
||||||
|
{ text: 'C', value: 'C' }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
series: [frameWithValueField], |
||||||
|
expected: [ |
||||||
|
{ text: 'A', value: 'A' }, |
||||||
|
{ text: 'B', value: 'B' }, |
||||||
|
{ text: 'C', value: 'C' }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
series: [frameWithTextAndValueField], |
||||||
|
expected: [ |
||||||
|
{ text: 'TA', value: 'VA' }, |
||||||
|
{ text: 'TB', value: 'VB' }, |
||||||
|
{ text: 'TC', value: 'VC' }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
series: [frameWithAStringField], |
||||||
|
expected: [ |
||||||
|
{ text: 'A', value: 'A' }, |
||||||
|
{ text: 'B', value: 'B' }, |
||||||
|
{ text: 'C', value: 'C' }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
series: [frameWithExpandableField], |
||||||
|
expected: [ |
||||||
|
{ text: 'A', value: 'A', expandable: true }, |
||||||
|
{ text: 'B', value: 'B', expandable: false }, |
||||||
|
{ text: 'C', value: 'C', expandable: true }, |
||||||
|
], |
||||||
|
}, |
||||||
|
].map(scenario => { |
||||||
|
it(`when called with series:${JSON.stringify(scenario.series, null, 0)}`, done => { |
||||||
|
const { series, expected } = scenario; |
||||||
|
const panelData: any = { series }; |
||||||
|
const observable = of(panelData).pipe(toMetricFindValues()); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual(expected); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when called without metric find values and string fields', () => { |
||||||
|
it('then the observable throws', done => { |
||||||
|
const frameWithTimeField = toDataFrame({ |
||||||
|
fields: [{ name: 'time', type: FieldType.time, values: [1, 2, 3] }], |
||||||
|
}); |
||||||
|
|
||||||
|
const panelData: any = { series: [frameWithTimeField] }; |
||||||
|
const observable = of(panelData).pipe(toMetricFindValues()); |
||||||
|
|
||||||
|
observableTester().subscribeAndExpectOnError({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual(new Error("Couldn't find any field of type string in the results.")); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('areMetricFindValues', () => { |
||||||
|
it.each` |
||||||
|
values | expected |
||||||
|
${null} | ${false} |
||||||
|
${undefined} | ${false} |
||||||
|
${[]} | ${true} |
||||||
|
${[{ text: '' }]} | ${true} |
||||||
|
${[{ Text: '' }]} | ${true} |
||||||
|
${[{ value: '' }]} | ${true} |
||||||
|
${[{ Value: '' }]} | ${true} |
||||||
|
${[{ text: '', value: '' }]} | ${true} |
||||||
|
${[{ Text: '', Value: '' }]} | ${true} |
||||||
|
`('when called with values:$values', ({ values, expected }) => {
|
||||||
|
expect(areMetricFindValues(values)).toBe(expected); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,187 @@ |
|||||||
|
import { from, of, OperatorFunction } from 'rxjs'; |
||||||
|
import { map, mergeMap } from 'rxjs/operators'; |
||||||
|
|
||||||
|
import { QueryVariableModel } from '../types'; |
||||||
|
import { ThunkDispatch } from '../../../types'; |
||||||
|
import { toVariableIdentifier, toVariablePayload } from '../state/types'; |
||||||
|
import { validateVariableSelectionState } from '../state/actions'; |
||||||
|
import { DataSourceApi, FieldType, getFieldDisplayName, MetricFindValue, PanelData } from '@grafana/data'; |
||||||
|
import { updateVariableOptions, updateVariableTags } from './reducer'; |
||||||
|
import { getTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; |
||||||
|
import { getLegacyQueryOptions, getTemplatedRegex } from '../utils'; |
||||||
|
|
||||||
|
const metricFindValueProps = ['text', 'Text', 'value', 'Value']; |
||||||
|
|
||||||
|
export function toMetricFindValues(): OperatorFunction<PanelData, MetricFindValue[]> { |
||||||
|
return source => |
||||||
|
source.pipe( |
||||||
|
map(panelData => { |
||||||
|
const frames = panelData.series; |
||||||
|
if (!frames || !frames.length) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
if (areMetricFindValues(frames)) { |
||||||
|
return frames; |
||||||
|
} |
||||||
|
|
||||||
|
const metrics: MetricFindValue[] = []; |
||||||
|
|
||||||
|
let valueIndex = -1; |
||||||
|
let textIndex = -1; |
||||||
|
let stringIndex = -1; |
||||||
|
let expandableIndex = -1; |
||||||
|
|
||||||
|
for (const frame of frames) { |
||||||
|
for (let index = 0; index < frame.fields.length; index++) { |
||||||
|
const field = frame.fields[index]; |
||||||
|
const fieldName = getFieldDisplayName(field, frame, frames).toLowerCase(); |
||||||
|
|
||||||
|
if (field.type === FieldType.string && stringIndex === -1) { |
||||||
|
stringIndex = index; |
||||||
|
} |
||||||
|
|
||||||
|
if (fieldName === 'text' && field.type === FieldType.string && textIndex === -1) { |
||||||
|
textIndex = index; |
||||||
|
} |
||||||
|
|
||||||
|
if (fieldName === 'value' && field.type === FieldType.string && valueIndex === -1) { |
||||||
|
valueIndex = index; |
||||||
|
} |
||||||
|
|
||||||
|
if ( |
||||||
|
fieldName === 'expandable' && |
||||||
|
(field.type === FieldType.boolean || field.type === FieldType.number) && |
||||||
|
expandableIndex === -1 |
||||||
|
) { |
||||||
|
expandableIndex = index; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (stringIndex === -1) { |
||||||
|
throw new Error("Couldn't find any field of type string in the results."); |
||||||
|
} |
||||||
|
|
||||||
|
for (const frame of frames) { |
||||||
|
for (let index = 0; index < frame.length; index++) { |
||||||
|
const expandable = expandableIndex !== -1 ? frame.fields[expandableIndex].values.get(index) : undefined; |
||||||
|
const string = frame.fields[stringIndex].values.get(index); |
||||||
|
const text = textIndex !== -1 ? frame.fields[textIndex].values.get(index) : null; |
||||||
|
const value = valueIndex !== -1 ? frame.fields[valueIndex].values.get(index) : null; |
||||||
|
|
||||||
|
if (valueIndex === -1 && textIndex === -1) { |
||||||
|
metrics.push({ text: string, value: string, expandable }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (valueIndex === -1 && textIndex !== -1) { |
||||||
|
metrics.push({ text, value: text, expandable }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (valueIndex !== -1 && textIndex === -1) { |
||||||
|
metrics.push({ text: value, value, expandable }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
metrics.push({ text, value, expandable }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return metrics; |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function updateOptionsState(args: { |
||||||
|
variable: QueryVariableModel; |
||||||
|
dispatch: ThunkDispatch; |
||||||
|
getTemplatedRegexFunc: typeof getTemplatedRegex; |
||||||
|
}): OperatorFunction<MetricFindValue[], void> { |
||||||
|
return source => |
||||||
|
source.pipe( |
||||||
|
map(results => { |
||||||
|
const { variable, dispatch, getTemplatedRegexFunc } = args; |
||||||
|
const templatedRegex = getTemplatedRegexFunc(variable); |
||||||
|
const payload = toVariablePayload(variable, { results, templatedRegex }); |
||||||
|
dispatch(updateVariableOptions(payload)); |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function runUpdateTagsRequest( |
||||||
|
args: { |
||||||
|
variable: QueryVariableModel; |
||||||
|
datasource: DataSourceApi; |
||||||
|
searchFilter?: string; |
||||||
|
}, |
||||||
|
timeSrv: TimeSrv = getTimeSrv() |
||||||
|
): OperatorFunction<void, MetricFindValue[]> { |
||||||
|
return source => |
||||||
|
source.pipe( |
||||||
|
mergeMap(() => { |
||||||
|
const { datasource, searchFilter, variable } = args; |
||||||
|
|
||||||
|
if (variable.useTags && datasource.metricFindQuery) { |
||||||
|
return from( |
||||||
|
datasource.metricFindQuery(variable.tagsQuery, getLegacyQueryOptions(variable, searchFilter, timeSrv)) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return of([]); |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function updateTagsState(args: { |
||||||
|
variable: QueryVariableModel; |
||||||
|
dispatch: ThunkDispatch; |
||||||
|
}): OperatorFunction<MetricFindValue[], void> { |
||||||
|
return source => |
||||||
|
source.pipe( |
||||||
|
map(tagResults => { |
||||||
|
const { dispatch, variable } = args; |
||||||
|
|
||||||
|
if (variable.useTags) { |
||||||
|
dispatch(updateVariableTags(toVariablePayload(variable, tagResults))); |
||||||
|
} |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function validateVariableSelection(args: { |
||||||
|
variable: QueryVariableModel; |
||||||
|
dispatch: ThunkDispatch; |
||||||
|
searchFilter?: string; |
||||||
|
}): OperatorFunction<void, void> { |
||||||
|
return source => |
||||||
|
source.pipe( |
||||||
|
mergeMap(() => { |
||||||
|
const { dispatch, variable, searchFilter } = args; |
||||||
|
|
||||||
|
// If we are searching options there is no need to validate selection state
|
||||||
|
// This condition was added to as validateVariableSelectionState will update the current value of the variable
|
||||||
|
// So after search and selection the current value is already update so no setValue, refresh & url update is performed
|
||||||
|
// The if statement below fixes https://github.com/grafana/grafana/issues/25671
|
||||||
|
if (!searchFilter) { |
||||||
|
return from(dispatch(validateVariableSelectionState(toVariableIdentifier(variable)))); |
||||||
|
} |
||||||
|
|
||||||
|
return of<void>(); |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function areMetricFindValues(data: any[]): data is MetricFindValue[] { |
||||||
|
if (!data) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (!data.length) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
const firstValue: any = data[0]; |
||||||
|
return metricFindValueProps.some(prop => firstValue.hasOwnProperty(prop) && typeof firstValue[prop] === 'string'); |
||||||
|
} |
||||||
@ -0,0 +1,316 @@ |
|||||||
|
import { QueryRunners } from './queryRunners'; |
||||||
|
import { DefaultTimeRange, observableTester, VariableSupportType } from '@grafana/data'; |
||||||
|
import { VariableRefresh } from '../types'; |
||||||
|
import { of } from 'rxjs'; |
||||||
|
|
||||||
|
describe('QueryRunners', () => { |
||||||
|
describe('when using a legacy data source', () => { |
||||||
|
const getLegacyTestContext = (variable?: any) => { |
||||||
|
variable = variable ?? { query: 'A query' }; |
||||||
|
const timeSrv = { |
||||||
|
timeRange: jest.fn().mockReturnValue(DefaultTimeRange), |
||||||
|
}; |
||||||
|
const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([{ text: 'A', value: 'A' }]) }; |
||||||
|
const runner = new QueryRunners().getRunnerForDatasource(datasource); |
||||||
|
const runRequest = jest.fn().mockReturnValue(of({})); |
||||||
|
const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; |
||||||
|
const request: any = {}; |
||||||
|
|
||||||
|
return { timeSrv, datasource, runner, variable, runnerArgs, request }; |
||||||
|
}; |
||||||
|
|
||||||
|
describe('and calling getRunnerForDatasource', () => { |
||||||
|
it('then it should return LegacyQueryRunner', () => { |
||||||
|
const { runner } = getLegacyTestContext(); |
||||||
|
expect(runner!.type).toEqual(VariableSupportType.Legacy); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling getTarget', () => { |
||||||
|
it('then it should return correct target', () => { |
||||||
|
const { runner, datasource, variable } = getLegacyTestContext(); |
||||||
|
const target = runner.getTarget({ datasource, variable }); |
||||||
|
expect(target).toEqual('A query'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling runRequest with a variable that refreshes when time range changes', () => { |
||||||
|
const { datasource, runner, runnerArgs, request, timeSrv } = getLegacyTestContext({ |
||||||
|
query: 'A query', |
||||||
|
refresh: VariableRefresh.onTimeRangeChanged, |
||||||
|
}); |
||||||
|
const observable = runner.runRequest(runnerArgs, request); |
||||||
|
|
||||||
|
it('then it should return correct observable', done => { |
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: values => { |
||||||
|
expect(values).toEqual({ |
||||||
|
series: [{ text: 'A', value: 'A' }], |
||||||
|
state: 'Done', |
||||||
|
timeRange: { from: {}, raw: { from: '6h', to: 'now' }, to: {} }, |
||||||
|
}); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('and it should call timeSrv.timeRange()', () => { |
||||||
|
expect(timeSrv.timeRange).toHaveBeenCalledTimes(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it('and it should call metricFindQuery with correct options', () => { |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledWith('A query', { |
||||||
|
range: { |
||||||
|
from: {}, |
||||||
|
raw: { |
||||||
|
from: '6h', |
||||||
|
to: 'now', |
||||||
|
}, |
||||||
|
to: {}, |
||||||
|
}, |
||||||
|
searchFilter: 'A searchFilter', |
||||||
|
variable: { |
||||||
|
query: 'A query', |
||||||
|
refresh: VariableRefresh.onTimeRangeChanged, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling runRequest with a variable that does not refresh when time range changes', () => { |
||||||
|
const { datasource, runner, runnerArgs, request, timeSrv } = getLegacyTestContext({ |
||||||
|
query: 'A query', |
||||||
|
refresh: VariableRefresh.never, |
||||||
|
}); |
||||||
|
const observable = runner.runRequest(runnerArgs, request); |
||||||
|
|
||||||
|
it('then it should return correct observable', done => { |
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: values => { |
||||||
|
expect(values).toEqual({ |
||||||
|
series: [{ text: 'A', value: 'A' }], |
||||||
|
state: 'Done', |
||||||
|
timeRange: { from: {}, raw: { from: '6h', to: 'now' }, to: {} }, |
||||||
|
}); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('and it should not call timeSrv.timeRange()', () => { |
||||||
|
expect(timeSrv.timeRange).not.toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('and it should call metricFindQuery with correct options', () => { |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1); |
||||||
|
expect(datasource.metricFindQuery).toHaveBeenCalledWith('A query', { |
||||||
|
range: undefined, |
||||||
|
searchFilter: 'A searchFilter', |
||||||
|
variable: { |
||||||
|
query: 'A query', |
||||||
|
refresh: VariableRefresh.never, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when using a data source with standard variable support', () => { |
||||||
|
const getStandardTestContext = (datasource?: any) => { |
||||||
|
const variable: any = { query: { refId: 'A', query: 'A query' } }; |
||||||
|
const timeSrv = {}; |
||||||
|
datasource = datasource ?? { |
||||||
|
variables: { |
||||||
|
getType: () => VariableSupportType.Standard, |
||||||
|
toDataQuery: (query: any) => ({ ...query, extra: 'extra' }), |
||||||
|
}, |
||||||
|
}; |
||||||
|
const runner = new QueryRunners().getRunnerForDatasource(datasource); |
||||||
|
const runRequest = jest.fn().mockReturnValue(of({})); |
||||||
|
const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; |
||||||
|
const request: any = {}; |
||||||
|
|
||||||
|
return { timeSrv, datasource, runner, variable, runnerArgs, request, runRequest }; |
||||||
|
}; |
||||||
|
|
||||||
|
describe('and calling getRunnerForDatasource', () => { |
||||||
|
it('then it should return StandardQueryRunner', () => { |
||||||
|
const { runner } = getStandardTestContext(); |
||||||
|
expect(runner!.type).toEqual(VariableSupportType.Standard); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling getTarget', () => { |
||||||
|
it('then it should return correct target', () => { |
||||||
|
const { runner, variable, datasource } = getStandardTestContext(); |
||||||
|
const target = runner.getTarget({ datasource, variable }); |
||||||
|
expect(target).toEqual({ refId: 'A', query: 'A query', extra: 'extra' }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling runRequest with a datasource that uses a custom query', () => { |
||||||
|
const { runner, request, runnerArgs, runRequest, datasource } = getStandardTestContext({ |
||||||
|
variables: { |
||||||
|
getType: () => VariableSupportType.Standard, |
||||||
|
toDataQuery: () => undefined, |
||||||
|
query: () => undefined, |
||||||
|
}, |
||||||
|
}); |
||||||
|
const observable = runner.runRequest(runnerArgs, request); |
||||||
|
|
||||||
|
it('then it should return correct observable', done => { |
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual({}); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call runRequest with correct args', () => { |
||||||
|
expect(runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(runRequest).toHaveBeenCalledWith(datasource, {}, datasource.variables.query); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling runRequest with a datasource that has no custom query', () => { |
||||||
|
const { runner, request, runnerArgs, runRequest, datasource } = getStandardTestContext({ |
||||||
|
variables: { getType: () => VariableSupportType.Standard, toDataQuery: () => undefined }, |
||||||
|
}); |
||||||
|
const observable = runner.runRequest(runnerArgs, request); |
||||||
|
|
||||||
|
it('then it should return correct observable', done => { |
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual({}); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call runRequest with correct args', () => { |
||||||
|
expect(runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(runRequest).toHaveBeenCalledWith(datasource, {}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when using a data source with custom variable support', () => { |
||||||
|
const getCustomTestContext = () => { |
||||||
|
const variable: any = { query: { refId: 'A', query: 'A query' } }; |
||||||
|
const timeSrv = {}; |
||||||
|
const datasource: any = { |
||||||
|
variables: { getType: () => VariableSupportType.Custom, query: () => undefined, editor: {} }, |
||||||
|
}; |
||||||
|
const runner = new QueryRunners().getRunnerForDatasource(datasource); |
||||||
|
const runRequest = jest.fn().mockReturnValue(of({})); |
||||||
|
const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; |
||||||
|
const request: any = {}; |
||||||
|
|
||||||
|
return { timeSrv, datasource, runner, variable, runnerArgs, request, runRequest }; |
||||||
|
}; |
||||||
|
|
||||||
|
describe('and calling getRunnerForDatasource', () => { |
||||||
|
it('then it should return CustomQueryRunner', () => { |
||||||
|
const { runner } = getCustomTestContext(); |
||||||
|
expect(runner!.type).toEqual(VariableSupportType.Custom); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling getTarget', () => { |
||||||
|
it('then it should return correct target', () => { |
||||||
|
const { runner, variable, datasource } = getCustomTestContext(); |
||||||
|
const target = runner.getTarget({ datasource, variable }); |
||||||
|
expect(target).toEqual({ refId: 'A', query: 'A query' }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling runRequest', () => { |
||||||
|
const { runner, request, runnerArgs, runRequest, datasource } = getCustomTestContext(); |
||||||
|
const observable = runner.runRequest(runnerArgs, request); |
||||||
|
|
||||||
|
it('then it should return correct observable', done => { |
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual({}); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call runRequest with correct args', () => { |
||||||
|
expect(runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(runRequest).toHaveBeenCalledWith(datasource, {}, datasource.variables.query); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when using a data source with datasource variable support', () => { |
||||||
|
const getDatasourceTestContext = () => { |
||||||
|
const variable: any = { query: { refId: 'A', query: 'A query' } }; |
||||||
|
const timeSrv = {}; |
||||||
|
const datasource: any = { |
||||||
|
variables: { getType: () => VariableSupportType.Datasource }, |
||||||
|
}; |
||||||
|
const runner = new QueryRunners().getRunnerForDatasource(datasource); |
||||||
|
const runRequest = jest.fn().mockReturnValue(of({})); |
||||||
|
const runnerArgs: any = { datasource, variable, searchFilter: 'A searchFilter', timeSrv, runRequest }; |
||||||
|
const request: any = {}; |
||||||
|
|
||||||
|
return { timeSrv, datasource, runner, variable, runnerArgs, request, runRequest }; |
||||||
|
}; |
||||||
|
|
||||||
|
describe('and calling getRunnerForDatasource', () => { |
||||||
|
it('then it should return DatasourceQueryRunner', () => { |
||||||
|
const { runner } = getDatasourceTestContext(); |
||||||
|
expect(runner!.type).toEqual(VariableSupportType.Datasource); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling getTarget', () => { |
||||||
|
it('then it should return correct target', () => { |
||||||
|
const { runner, datasource, variable } = getDatasourceTestContext(); |
||||||
|
const target = runner.getTarget({ datasource, variable }); |
||||||
|
expect(target).toEqual({ refId: 'A', query: 'A query' }); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('and calling runRequest', () => { |
||||||
|
const { runner, request, runnerArgs, runRequest, datasource } = getDatasourceTestContext(); |
||||||
|
const observable = runner.runRequest(runnerArgs, request); |
||||||
|
|
||||||
|
it('then it should return correct observable', done => { |
||||||
|
observableTester().subscribeAndExpectOnNext({ |
||||||
|
observable, |
||||||
|
expect: value => { |
||||||
|
expect(value).toEqual({}); |
||||||
|
}, |
||||||
|
done, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call runRequest with correct args', () => { |
||||||
|
expect(runRequest).toHaveBeenCalledTimes(1); |
||||||
|
expect(runRequest).toHaveBeenCalledWith(datasource, {}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when using a data source with unknown variable support', () => { |
||||||
|
describe('and calling getRunnerForDatasource', () => { |
||||||
|
it('then it should throw', () => { |
||||||
|
const datasource: any = { |
||||||
|
variables: {}, |
||||||
|
}; |
||||||
|
|
||||||
|
expect(() => new QueryRunners().getRunnerForDatasource(datasource)).toThrow(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,178 @@ |
|||||||
|
import { from, Observable, of } from 'rxjs'; |
||||||
|
import { mergeMap } from 'rxjs/operators'; |
||||||
|
import { |
||||||
|
DataQuery, |
||||||
|
DataQueryRequest, |
||||||
|
DataSourceApi, |
||||||
|
DefaultTimeRange, |
||||||
|
LoadingState, |
||||||
|
PanelData, |
||||||
|
VariableSupportType, |
||||||
|
} from '@grafana/data'; |
||||||
|
|
||||||
|
import { QueryVariableModel } from '../types'; |
||||||
|
import { |
||||||
|
hasCustomVariableSupport, |
||||||
|
hasDatasourceVariableSupport, |
||||||
|
hasLegacyVariableSupport, |
||||||
|
hasStandardVariableSupport, |
||||||
|
} from '../guard'; |
||||||
|
import { getLegacyQueryOptions } from '../utils'; |
||||||
|
import { TimeSrv } from '../../dashboard/services/TimeSrv'; |
||||||
|
|
||||||
|
export interface RunnerArgs { |
||||||
|
variable: QueryVariableModel; |
||||||
|
datasource: DataSourceApi; |
||||||
|
timeSrv: TimeSrv; |
||||||
|
runRequest: ( |
||||||
|
datasource: DataSourceApi, |
||||||
|
request: DataQueryRequest, |
||||||
|
queryFunction?: typeof datasource.query |
||||||
|
) => Observable<PanelData>; |
||||||
|
searchFilter?: string; |
||||||
|
} |
||||||
|
|
||||||
|
type GetTargetArgs = { datasource: DataSourceApi; variable: QueryVariableModel }; |
||||||
|
|
||||||
|
export interface QueryRunner { |
||||||
|
type: VariableSupportType; |
||||||
|
canRun: (dataSource: DataSourceApi) => boolean; |
||||||
|
getTarget: (args: GetTargetArgs) => DataQuery; |
||||||
|
runRequest: (args: RunnerArgs, request: DataQueryRequest) => Observable<PanelData>; |
||||||
|
} |
||||||
|
|
||||||
|
export class QueryRunners { |
||||||
|
private readonly runners: QueryRunner[]; |
||||||
|
constructor() { |
||||||
|
this.runners = [ |
||||||
|
new LegacyQueryRunner(), |
||||||
|
new StandardQueryRunner(), |
||||||
|
new CustomQueryRunner(), |
||||||
|
new DatasourceQueryRunner(), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
getRunnerForDatasource(datasource: DataSourceApi): QueryRunner { |
||||||
|
const runner = this.runners.find(runner => runner.canRun(datasource)); |
||||||
|
if (runner) { |
||||||
|
return runner; |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error("Couldn't find a query runner that matches supplied arguments."); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class LegacyQueryRunner implements QueryRunner { |
||||||
|
type = VariableSupportType.Legacy; |
||||||
|
|
||||||
|
canRun(dataSource: DataSourceApi) { |
||||||
|
return hasLegacyVariableSupport(dataSource); |
||||||
|
} |
||||||
|
|
||||||
|
getTarget({ datasource, variable }: GetTargetArgs) { |
||||||
|
if (hasLegacyVariableSupport(datasource)) { |
||||||
|
return variable.query; |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error("Couldn't create a target with supplied arguments."); |
||||||
|
} |
||||||
|
|
||||||
|
runRequest({ datasource, variable, searchFilter, timeSrv }: RunnerArgs, request: DataQueryRequest) { |
||||||
|
if (!hasLegacyVariableSupport(datasource)) { |
||||||
|
return getEmptyMetricFindValueObservable(); |
||||||
|
} |
||||||
|
|
||||||
|
const queryOptions: any = getLegacyQueryOptions(variable, searchFilter, timeSrv); |
||||||
|
|
||||||
|
return from(datasource.metricFindQuery(variable.query, queryOptions)).pipe( |
||||||
|
mergeMap(values => { |
||||||
|
if (!values || !values.length) { |
||||||
|
return getEmptyMetricFindValueObservable(); |
||||||
|
} |
||||||
|
|
||||||
|
const series: any = values; |
||||||
|
return of({ series, state: LoadingState.Done, timeRange: DefaultTimeRange }); |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class StandardQueryRunner implements QueryRunner { |
||||||
|
type = VariableSupportType.Standard; |
||||||
|
|
||||||
|
canRun(dataSource: DataSourceApi) { |
||||||
|
return hasStandardVariableSupport(dataSource); |
||||||
|
} |
||||||
|
|
||||||
|
getTarget({ datasource, variable }: GetTargetArgs) { |
||||||
|
if (hasStandardVariableSupport(datasource)) { |
||||||
|
return datasource.variables.toDataQuery(variable.query); |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error("Couldn't create a target with supplied arguments."); |
||||||
|
} |
||||||
|
|
||||||
|
runRequest({ datasource, runRequest }: RunnerArgs, request: DataQueryRequest) { |
||||||
|
if (!hasStandardVariableSupport(datasource)) { |
||||||
|
return getEmptyMetricFindValueObservable(); |
||||||
|
} |
||||||
|
|
||||||
|
if (!datasource.variables.query) { |
||||||
|
return runRequest(datasource, request); |
||||||
|
} |
||||||
|
|
||||||
|
return runRequest(datasource, request, datasource.variables.query); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class CustomQueryRunner implements QueryRunner { |
||||||
|
type = VariableSupportType.Custom; |
||||||
|
|
||||||
|
canRun(dataSource: DataSourceApi) { |
||||||
|
return hasCustomVariableSupport(dataSource); |
||||||
|
} |
||||||
|
|
||||||
|
getTarget({ datasource, variable }: GetTargetArgs) { |
||||||
|
if (hasCustomVariableSupport(datasource)) { |
||||||
|
return variable.query; |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error("Couldn't create a target with supplied arguments."); |
||||||
|
} |
||||||
|
|
||||||
|
runRequest({ datasource, runRequest }: RunnerArgs, request: DataQueryRequest) { |
||||||
|
if (!hasCustomVariableSupport(datasource)) { |
||||||
|
return getEmptyMetricFindValueObservable(); |
||||||
|
} |
||||||
|
|
||||||
|
return runRequest(datasource, request, datasource.variables.query); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class DatasourceQueryRunner implements QueryRunner { |
||||||
|
type = VariableSupportType.Datasource; |
||||||
|
|
||||||
|
canRun(dataSource: DataSourceApi) { |
||||||
|
return hasDatasourceVariableSupport(dataSource); |
||||||
|
} |
||||||
|
|
||||||
|
getTarget({ datasource, variable }: GetTargetArgs) { |
||||||
|
if (hasDatasourceVariableSupport(datasource)) { |
||||||
|
return variable.query; |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error("Couldn't create a target with supplied arguments."); |
||||||
|
} |
||||||
|
|
||||||
|
runRequest({ datasource, runRequest }: RunnerArgs, request: DataQueryRequest) { |
||||||
|
if (!hasDatasourceVariableSupport(datasource)) { |
||||||
|
return getEmptyMetricFindValueObservable(); |
||||||
|
} |
||||||
|
|
||||||
|
return runRequest(datasource, request); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getEmptyMetricFindValueObservable(): Observable<PanelData> { |
||||||
|
return of({ state: LoadingState.Done, series: [], timeRange: DefaultTimeRange }); |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
import { variableQueryObserver } from './variableQueryObserver'; |
||||||
|
import { LoadingState } from '@grafana/data'; |
||||||
|
import { VariableIdentifier } from '../state/types'; |
||||||
|
import { UpdateOptionsResults } from './VariableQueryRunner'; |
||||||
|
|
||||||
|
function getTestContext(args: { next?: UpdateOptionsResults; error?: any; complete?: boolean }) { |
||||||
|
const { next, error, complete } = args; |
||||||
|
const resolve = jest.fn(); |
||||||
|
const reject = jest.fn(); |
||||||
|
const subscription: any = { |
||||||
|
unsubscribe: jest.fn(), |
||||||
|
}; |
||||||
|
const observer = variableQueryObserver(resolve, reject, subscription); |
||||||
|
|
||||||
|
if (next) { |
||||||
|
observer.next(next); |
||||||
|
} |
||||||
|
|
||||||
|
if (error) { |
||||||
|
observer.error(error); |
||||||
|
} |
||||||
|
|
||||||
|
if (complete) { |
||||||
|
observer.complete(); |
||||||
|
} |
||||||
|
|
||||||
|
return { resolve, reject, subscription, observer }; |
||||||
|
} |
||||||
|
|
||||||
|
const identifier: VariableIdentifier = { id: 'id', type: 'query' }; |
||||||
|
|
||||||
|
describe('variableQueryObserver', () => { |
||||||
|
describe('when receiving a Done state', () => { |
||||||
|
it('then it should call unsubscribe', () => { |
||||||
|
const { subscription } = getTestContext({ next: { state: LoadingState.Done, identifier } }); |
||||||
|
|
||||||
|
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call resolve', () => { |
||||||
|
const { resolve } = getTestContext({ next: { state: LoadingState.Done, identifier } }); |
||||||
|
|
||||||
|
expect(resolve).toHaveBeenCalledTimes(1); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when receiving an Error state', () => { |
||||||
|
it('then it should call unsubscribe', () => { |
||||||
|
const { subscription } = getTestContext({ next: { state: LoadingState.Error, identifier, error: 'An error' } }); |
||||||
|
|
||||||
|
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call reject', () => { |
||||||
|
const { reject } = getTestContext({ next: { state: LoadingState.Error, identifier, error: 'An error' } }); |
||||||
|
|
||||||
|
expect(reject).toHaveBeenCalledTimes(1); |
||||||
|
expect(reject).toHaveBeenCalledWith('An error'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when receiving an error', () => { |
||||||
|
it('then it should call unsubscribe', () => { |
||||||
|
const { subscription } = getTestContext({ error: 'An error' }); |
||||||
|
|
||||||
|
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call reject', () => { |
||||||
|
const { reject } = getTestContext({ error: 'An error' }); |
||||||
|
|
||||||
|
expect(reject).toHaveBeenCalledTimes(1); |
||||||
|
expect(reject).toHaveBeenCalledWith('An error'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('when receiving complete', () => { |
||||||
|
it('then it should call unsubscribe', () => { |
||||||
|
const { subscription } = getTestContext({ complete: true }); |
||||||
|
|
||||||
|
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it('then it should call resolve', () => { |
||||||
|
const { resolve } = getTestContext({ complete: true }); |
||||||
|
|
||||||
|
expect(resolve).toHaveBeenCalledTimes(1); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
import { Observer, Subscription } from 'rxjs'; |
||||||
|
import { LoadingState } from '@grafana/data'; |
||||||
|
|
||||||
|
import { UpdateOptionsResults } from './VariableQueryRunner'; |
||||||
|
|
||||||
|
export function variableQueryObserver( |
||||||
|
resolve: (value?: any) => void, |
||||||
|
reject: (value?: any) => void, |
||||||
|
subscription: Subscription |
||||||
|
): Observer<UpdateOptionsResults> { |
||||||
|
const observer: Observer<UpdateOptionsResults> = { |
||||||
|
next: results => { |
||||||
|
if (results.state === LoadingState.Error) { |
||||||
|
subscription.unsubscribe(); |
||||||
|
reject(results.error); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (results.state === LoadingState.Done) { |
||||||
|
subscription.unsubscribe(); |
||||||
|
resolve(); |
||||||
|
return; |
||||||
|
} |
||||||
|
}, |
||||||
|
error: err => { |
||||||
|
subscription.unsubscribe(); |
||||||
|
reject(err); |
||||||
|
}, |
||||||
|
complete: () => { |
||||||
|
subscription.unsubscribe(); |
||||||
|
resolve(); |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
return observer; |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
import { QueryVariableModel } from 'app/features/variables/types'; |
||||||
|
import { DatasourceVariableBuilder } from './datasourceVariableBuilder'; |
||||||
|
|
||||||
|
export class QueryVariableBuilder<T extends QueryVariableModel> extends DatasourceVariableBuilder<T> { |
||||||
|
withTags(useTags: boolean) { |
||||||
|
this.variable.useTags = useTags; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
withTagsQuery(tagsQuery: string) { |
||||||
|
this.variable.tagsQuery = tagsQuery; |
||||||
|
return this; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
import { from, Observable } from 'rxjs'; |
||||||
|
import { map, mergeMap } from 'rxjs/operators'; |
||||||
|
import { CustomVariableSupport, DataQueryRequest, DataQueryResponse } from '@grafana/data'; |
||||||
|
|
||||||
|
import CloudMonitoringDatasource from './datasource'; |
||||||
|
import { CloudMonitoringVariableQuery } from './types'; |
||||||
|
import CloudMonitoringMetricFindQuery from './CloudMonitoringMetricFindQuery'; |
||||||
|
import { CloudMonitoringVariableQueryEditor } from './components/VariableQueryEditor'; |
||||||
|
|
||||||
|
export class CloudMonitoringVariableSupport extends CustomVariableSupport< |
||||||
|
CloudMonitoringDatasource, |
||||||
|
CloudMonitoringVariableQuery |
||||||
|
> { |
||||||
|
private readonly metricFindQuery: CloudMonitoringMetricFindQuery; |
||||||
|
|
||||||
|
constructor(private readonly datasource: CloudMonitoringDatasource) { |
||||||
|
super(); |
||||||
|
this.metricFindQuery = new CloudMonitoringMetricFindQuery(datasource); |
||||||
|
this.query = this.query.bind(this); |
||||||
|
} |
||||||
|
|
||||||
|
editor = CloudMonitoringVariableQueryEditor; |
||||||
|
|
||||||
|
query(request: DataQueryRequest<CloudMonitoringVariableQuery>): Observable<DataQueryResponse> { |
||||||
|
const executeObservable = from(this.metricFindQuery.execute(request.targets[0])); |
||||||
|
return from(this.datasource.ensureGCEDefaultProject()).pipe( |
||||||
|
mergeMap(() => executeObservable), |
||||||
|
map(data => ({ data })) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
import { from, Observable, of } from 'rxjs'; |
||||||
|
import { map } from 'rxjs/operators'; |
||||||
|
import { |
||||||
|
DataQueryRequest, |
||||||
|
DataQueryResponse, |
||||||
|
rangeUtil, |
||||||
|
StandardVariableQuery, |
||||||
|
StandardVariableSupport, |
||||||
|
} from '@grafana/data'; |
||||||
|
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { PrometheusDatasource } from './datasource'; |
||||||
|
import { PromQuery } from './types'; |
||||||
|
import PrometheusMetricFindQuery from './metric_find_query'; |
||||||
|
import { getTimeSrv, TimeSrv } from '../../../features/dashboard/services/TimeSrv'; |
||||||
|
|
||||||
|
export class PrometheusVariableSupport extends StandardVariableSupport<PrometheusDatasource> { |
||||||
|
constructor( |
||||||
|
private readonly datasource: PrometheusDatasource, |
||||||
|
private readonly templateSrv: TemplateSrv = getTemplateSrv(), |
||||||
|
private readonly timeSrv: TimeSrv = getTimeSrv() |
||||||
|
) { |
||||||
|
super(); |
||||||
|
this.query = this.query.bind(this); |
||||||
|
} |
||||||
|
|
||||||
|
query(request: DataQueryRequest<PromQuery>): Observable<DataQueryResponse> { |
||||||
|
const query = request.targets[0].expr; |
||||||
|
if (!query) { |
||||||
|
return of({ data: [] }); |
||||||
|
} |
||||||
|
|
||||||
|
const scopedVars = { |
||||||
|
...request.scopedVars, |
||||||
|
__interval: { text: this.datasource.interval, value: this.datasource.interval }, |
||||||
|
__interval_ms: { |
||||||
|
text: rangeUtil.intervalToMs(this.datasource.interval), |
||||||
|
value: rangeUtil.intervalToMs(this.datasource.interval), |
||||||
|
}, |
||||||
|
...this.datasource.getRangeScopedVars(this.timeSrv.timeRange()), |
||||||
|
}; |
||||||
|
|
||||||
|
const interpolated = this.templateSrv.replace(query, scopedVars, this.datasource.interpolateQueryExpr); |
||||||
|
const metricFindQuery = new PrometheusMetricFindQuery(this.datasource, interpolated); |
||||||
|
const metricFindStream = from(metricFindQuery.process()); |
||||||
|
|
||||||
|
return metricFindStream.pipe(map(results => ({ data: results }))); |
||||||
|
} |
||||||
|
|
||||||
|
toDataQuery(query: StandardVariableQuery): PromQuery { |
||||||
|
return { |
||||||
|
refId: 'PrometheusDatasource-VariableQuery', |
||||||
|
expr: query.query, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import { StandardVariableQuery, StandardVariableSupport } from '@grafana/data'; |
||||||
|
|
||||||
|
import { TestDataDataSource } from './datasource'; |
||||||
|
import { TestDataQuery } from './types'; |
||||||
|
|
||||||
|
export class TestDataVariableSupport extends StandardVariableSupport<TestDataDataSource> { |
||||||
|
toDataQuery(query: StandardVariableQuery): TestDataQuery { |
||||||
|
return { |
||||||
|
refId: 'TestDataDataSource-QueryVariable', |
||||||
|
stringInput: query.query, |
||||||
|
scenarioId: 'variables-query', |
||||||
|
csvWave: null, |
||||||
|
points: [], |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue