diff --git a/public/app/features/scenes/core/sceneGraph.ts b/public/app/features/scenes/core/sceneGraph.ts index 0cee433477e..2522e2e5264 100644 --- a/public/app/features/scenes/core/sceneGraph.ts +++ b/public/app/features/scenes/core/sceneGraph.ts @@ -1,6 +1,6 @@ -import { getDefaultTimeRange, LoadingState } from '@grafana/data'; +import { getDefaultTimeRange, LoadingState, ScopedVars } from '@grafana/data'; -import { sceneInterpolator } from '../variables/interpolation/sceneInterpolator'; +import { CustomFormatterFn, sceneInterpolator } from '../variables/interpolation/sceneInterpolator'; import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; import { SceneVariables } from '../variables/types'; @@ -89,13 +89,18 @@ export function getLayout(scene: SceneObject): SceneObject { /** * Interpolates the given string using the current scene object as context. * */ -export function interpolate(sceneObject: SceneObject, value: string | undefined | null): string { +export function interpolate( + sceneObject: SceneObject, + value: string | undefined | null, + scopedVars?: ScopedVars, + format?: string | CustomFormatterFn +): string { // Skip interpolation if there are no variable dependencies if (!value || !sceneObject.variableDependency || sceneObject.variableDependency.getNames().size === 0) { return value ?? ''; } - return sceneInterpolator(sceneObject, value); + return sceneInterpolator(sceneObject, value, scopedVars, format); } export const EmptyVariableSet = new SceneVariableSet({ variables: [] }); diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx index 16cf91a60fb..aae519e72b7 100644 --- a/public/app/features/scenes/scenes/variablesDemo.tsx +++ b/public/app/features/scenes/scenes/variablesDemo.tsx @@ -7,7 +7,9 @@ import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { VariableValueSelectors } from '../variables/components/VariableValueSelectors'; import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; +import { ConstantVariable } from '../variables/variants/ConstantVariable'; import { CustomVariable } from '../variables/variants/CustomVariable'; +import { DataSourceVariable } from '../variables/variants/DataSourceVariable'; import { TestVariable } from '../variables/variants/TestVariable'; import { getQueryRunnerWithRandomWalkQuery } from './queries'; @@ -43,16 +45,36 @@ export function getVariablesDemo(): Scene { text: '', options: [], }), + new ConstantVariable({ + name: 'constant', + value: 'slow', + }), new CustomVariable({ name: 'Single Custom', query: 'A : 10,B : 20', - options: [], }), new CustomVariable({ name: 'Multi Custom', query: 'A : 10,B : 20', isMulti: true, - options: [], + }), + new DataSourceVariable({ + name: 'DataSource', + query: 'testdata', + }), + new DataSourceVariable({ + name: 'DataSource', + query: 'prometheus', + }), + new DataSourceVariable({ + name: 'DataSource multi', + query: 'prometheus', + isMulti: true, + }), + new DataSourceVariable({ + name: 'Datasource w/ regex and using $constant', + query: 'prometheus', + regex: '.*$constant.*', }), ], }), diff --git a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts index 06466d7d892..8b72af1ef95 100644 --- a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts +++ b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts @@ -9,7 +9,7 @@ import { VariableValue } from '../types'; import { getSceneVariableForScopedVar } from './ScopedVarsVariable'; import { formatRegistry, FormatRegistryID, FormatVariable } from './formatRegistry'; -type CustomFormatterFn = ( +export type CustomFormatterFn = ( value: unknown, legacyVariableModel: VariableModel, legacyDefaultFormatter: CustomFormatterFn diff --git a/public/app/features/scenes/variables/variants/CustomVariable.test.ts b/public/app/features/scenes/variables/variants/CustomVariable.test.ts index f974a281b93..21e39a652b0 100644 --- a/public/app/features/scenes/variables/variants/CustomVariable.test.ts +++ b/public/app/features/scenes/variables/variants/CustomVariable.test.ts @@ -21,26 +21,7 @@ describe('CustomVariable', () => { }); }); - describe('When invalid query is provided', () => { - it('Should default to empty options', async () => { - const variable = new CustomVariable({ - name: 'test', - options: [], - value: '', - text: '', - query: 'A - B', - }); - - // TODO: Be able to triggger the state update to get the options - await lastValueFrom(variable.getValueOptions({})); - - expect(variable.state.value).toEqual(''); - expect(variable.state.text).toEqual(''); - expect(variable.state.options).toEqual([]); - }); - }); - - describe('When valid query is provided', () => { + describe('When query is provided', () => { it('Should generate correctly the options for only value queries', async () => { const variable = new CustomVariable({ name: 'test', diff --git a/public/app/features/scenes/variables/variants/DataSourceVariable.test.ts b/public/app/features/scenes/variables/variants/DataSourceVariable.test.ts new file mode 100644 index 00000000000..94f6fb6881d --- /dev/null +++ b/public/app/features/scenes/variables/variants/DataSourceVariable.test.ts @@ -0,0 +1,227 @@ +import { lastValueFrom } from 'rxjs'; + +import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; +import { getMockPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; + +import { SceneObject } from '../../core/types'; +import { CustomFormatterFn } from '../interpolation/sceneInterpolator'; + +import { DataSourceVariable } from './DataSourceVariable'; + +function getDataSource(name: string, type: string, isDefault = false): DataSourceInstanceSettings { + return { + id: 1, + uid: 'c8eceabb-0275-4108-8f03-8f74faf4bf6d', + type, + name, + meta: getMockPlugin({ name, id: type }), + jsonData: {}, + access: 'proxy', + readOnly: false, + isDefault, + }; +} + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: () => [ + getDataSource('prometheus-mocked', 'prometheus'), + getDataSource('slow-prometheus-mocked', 'prometheus', true), + getDataSource('elastic-mocked', 'elastic'), + ], + }), +})); + +jest.mock('../../core/sceneGraph', () => { + return { + ...jest.requireActual('../../core/sceneGraph'), + sceneGraph: { + interpolate: ( + sceneObject: SceneObject, + value: string | undefined | null, + scopedVars?: ScopedVars, + format?: string | CustomFormatterFn + ) => { + return value?.replace('$variable-1', 'slow'); + }, + }, + }; +}); + +describe('DataSourceVariable', () => { + describe('When empty query is provided', () => { + it('Should default to empty options and empty value', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + value: '', + text: '', + query: '', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(''); + expect(variable.state.text).toEqual(''); + expect(variable.state.options).toEqual([]); + }); + }); + + describe('When query is provided', () => { + it('Should default to non datasources found options for invalid query', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + value: '', + text: '', + query: 'non-existant-datasource', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(''); + expect(variable.state.text).toEqual(''); + expect(variable.state.options).toEqual([ + { + label: 'No data sources found', + value: '', + }, + ]); + }); + + it('Should default to first item datasource when options available', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + value: '', + text: '', + query: 'prometheus', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual('prometheus-mocked'); + expect(variable.state.text).toEqual('prometheus-mocked'); + expect(variable.state.options).toEqual([ + { + label: 'prometheus-mocked', + value: 'prometheus-mocked', + }, + { + label: 'slow-prometheus-mocked', + value: 'slow-prometheus-mocked', + }, + { + label: 'default', + value: 'default', + }, + ]); + }); + + it('Should generate correctly the options including only datasources with the queried type', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + value: '', + text: '', + query: 'prometheus', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual('prometheus-mocked'); + expect(variable.state.text).toEqual('prometheus-mocked'); + expect(variable.state.options).toEqual([ + { label: 'prometheus-mocked', value: 'prometheus-mocked' }, + { label: 'slow-prometheus-mocked', value: 'slow-prometheus-mocked' }, + { label: 'default', value: 'default' }, + ]); + }); + }); + + describe('When regex is provided', () => { + it('Should generate correctly the options including only datasources with matching', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + value: '', + text: '', + query: 'prometheus', + regex: 'slow.*', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual('slow-prometheus-mocked'); + expect(variable.state.text).toEqual('slow-prometheus-mocked'); + expect(variable.state.options).toEqual([{ label: 'slow-prometheus-mocked', value: 'slow-prometheus-mocked' }]); + }); + + it('Should generate correctly the options after interpolating variables', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + value: '', + text: '', + query: 'prometheus', + regex: '$variable-1.*', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual('slow-prometheus-mocked'); + expect(variable.state.text).toEqual('slow-prometheus-mocked'); + expect(variable.state.options).toEqual([{ label: 'slow-prometheus-mocked', value: 'slow-prometheus-mocked' }]); + }); + }); + + describe('When value is provided', () => { + it('Should keep current value if current value is valid', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + query: 'prometheus', + value: 'slow-prometheus-mocked', + text: 'slow-prometheus-mocked', + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toBe('slow-prometheus-mocked'); + expect(variable.state.text).toBe('slow-prometheus-mocked'); + }); + + it('Should maintain the valid values when multiple selected', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + isMulti: true, + query: 'prometheus', + value: ['prometheus-mocked', 'slow-prometheus-mocked', 'elastic-mocked'], + text: ['prometheus-mocked', 'slow-prometheus-mocked', 'elastic-mocked'], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(['prometheus-mocked', 'slow-prometheus-mocked']); + expect(variable.state.text).toEqual(['prometheus-mocked', 'slow-prometheus-mocked']); + }); + + it('Should pick first option if none of the current values are valid', async () => { + const variable = new DataSourceVariable({ + name: 'test', + options: [], + isMulti: true, + query: 'elastic', + value: ['prometheus-mocked', 'slow-prometheus-mocked'], + text: ['prometheus-mocked', 'slow-prometheus-mocked'], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(['elastic-mocked']); + expect(variable.state.text).toEqual(['elastic-mocked']); + }); + }); +}); diff --git a/public/app/features/scenes/variables/variants/DataSourceVariable.tsx b/public/app/features/scenes/variables/variants/DataSourceVariable.tsx new file mode 100644 index 00000000000..18712274f0c --- /dev/null +++ b/public/app/features/scenes/variables/variants/DataSourceVariable.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Observable, of } from 'rxjs'; + +import { stringToJsRegex, DataSourceInstanceSettings } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; + +import { sceneGraph } from '../../core/sceneGraph'; +import { SceneComponentProps } from '../../core/types'; +import { VariableDependencyConfig } from '../VariableDependencyConfig'; +import { VariableValueSelect } from '../components/VariableValueSelect'; +import { VariableValueOption } from '../types'; + +import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable'; + +export interface DataSourceVariableState extends MultiValueVariableState { + query: string; + regex: string; +} + +export class DataSourceVariable extends MultiValueVariable { + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['regex'], + }); + + public constructor(initialState: Partial) { + super({ + value: '', + text: '', + options: [], + name: '', + regex: '', + query: '', + ...initialState, + }); + } + + public getValueOptions(args: VariableGetOptionsArgs): Observable { + if (!this.state.query) { + return of([]); + } + + const dataSourceTypes = this.getDataSourceTypes(); + + let regex; + if (this.state.regex) { + const interpolated = sceneGraph.interpolate(this, this.state.regex, undefined, 'regex'); + regex = stringToJsRegex(interpolated); + } + + const options: VariableValueOption[] = []; + + for (let i = 0; i < dataSourceTypes.length; i++) { + const source = dataSourceTypes[i]; + // must match on type + if (source.meta.id !== this.state.query) { + continue; + } + + if (isValid(source, regex)) { + options.push({ label: source.name, value: source.name }); + } + + if (isDefault(source, regex)) { + options.push({ label: 'default', value: 'default' }); + } + } + + if (options.length === 0) { + options.push({ label: 'No data sources found', value: '' }); + } + + // TODO: Add support for include All + // if (instanceState.includeAll) { + // options.unshift({ label: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE }); + //} + + return of(options); + } + + private getDataSourceTypes(): DataSourceInstanceSettings[] { + return getDataSourceSrv().getList({ metrics: true, variables: false }); + } + + public static Component = ({ model }: SceneComponentProps) => { + return ; + }; +} + +function isValid(source: DataSourceInstanceSettings, regex?: RegExp) { + if (!regex) { + return true; + } + + return regex.exec(source.name); +} + +function isDefault(source: DataSourceInstanceSettings, regex?: RegExp) { + if (!source.isDefault) { + return false; + } + + if (!regex) { + return true; + } + + return regex.exec('default'); +}