This is an automated email from the ASF dual-hosted git repository. jixuan1989 pushed a commit to branch fix/grafana-expand-multi-value-prefixpath in repository https://gitbox.apache.org/repos/asf/iotdb-extras.git
commit c977150dc5e66fb5c8c86af792ebe8712c3536c6 Author: xiangdong huang <[email protected]> AuthorDate: Fri May 22 13:32:14 2026 +0800 Expand multi-value template variables in prefixPath into multiple paths When a multi-value Grafana template variable is used in prefixPath (e.g. root.application.${device}), the plugin now expands it into multiple valid IoTDB paths instead of producing an invalid concatenated path. This enables the common 'global variable filter' dashboard pattern where a single multi-select dropdown controls all panels. Closes #108 --- connectors/grafana-plugin/README.md | 21 +++ connectors/grafana-plugin/src/datasource.test.ts | 156 +++++++++++++++++++++++ connectors/grafana-plugin/src/datasource.ts | 16 ++- 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/connectors/grafana-plugin/README.md b/connectors/grafana-plugin/README.md index ce4f07b..974916b 100644 --- a/connectors/grafana-plugin/README.md +++ b/connectors/grafana-plugin/README.md @@ -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"> diff --git a/connectors/grafana-plugin/src/datasource.test.ts b/connectors/grafana-plugin/src/datasource.test.ts new file mode 100644 index 0000000..3f7ea71 --- /dev/null +++ b/connectors/grafana-plugin/src/datasource.test.ts @@ -0,0 +1,156 @@ +/* + * 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(); + +jest.mock('@grafana/runtime', () => ({ + DataSourceWithBackend: class {}, + getTemplateSrv: () => ({ + replace: mockReplace, + containsTemplate: mockContainsTemplate, + }), +})); + +describe('DataSource', () => { + let ds: DataSource; + + beforeEach(() => { + ds = new DataSource({ jsonData: { url: 'http://localhost:6667', username: 'root' } } as any); + mockReplace.mockReset(); + mockContainsTemplate.mockReset(); + }); + + 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); + mockReplace.mockReturnValue('root.app.device1'); + const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as IoTDBQuery; + + const result = ds.applyTemplateVariables(query, scopedVars); + + expect(result.prefixPath).toEqual(['root.app.device1']); + expect(mockReplace).toHaveBeenCalledWith('root.app.${device}', scopedVars, 'pipe'); + }); + + it('should expand multi-value variable into multiple paths', () => { + mockContainsTemplate.mockReturnValue(true); + mockReplace.mockReturnValue('root.app.device1|root.app.device2|root.app.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']); + }); + + it('should handle mixed literal and template paths', () => { + mockContainsTemplate.mockImplementation((path: string) => path.includes('${')); + mockReplace.mockReturnValue('root.app.device1|root.app.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']); + }); + + it('should handle multiple template paths each with multi-value variables', () => { + mockContainsTemplate.mockReturnValue(true); + mockReplace + .mockReturnValueOnce('root.a.d1|root.a.d2') + .mockReturnValueOnce('root.b.d3|root.b.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']); + }); + + 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 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'); + }); + }); +}); diff --git a/connectors/grafana-plugin/src/datasource.ts b/connectors/grafana-plugin/src/datasource.ts index df71085..8922495 100644 --- a/connectors/grafana-plugin/src/datasource.ts +++ b/connectors/grafana-plugin/src/datasource.ts @@ -37,9 +37,19 @@ export class DataSource extends DataSourceWithBackend<IoTDBQuery, IoTDBOptions> ); } if (query.prefixPath) { - query.prefixPath.map( - (_, index) => (query.prefixPath[index] = getTemplateSrv().replace(query.prefixPath[index], scopedVars)) - ); + const expanded: string[] = []; + for (const path of query.prefixPath) { + if (getTemplateSrv().containsTemplate(path)) { + const replaced = getTemplateSrv().replace(path, scopedVars, 'pipe'); + const values = replaced.split('|'); + for (const val of values) { + expanded.push(val); + } + } else { + expanded.push(path); + } + } + query.prefixPath = expanded; } if (query.condition) {
