Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions connectors/grafana-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,27 @@ Select a time series in the TIME-SERIES selection box, select a function in the

Both SQL: Full Customized and SQL: Drop-down List input methods support the variable and template functions of grafana. In the following example, raw input method is used, and aggregation is similar.

##### Multi-value variable expansion in FROM (prefixPath)

When a multi-value template variable is used in the FROM input box (prefixPath), the plugin automatically expands it into multiple paths. This enables the common "global variable filter" pattern where a single dashboard dropdown controls all panels.

For example, define a multi-select variable `device` with values `device1`, `device2`, ..., `device8`. Then in the FROM input box, enter:

```
root.application.${device}
```

When the user selects `device1` and `device2`, the plugin internally expands this to:

```
root.application.device1
root.application.device2
```

Only the selected devices are queried from IoTDB — no client-side filtering or transformations needed.

This works with any number of prefixPath entries. Literal paths (without variables) and single-value variables behave the same as before.

After creating a new Panel, click the Settings button in the upper right corner:

<img style="width:100%; max-width:800px; max-height:600px; margin-left:auto; margin-right:auto; display:block;" src="https://github.com/apache/iotdb-bin-resources/blob/main/docs/UserGuide/Ecosystem%20Integration/Grafana-plugin/setconf.png?raw=true">
Expand Down
240 changes: 240 additions & 0 deletions connectors/grafana-plugin/src/datasource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DataSource } from './datasource';
import { IoTDBQuery } from './types';
import { ScopedVars } from '@grafana/data';

const mockReplace = jest.fn();
const mockContainsTemplate = jest.fn();
const mockGetVariables = jest.fn();

jest.mock('@grafana/runtime', () => ({
DataSourceWithBackend: class {},
getTemplateSrv: () => ({
replace: mockReplace,
containsTemplate: mockContainsTemplate,
getVariables: mockGetVariables,
}),
}));

describe('DataSource', () => {
let ds: DataSource;

beforeEach(() => {
ds = new DataSource({ jsonData: { url: 'http://localhost:6667', username: 'root' } } as any);
mockReplace.mockReset();
mockContainsTemplate.mockReset();
mockGetVariables.mockReset();
mockGetVariables.mockReturnValue([]);
});

describe('applyTemplateVariables - prefixPath expansion', () => {
const baseQuery: Partial<IoTDBQuery> = {
sqlType: 'SQL: Full Customized',
expression: [],
prefixPath: [],
condition: '',
control: '',
};
const scopedVars: ScopedVars = {};

it('should pass through literal paths without variables', () => {
mockContainsTemplate.mockReturnValue(false);
const query = { ...baseQuery, prefixPath: ['root.app.device1', 'root.app.device2'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.prefixPath).toEqual(['root.app.device1', 'root.app.device2']);
expect(mockReplace).not.toHaveBeenCalled();
});

it('should handle single-value variable without expansion', () => {
mockContainsTemplate.mockReturnValue(true);
mockGetVariables.mockReturnValue([
{ name: 'device', current: { value: 'device1' }, options: [{ value: '$__all' }, { value: 'device1' }] },
]);
mockReplace.mockReturnValue('device1');
const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.prefixPath).toEqual(['root.app.device1']);
});

it('should expand multi-value variable into multiple paths', () => {
mockContainsTemplate.mockReturnValue(true);
mockGetVariables.mockReturnValue([
{
name: 'device',
current: { value: ['device1', 'device2', 'device3'] },
options: [{ value: '$__all' }, { value: 'device1' }, { value: 'device2' }, { value: 'device3' }],
},
]);
const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.prefixPath).toEqual(['root.app.device1', 'root.app.device2', 'root.app.device3']);
});
Comment thread
jixuan1989 marked this conversation as resolved.

it('should handle mixed literal and template paths', () => {
mockContainsTemplate.mockImplementation((path: string) => path.includes('${'));
mockGetVariables.mockReturnValue([
{ name: 'device', current: { value: ['device1', 'device2'] }, options: [{ value: '$__all' }, { value: 'device1' }, { value: 'device2' }] },
]);
const query = {
...baseQuery,
prefixPath: ['root.static.path', 'root.app.${device}'],
} as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.prefixPath).toEqual(['root.static.path', 'root.app.device1', 'root.app.device2']);
});
Comment on lines +94 to +107
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — fixed in the force-push. Tests now use mockGetVariables() to provide variable values directly, matching the actual implementation.


it('should handle multiple template paths each with multi-value variables', () => {
mockContainsTemplate.mockReturnValue(true);
mockGetVariables.mockReturnValue([
{ name: 'var1', current: { value: ['d1', 'd2'] }, options: [{ value: '$__all' }, { value: 'd1' }, { value: 'd2' }] },
{ name: 'var2', current: { value: ['d3', 'd4'] }, options: [{ value: '$__all' }, { value: 'd3' }, { value: 'd4' }] },
]);
const query = {
...baseQuery,
prefixPath: ['root.a.${var1}', 'root.b.${var2}'],
} as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.prefixPath).toEqual(['root.a.d1', 'root.a.d2', 'root.b.d3', 'root.b.d4']);
});
Comment thread
jixuan1989 marked this conversation as resolved.

it('should still replace expression fields normally', () => {
mockContainsTemplate.mockReturnValue(false);
mockReplace.mockImplementation((v: string) => v.replace('${metric}', 'temperature'));
const query = {
...baseQuery,
prefixPath: ['root.app.device1'],
expression: ['${metric}'],
} as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.expression).toEqual(['temperature']);
});

it('should expand $__all using options list', () => {
mockContainsTemplate.mockReturnValue(true);
mockGetVariables.mockReturnValue([
{
name: 'target',
current: { value: '$__all' },
options: [{ value: '$__all' }, { value: 'apache_iotdb' }, { value: 'timecho' }, { value: 'influxdb' }],
},
]);
const query = { ...baseQuery, prefixPath: ['root.market_ops.pypi.${target}'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.prefixPath).toEqual([
'root.market_ops.pypi.apache_iotdb',
'root.market_ops.pypi.timecho',
'root.market_ops.pypi.influxdb',
]);
});

it('should use scopedVars when variable is present there', () => {
mockContainsTemplate.mockReturnValue(true);
const scoped: ScopedVars = { device: { text: 'Device 1', value: 'device1' } };
const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scoped);

expect(result.prefixPath).toEqual(['root.app.device1']);
});

it('should use scopedVars array value when variable is present there', () => {
mockContainsTemplate.mockReturnValue(true);
const scoped: ScopedVars = { device: { text: 'All', value: ['dev1', 'dev2'] } } as any;
const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scoped);

expect(result.prefixPath).toEqual(['root.app.dev1', 'root.app.dev2']);
});

Comment thread
jixuan1989 marked this conversation as resolved.
it('should expand $__all from scopedVars using options list', () => {
mockContainsTemplate.mockReturnValue(true);
mockGetVariables.mockReturnValue([
{
name: 'device',
current: { value: '$__all' },
options: [{ value: '$__all' }, { value: 'device1' }, { value: 'device2' }, { value: 'device3' }],
},
]);
const scoped: ScopedVars = { device: { text: 'All', value: '$__all' } } as any;
const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scoped);

expect(result.prefixPath).toEqual(['root.app.device1', 'root.app.device2', 'root.app.device3']);
});

it('should fallback to replace whole path when variable cannot be resolved', () => {
mockContainsTemplate.mockReturnValue(true);
mockGetVariables.mockReturnValue([]);
mockReplace.mockReturnValue('root.app.unknown');
const query = { ...baseQuery, prefixPath: ['root.app.${missing}'] } as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.prefixPath).toEqual(['root.app.unknown']);
expect(mockReplace).toHaveBeenCalledWith('root.app.${missing}', scopedVars);
});

it('should still replace condition and control fields', () => {
mockContainsTemplate.mockReturnValue(false);
mockReplace.mockImplementation((v: string) => v.replace('${threshold}', '100'));
const query = {
...baseQuery,
prefixPath: ['root.app.device1'],
condition: 'value > ${threshold}',
control: 'limit ${threshold}',
} as IoTDBQuery;

const result = ds.applyTemplateVariables(query, scopedVars);

expect(result.condition).toBe('value > 100');
expect(result.control).toBe('limit 100');
});
});

describe('applyTemplateVariables - SQL: Drop-down List', () => {
it('should replace groupBy and fillClauses fields', () => {
mockReplace.mockImplementation((v: string) => v.replace('${interval}', '1h'));
const query = {
sqlType: 'SQL: Drop-down List',
groupBy: { samplingInterval: '${interval}', step: '${interval}', groupByLevel: '1' },
fillClauses: 'previous',
} as unknown as IoTDBQuery;

const result = ds.applyTemplateVariables(query, {});

expect(result.groupBy?.samplingInterval).toBe('1h');
expect(result.groupBy?.step).toBe('1h');
});
});
});
73 changes: 69 additions & 4 deletions connectors/grafana-plugin/src/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,81 @@ export class DataSource extends DataSourceWithBackend<IoTDBQuery, IoTDBOptions>
this.username = instanceSettings.jsonData.username;
}
applyTemplateVariables(query: IoTDBQuery, scopedVars: ScopedVars) {
if (query.sqlType === 'SQL: Full Customized') {
if (!query.sqlType || query.sqlType === 'SQL: Full Customized') {
if (query.expression) {
query.expression.map(
(_, index) => (query.expression[index] = getTemplateSrv().replace(query.expression[index], scopedVars))
);
}
if (query.prefixPath) {
query.prefixPath.map(
(_, index) => (query.prefixPath[index] = getTemplateSrv().replace(query.prefixPath[index], scopedVars))
);
const expanded: string[] = [];
const templateSrv = getTemplateSrv();
const varPattern = /\$\{(\w+)(?::[^}]*)?\}|\$(\w+)\b/;
for (const path of query.prefixPath) {
if (varPattern.test(path)) {
const varMatch = path.match(/\$\{(\w+)(?::[^}]*)?\}|\$(\w+)\b/);
if (varMatch) {
const varName = varMatch[1] || varMatch[2];
const idx = varMatch.index!;
Comment on lines +44 to +48
const prefix = path.substring(0, idx);
const suffix = path.substring(idx + varMatch[0].length);
let values: string[] = [];
if (scopedVars && scopedVars[varName]) {
const val = scopedVars[varName].value;
if (val === '$__all') {
const allVars = templateSrv.getVariables() as any[];
const found = allVars.find((v: any) => v.name === varName);
if (found && found.options) {
values = found.options
.filter((o: any) => o.value !== '$__all')
.map((o: any) => o.value);
}
} else {
values = Array.isArray(val) ? val : [String(val)];
}
} else {
Comment on lines +52 to +65
const allVars = templateSrv.getVariables() as any[];
Comment thread
jixuan1989 marked this conversation as resolved.
const found = allVars.find((v: any) => v.name === varName);
if (found) {
const current = found.current;
if (current) {
if (Array.isArray(current.value)) {
values = current.value.filter((v: string) => v !== '$__all');
if (values.length === 0 && found.options) {
values = found.options
.filter((o: any) => o.value !== '$__all')
.map((o: any) => o.value);
}
} else if (current.value === '$__all') {
if (found.options) {
values = found.options
.filter((o: any) => o.value !== '$__all')
.map((o: any) => o.value);
}
} else {
values = [current.value];
}
}
}
if (values.length === 0) {
expanded.push(templateSrv.replace(path, scopedVars));
continue;
}
Comment on lines +89 to +92
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 04db426. The fallback now uses templateSrv.replace(path, scopedVars) on the whole path (not just the token), producing a single entry that preserves prior behavior when the variable cannot be resolved.

}
const resolvedSuffix = suffix && varPattern.test(suffix)
? templateSrv.replace(suffix, scopedVars)
: suffix;
for (const val of values) {
expanded.push(prefix + val + resolvedSuffix);
}
} else {
expanded.push(templateSrv.replace(path, scopedVars));
}
} else {
expanded.push(path);
}
}
query.prefixPath = expanded;
}

if (query.condition) {
Expand Down
Loading