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) {

Reply via email to