Variables: Multi-select DataSource variables are inconsistently displayed in the Data source picker (#76039)

Always show multi-select DataSource(DS) variables in the DS picker, and display a warning in the panel when a DataSource variable holds multiple values and is not being repeated.

---------

Co-authored-by: Alexandra Vargas <alexa1866@gmail.com>
Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Polina Boneva 2024-03-18 11:30:27 +02:00 committed by GitHub
parent f5e83d07a7
commit fce78aea2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 19 deletions

View File

@ -4088,7 +4088,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
"public/app/features/templating/template_srv.mock.ts:5381": [ "public/app/features/templating/template_srv.mock.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
], ],
"public/app/features/templating/template_srv.ts:5381": [ "public/app/features/templating/template_srv.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],

View File

@ -251,8 +251,10 @@ export class DatasourceSrv implements DataSourceService {
continue; continue;
} }
let dsValue = variable.current.value === 'default' ? this.defaultName : variable.current.value; let dsValue = variable.current.value === 'default' ? this.defaultName : variable.current.value;
if (Array.isArray(dsValue) && dsValue.length === 1) { // Support for multi-value DataSource (ds) variables
// Support for multi-value variables with only one selected datasource if (Array.isArray(dsValue)) {
// If the ds variable have multiple selected datasources
// We will use the first one
dsValue = dsValue[0]; dsValue = dsValue[0];
} }
const dsSettings = const dsSettings =

View File

@ -3,8 +3,9 @@ const applyFieldOverridesMock = jest.fn(); // needs to be first in this file
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
// Importing this way to be able to spy on grafana/data // Importing this way to be able to spy on grafana/data
import * as grafanaData from '@grafana/data'; import * as grafanaData from '@grafana/data';
import { DataSourceApi } from '@grafana/data'; import { DataSourceApi, TypedVariableModel } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; import { DataSourceSrv, setDataSourceSrv, setEchoSrv } from '@grafana/runtime';
import { TemplateSrvMock } from 'app/features/templating/template_srv.mock'; import { TemplateSrvMock } from 'app/features/templating/template_srv.mock';
@ -46,7 +47,27 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({
})); }));
jest.mock('app/features/templating/template_srv', () => ({ jest.mock('app/features/templating/template_srv', () => ({
getTemplateSrv: () => new TemplateSrvMock({}), ...jest.requireActual('app/features/templating/template_srv'),
getTemplateSrv: () =>
new TemplateSrvMock([
{
name: 'server',
type: 'datasource',
current: { text: 'Server1', value: 'server' },
options: [{ text: 'Server1', value: 'server1' }],
},
//multi value variable
{
name: 'multi',
type: 'datasource',
multi: true,
current: { text: 'Server1,Server2', value: ['server-1', 'server-2'] },
options: [
{ text: 'Server1', value: 'server1' },
{ text: 'Server2', value: 'server2' },
],
},
] as TypedVariableModel[]),
})); }));
interface ScenarioContext { interface ScenarioContext {
@ -405,4 +426,53 @@ describe('PanelQueryRunner', () => {
snapshotData, snapshotData,
} }
); );
describeQueryRunnerScenario(
'shouldAddErrorwhenDatasourceVariableIsMultiple',
(ctx) => {
it('should add error when datasource variable is multiple and not repeated', async () => {
// scopedVars is an object that represent the variables repeated in a panel
const scopedVars = {
server: { text: 'Server1', value: 'server-1' },
};
// We are spying on the replace method of the TemplateSrvMock to check if the custom format function is being called
const spyReplace = jest.spyOn(TemplateSrvMock.prototype, 'replace');
const response = {
data: [
{
target: 'hello',
datapoints: [
[1, 1000],
[2, 2000],
],
},
],
};
const datasource = {
name: '${multi}',
uid: '${multi}',
interval: ctx.dsInterval,
query: (options: grafanaData.DataQueryRequest) => {
ctx.queryCalledWith = options;
return Promise.resolve(response);
},
getRef: () => ({ type: 'test', uid: 'TestDB-uid' }),
testDatasource: jest.fn(),
} as unknown as DataSourceApi;
ctx.runner.shouldAddErrorWhenDatasourceVariableIsMultiple(datasource, scopedVars);
// the test is checking implementation details :(, but it is the only way to check if the error will be added
// if the getTemplateSrv.replace is called with the custom format function,it means we will check
// if the error should be added
expect(spyReplace.mock.calls[0][2]).toBeInstanceOf(Function);
});
},
{
...defaultPanelConfig,
}
);
}); });

View File

@ -275,6 +275,9 @@ export class PanelQueryRunner {
return; return;
} }
//check if datasource is a variable datasource and if that variable has multiple values
const addErroDSVariable = this.shouldAddErrorWhenDatasourceVariableIsMultiple(datasource, scopedVars);
const request: DataQueryRequest = { const request: DataQueryRequest = {
app: app ?? CoreApp.Dashboard, app: app ?? CoreApp.Dashboard,
requestId: getNextRequestId(), requestId: getNextRequestId(),
@ -327,7 +330,7 @@ export class PanelQueryRunner {
this.lastRequest = request; this.lastRequest = request;
this.pipeToSubject(runRequest(ds, request), panelId); this.pipeToSubject(runRequest(ds, request), panelId, false, addErroDSVariable);
} catch (err) { } catch (err) {
this.pipeToSubject( this.pipeToSubject(
of({ of({
@ -341,7 +344,12 @@ export class PanelQueryRunner {
} }
} }
private pipeToSubject(observable: Observable<PanelData>, panelId?: number, skipPreProcess = false) { private pipeToSubject(
observable: Observable<PanelData>,
panelId?: number,
skipPreProcess = false,
addErroDSVariable = false
) {
if (this.subscription) { if (this.subscription) {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
} }
@ -379,6 +387,17 @@ export class PanelQueryRunner {
this.lastResult = next; this.lastResult = next;
//add error message if datasource is a variable and has multiple values
if (addErroDSVariable) {
next.errors = [
{
message:
'Panel is using a variable datasource with multiple values without repeat option. Please configure the panel to be repeated by the same datasource variable.',
},
];
next.state = LoadingState.Error;
}
// Store preprocessed query results for applying overrides later on in the pipeline // Store preprocessed query results for applying overrides later on in the pipeline
this.subject.next(next); this.subject.next(next);
}, },
@ -451,6 +470,28 @@ export class PanelQueryRunner {
getLastRequest(): DataQueryRequest | undefined { getLastRequest(): DataQueryRequest | undefined {
return this.lastRequest; return this.lastRequest;
} }
shouldAddErrorWhenDatasourceVariableIsMultiple(
datasource: DataSourceRef | DataSourceApi | null,
scopedVars: ScopedVars | undefined
): boolean {
let addWarningMessageMultipleDatasourceVariable = false;
//If datasource is a variable
if (datasource?.uid?.startsWith('${')) {
// we can access the raw datasource variable values inside the replace function if we pass a custom format function
this.templateSrv.replace(datasource.uid, scopedVars, (value: string | string[]) => {
// if the variable has multiple values it means it's not being repeated
if (Array.isArray(value) && value.length > 1) {
addWarningMessageMultipleDatasourceVariable = true;
}
// return empty string to avoid replacing the variable
return '';
});
}
return addWarningMessageMultipleDatasourceVariable;
}
} }
async function getDataSource( async function getDataSource(

View File

@ -1,4 +1,4 @@
import { ScopedVars, TimeRange, TypedVariableModel } from '@grafana/data'; import { ScopedVars, TimeRange, TypedVariableModel, VariableOption } from '@grafana/data';
import { TemplateSrv } from '@grafana/runtime'; import { TemplateSrv } from '@grafana/runtime';
import { variableRegex } from '../variables/utils'; import { variableRegex } from '../variables/utils';
@ -13,18 +13,37 @@ import { variableRegex } from '../variables/utils';
*/ */
export class TemplateSrvMock implements TemplateSrv { export class TemplateSrvMock implements TemplateSrv {
private regex = variableRegex; private regex = variableRegex;
constructor(private variables: Record<string, string>) {} constructor(private variables: TypedVariableModel[]) {}
getVariables(): TypedVariableModel[] { getVariables(): TypedVariableModel[] {
return Object.keys(this.variables).map((key) => { if (!this.variables) {
return { return [];
type: 'custom', }
name: key,
label: key, return this.variables.reduce((acc: TypedVariableModel[], variable) => {
const commonProps = {
type: variable.type ?? 'custom',
name: variable.name ?? 'test',
label: variable.label ?? 'test',
}; };
// TODO: we remove this type assertion in a later PR if (variable.type === 'datasource') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions acc.push({
}) as TypedVariableModel[]; ...commonProps,
current: {
text: variable.current?.text,
value: variable.current?.value,
} as VariableOption,
options: variable.options ?? [],
multi: variable.multi ?? false,
includeAll: variable.includeAll ?? false,
} as TypedVariableModel);
} else {
acc.push({
...commonProps,
} as TypedVariableModel);
}
return acc as TypedVariableModel[];
}, []);
} }
replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string { replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string {
@ -35,8 +54,7 @@ export class TemplateSrvMock implements TemplateSrv {
this.regex.lastIndex = 0; this.regex.lastIndex = 0;
return target.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { return target.replace(this.regex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => {
const variableName = var1 || var2 || var3; return var1 || var2 || var3;
return this.variables[variableName];
}); });
} }