Migrate from DatasourceAPI to DatasourceWithBackend (#2123)

This PR migrates the use of `DatasourceApi` to `DatasourceWithBackend`,
with this a couple additional improvements were made:

1. Migrate to use `interpolateVariablesInQuery` everywhere instead of
the custom `replaceTemplateVariables` we were using
2. Moves util functions out of `datasource.ts` and into the existing
`utils.ts`

<img width="1261" height="406" alt="Screenshot 2025-11-20 at 11 37
56 AM"
src="https://github.com/user-attachments/assets/9e396cf2-eab0-49d1-958c-963a2e896eba"
/>

Now we can see the `query` calls being made to the backend:
<img width="367" height="102" alt="Screenshot 2025-11-20 at 11 38 18 AM"
src="https://github.com/user-attachments/assets/a5a9a337-7f19-4f7c-9d04-9d30c0216fb2"
/>

Tested:
- By running queries from Explore and Dashboards (with and without
variables)
- By interacting with all the different Editors to make sure `ComboBox`
was working as expected


Next:
Once this is merged, we will next be able to slowly move away from using
the `ZabbixConnector` to make backend datasource calls.

Fixes:
[#131](https://github.com/orgs/grafana/projects/457/views/40?pane=issue&itemId=139450234&issue=grafana%7Coss-big-tent-squad%7C131)
This commit is contained in:
Jocelyn Collado-Kuri
2025-12-16 09:58:02 -08:00
committed by GitHub
parent cc492b916d
commit ce4a8d3e19
13 changed files with 658 additions and 564 deletions

View File

@@ -1,26 +1,67 @@
import { dateMath } from '@grafana/data';
import { DataQueryResponse, dateMath } from '@grafana/data';
import _ from 'lodash';
import { datasourceSrvMock, templateSrvMock } from '../../test-setup/mocks';
import { replaceTemplateVars, ZabbixDatasource, zabbixTemplateFormat } from '../datasource';
import { VariableQueryTypes } from '../types';
import { ZabbixDatasource } from 'datasource/datasource';
// firstValueFrom removed - tests call frontendQuery directly for text queries
import * as utils from '../utils';
jest.mock(
'@grafana/runtime',
() => ({
getBackendSrv: () => ({
datasourceRequest: jest.fn().mockResolvedValue({ data: { result: '' } }),
fetch: () => ({
toPromise: () => jest.fn().mockResolvedValue({ data: { result: '' } }),
() => {
const actual = jest.requireActual('@grafana/runtime');
// Provide a custom query implementation that resolves backend + frontend + db + annotations
// so tests relying on merged results receive expected data.
if (actual && actual.DataSourceWithBackend && actual.DataSourceWithBackend.prototype) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
actual.DataSourceWithBackend.prototype.query = function (request: any) {
const that: any = this;
const { from } = require('rxjs');
const backendResponse = Promise.resolve({ data: [] });
const dbPromise = that.dbConnectionQuery ? that.dbConnectionQuery(request) : Promise.resolve({ data: [] });
const fePromise = that.frontendQuery ? that.frontendQuery(request) : Promise.resolve({ data: [] });
const annPromise = that.annotationRequest ? that.annotationRequest(request) : Promise.resolve({ data: [] });
return from(
Promise.all([backendResponse, dbPromise, fePromise, annPromise]).then(([backend, db, fe, ann]) => {
const data: any[] = [];
if (backend && backend.data) {
data.push(...backend.data);
}
if (db && db.data) {
data.push(...db.data);
}
if (fe && fe.data) {
data.push(...fe.data);
}
if (ann && ann.data) {
data.push(...ann.data);
}
return { data };
})
);
};
}
return {
...actual,
getBackendSrv: () => ({
datasourceRequest: jest.fn().mockResolvedValue({ data: { result: '' } }),
fetch: () => ({
toPromise: () => jest.fn().mockResolvedValue({ data: { result: '' } }),
}),
}),
}),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn().mockResolvedValue({}),
}),
getTemplateSrv: () => ({
replace: jest.fn().mockImplementation((query) => query),
}),
reportInteraction: jest.fn(),
}),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn().mockResolvedValue({}),
}),
getTemplateSrv: () => ({
replace: jest.fn().mockImplementation((query) => query),
}),
reportInteraction: jest.fn(),
};
},
{ virtual: true }
);
@@ -28,6 +69,24 @@ jest.mock('../components/AnnotationQueryEditor', () => ({
AnnotationQueryEditor: () => {},
}));
jest.mock(
'../utils',
() => (
jest.requireActual('../utils'),
{
replaceVariablesInFuncParams: jest.fn(),
parseInterval: jest.fn(),
replaceTemplateVars: jest.fn().mockImplementation((templateSrv, prop) => prop),
getRangeScopedVars: jest.fn(),
bindFunctionDefs: jest.fn().mockResolvedValue([]),
parseLegacyVariableQuery: jest.fn(),
formatMetric: jest.fn().mockImplementation((metric) => {
return { text: metric.name, expandable: false };
}),
}
)
);
describe('ZabbixDatasource', () => {
let ctx: any = {};
let consoleSpy: jest.SpyInstance;
@@ -101,7 +160,7 @@ describe('ZabbixDatasource', () => {
item: { filter: 'System information' },
textFilter: '',
useCaptureGroups: true,
queryType: 2,
queryType: '2',
resultFormat: 'table',
options: {
skipEmptyValues: false,
@@ -110,25 +169,18 @@ describe('ZabbixDatasource', () => {
];
});
it('should return data in table format', (done) => {
ctx.ds.query(ctx.options).then((result) => {
expect(result.data.length).toBe(1);
it('should return data in table format', async () => {
const result = (await ctx.ds.frontendQuery(ctx.options)) as DataQueryResponse;
expect(result.data.length).toBe(1);
let tableData = result.data[0];
expect(tableData.columns).toEqual([
{ text: 'Host' },
{ text: 'Item' },
{ text: 'Key' },
{ text: 'Last value' },
]);
expect(tableData.rows).toEqual([['Zabbix server', 'System information', 'system.uname', 'Linux last']]);
done();
});
let tableData = result.data[0];
expect(tableData.columns).toEqual([{ text: 'Host' }, { text: 'Item' }, { text: 'Key' }, { text: 'Last value' }]);
expect(tableData.rows).toEqual([['Zabbix server', 'System information', 'system.uname', 'Linux last']]);
});
it('should extract value if regex with capture group is used', (done) => {
ctx.options.targets[0].textFilter = 'Linux (.*)';
ctx.ds.query(ctx.options).then((result) => {
ctx.ds.frontendQuery(ctx.options).then((result) => {
let tableData = result.data[0];
expect(tableData.rows[0][3]).toEqual('last');
done();
@@ -163,7 +215,7 @@ describe('ZabbixDatasource', () => {
{ clock: '1500010500', itemid: '90109', ns: '900111000', value: '' },
])
);
return ctx.ds.query(ctx.options).then((result) => {
return ctx.ds.frontendQuery(ctx.options).then((result) => {
let tableData = result.data[0];
expect(tableData.rows.length).toBe(1);
expect(tableData.rows[0][3]).toEqual('Linux last');
@@ -171,69 +223,34 @@ describe('ZabbixDatasource', () => {
});
});
describe('When replacing template variables', () => {
function testReplacingVariable(target, varValue, expectedResult, done) {
ctx.ds.replaceTemplateVars = _.partial(replaceTemplateVars, {
replace: jest.fn((target) => zabbixTemplateFormat(varValue)),
});
let result = ctx.ds.replaceTemplateVars(target);
expect(result).toBe(expectedResult);
done();
}
/*
* Alphanumerics, spaces, dots, dashes and underscores
* are allowed in Zabbix host name.
* 'AaBbCc0123 .-_'
*/
it('should return properly escaped regex', (done) => {
let target = '$host';
let template_var_value = 'AaBbCc0123 .-_';
let expected_result = '/^AaBbCc0123 \\.-_$/';
testReplacingVariable(target, template_var_value, expected_result, done);
});
/*
* Single-value variable
* $host = backend01
* $host => /^backend01|backend01$/
*/
it('should return proper regex for single value', (done) => {
let target = '$host';
let template_var_value = 'backend01';
let expected_result = '/^backend01$/';
testReplacingVariable(target, template_var_value, expected_result, done);
});
/*
* Multi-value variable
* $host = [backend01, backend02]
* $host => /^(backend01|backend01)$/
*/
it('should return proper regex for multi-value', (done) => {
let target = '$host';
let template_var_value = ['backend01', 'backend02'];
let expected_result = '/^(backend01|backend02)$/';
testReplacingVariable(target, template_var_value, expected_result, done);
});
});
describe('When invoking metricFindQuery() with legacy query', () => {
beforeEach(() => {
ctx.ds.replaceTemplateVars = (str) => str;
ctx.ds.zabbix = {
getGroups: jest.fn().mockReturnValue(Promise.resolve([])),
getHosts: jest.fn().mockReturnValue(Promise.resolve([])),
getApps: jest.fn().mockReturnValue(Promise.resolve([])),
getItems: jest.fn().mockReturnValue(Promise.resolve([])),
};
jest.spyOn(utils, 'replaceTemplateVars').mockImplementation(({}, prop: string, {}) => {
return prop;
});
});
it('should return groups', (done) => {
jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => {
let group = '';
if (query === '*') {
group = '/.*/';
} else {
group = query;
}
return {
queryType: VariableQueryTypes.Group,
group: group,
};
});
const tests = [
{ query: '*', expect: '/.*/' },
{ query: 'Backend', expect: 'Backend' },
@@ -259,6 +276,14 @@ describe('ZabbixDatasource', () => {
});
it('should return hosts', (done) => {
jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => {
let splits = query.split('.');
return {
queryType: VariableQueryTypes.Host,
group: splits[0] === '*' ? '/.*/' : splits[0],
host: splits[1] === '*' ? '/.*/' : splits[1],
};
});
const tests = [
{ query: '*.*', expect: ['/.*/', '/.*/'] },
{ query: '.', expect: ['', ''] },
@@ -275,6 +300,15 @@ describe('ZabbixDatasource', () => {
});
it('should return applications', (done) => {
jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => {
let splits = query.split('.');
return {
queryType: VariableQueryTypes.Application,
group: splits[0] === '*' ? '/.*/' : splits[0],
host: splits[1] === '*' ? '/.*/' : splits[1],
application: splits[2] === '*' ? '/.*/' : splits[2],
};
});
const tests = [
{ query: '*.*.*', expect: ['/.*/', '/.*/', '/.*/'] },
{ query: '.*.', expect: ['', '/.*/', ''] },
@@ -291,6 +325,16 @@ describe('ZabbixDatasource', () => {
});
it('should return items', (done) => {
jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => {
let splits = query.split('.');
return {
queryType: VariableQueryTypes.Item,
group: splits[0] === '*' ? '/.*/' : splits[0],
host: splits[1] === '*' ? '/.*/' : splits[1],
application: splits[2] === '*' ? '' : splits[2],
item: splits[3] === '*' ? '/.*/' : splits[3],
};
});
const tests = [
{ query: '*.*.*.*', expect: ['/.*/', '/.*/', '', undefined, '/.*/', { showDisabledItems: undefined }] },
{ query: '.*.*.*', expect: ['', '/.*/', '', undefined, '/.*/', { showDisabledItems: undefined }] },
@@ -320,6 +364,14 @@ describe('ZabbixDatasource', () => {
});
it('should invoke method with proper arguments', (done) => {
jest.spyOn(utils, 'parseLegacyVariableQuery').mockImplementation((query: string) => {
let splits = query.split('.');
return {
queryType: VariableQueryTypes.Host,
group: splits[0] === '*' ? '/.*/' : splits[0],
host: splits[1] === '*' ? '/.*/' : splits[1],
};
});
let query = '*.*';
ctx.ds.metricFindQuery(query);
@@ -329,7 +381,6 @@ describe('ZabbixDatasource', () => {
describe('When invoking metricFindQuery()', () => {
beforeEach(() => {
ctx.ds.replaceTemplateVars = (str) => str;
ctx.ds.zabbix = {
getGroups: jest.fn().mockReturnValue(Promise.resolve([{ name: 'Group1' }, { name: 'Group2' }])),
getHosts: jest.fn().mockReturnValue(Promise.resolve([{ name: 'Host1' }, { name: 'Host2' }])),

View File

@@ -1,5 +1,6 @@
import _ from 'lodash';
import * as utils from '../utils';
import { replaceTemplateVars, zabbixTemplateFormat } from '../utils';
describe('Utils', () => {
describe('expandItemName()', () => {
@@ -174,4 +175,90 @@ describe('Utils', () => {
}
});
});
describe('replaceTemplateVars()', () => {
function testReplacingVariable(target, varValue, expectedResult, done) {
const templateSrv = {
replace: jest.fn((target) => zabbixTemplateFormat(varValue)),
getVariables: jest.fn(),
containsTemplate: jest.fn(),
updateTimeRange: jest.fn(),
};
let result = replaceTemplateVars(templateSrv, target, {});
expect(result).toBe(expectedResult);
done();
}
/*
* Alphanumerics, spaces, dots, dashes and underscores
* are allowed in Zabbix host name.
* 'AaBbCc0123 .-_'
*/
it('should return properly escaped regex', (done) => {
let target = '$host';
let template_var_value = 'AaBbCc0123 .-_';
let expected_result = '/^AaBbCc0123 \\.-_$/';
testReplacingVariable(target, template_var_value, expected_result, done);
});
/*
* Single-value variable
* $host = backend01
* $host => /^backend01|backend01$/
*/
it('should return proper regex for single value', (done) => {
let target = '$host';
let template_var_value = 'backend01';
let expected_result = '/^backend01$/';
testReplacingVariable(target, template_var_value, expected_result, done);
});
/*
* Multi-value variable
* $host = [backend01, backend02]
* $host => /^(backend01|backend01)$/
*/
it('should return proper regex for multi-value', (done) => {
let target = '$host';
let template_var_value = ['backend01', 'backend02'];
let expected_result = '/^(backend01|backend02)$/';
testReplacingVariable(target, template_var_value, expected_result, done);
});
});
describe('replaceVariablesInFuncParams()', () => {
it('should interpolate numeric and string params with templateSrv', () => {
const replaceMock = jest
.fn()
.mockImplementation((value) => (value === '42' ? '100' : value.replace('$var', 'result')));
const templateSrv = { replace: replaceMock };
const scopedVars = { some: 'var' } as any;
const functions = [
{
def: { name: 'test' },
params: [42, '$var'],
},
];
const [fn] = utils.replaceVariablesInFuncParams(templateSrv as any, functions as any, scopedVars);
expect(replaceMock).toHaveBeenCalledWith('42', scopedVars);
expect(replaceMock).toHaveBeenCalledWith('$var', scopedVars);
expect(fn.params).toEqual([100, 'result']);
});
it('should keep params undefined when function has none', () => {
const templateSrv = { replace: jest.fn() };
const functions = [{ def: { name: 'noop' } }];
const [fn] = utils.replaceVariablesInFuncParams(templateSrv as any, functions as any, {} as any);
expect(fn.params).toBeUndefined();
expect(templateSrv.replace).not.toHaveBeenCalled();
});
});
});