This is an automated email from the ASF dual-hosted git repository.

JackieTien97 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb-extras.git


The following commit(s) were added to refs/heads/master by this push:
     new d11e1c0  support dynamic IoTDB table-model variables (#126)
d11e1c0 is described below

commit d11e1c03d4a28dfcd90732bc3a47a0b3cbd38fe9
Author: Zhao Xinqi <[email protected]>
AuthorDate: Fri Aug 21 17:43:09 2026 +0800

    support dynamic IoTDB table-model variables (#126)
---
 .../pkg/plugin/iotdb_resource_handler.go           | 122 +++++-
 connectors/grafana-plugin/pkg/plugin/plugin.go     |  23 +-
 .../grafana-plugin/pkg/plugin/table_query.go       |  88 ++++-
 .../pkg/plugin/table_variable_test.go              | 411 +++++++++++++++++++++
 4 files changed, 623 insertions(+), 21 deletions(-)

diff --git a/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go 
b/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go
index f0de10f..9154ac3 100644
--- a/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go
+++ b/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go
@@ -20,20 +20,30 @@ package plugin
 import (
        "bytes"
        "encoding/json"
+       "errors"
        "io"
        "io/ioutil"
        "net/http"
+       "strings"
 
        "github.com/grafana/grafana-plugin-sdk-go/backend"
        "github.com/grafana/grafana-plugin-sdk-go/backend/log"
        "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
 )
 
-func iotdbResourceHandler(authorization string, httpClient *http.Client) 
backend.CallResourceHandler {
+// tableVariablePrefix marks a template-variable query as a table-model query.
+// A variable query of the form "table:<database>:<SQL>" is executed through 
the
+// IoTDB table-model RPC client instead of the legacy tree-model REST endpoint.
+const tableVariablePrefix = "table:"
+
+// iotdbResourceHandler wires the plugin resource endpoints. It is a method so
+// the table-model variable path can reach the datasource's native-client
+// session pool.
+func (d *IoTDBDataSource) iotdbResourceHandler(authorization string, 
httpClient *http.Client) backend.CallResourceHandler {
        mux := http.NewServeMux()
 
-       mux.Handle("/getVariables", getVariables(authorization, httpClient))
-       mux.Handle("/getNodes", getNodes(authorization, httpClient))
+       mux.Handle("/getVariables", d.getVariables(authorization, httpClient))
+       mux.Handle("/getNodes", d.getNodes(authorization, httpClient))
 
        return httpadapter.New(mux)
 }
@@ -43,7 +53,6 @@ type queryReq struct {
 }
 type nodeReq struct {
        Data []string `json:"data"`
-       Url  string   `json:"url"`
 }
 
 type queryResp struct {
@@ -51,22 +60,41 @@ type queryResp struct {
        Message string `json:"message"`
 }
 
-func getVariables(authorization string, httpClient *http.Client) http.Handler {
+func (d *IoTDBDataSource) getVariables(authorization string, httpClient 
*http.Client) http.Handler {
        fn := func(w http.ResponseWriter, r *http.Request) {
-               var url = r.FormValue("url")
-               var sql = r.FormValue("sql")
                if r.Method != http.MethodGet {
                        http.NotFound(w, r)
                        return
                }
+               var sql = r.FormValue("sql")
+
+               // table:<database>:<SQL> variables run through the table-model 
RPC
+               // client; every other query keeps the legacy tree-model 
behavior.
+               if strings.HasPrefix(strings.TrimSpace(sql), 
tableVariablePrefix) {
+                       d.handleTableVariableQuery(w, r, sql)
+                       return
+               }
+
                var queryReq = &queryReq{Sql: sql}
                qpJson, _ := json.Marshal(queryReq)
                reader := bytes.NewReader(qpJson)
                client := &http.Client{}
-               request, _ := http.NewRequest(http.MethodPost, 
url+"/grafana/v1/variable", reader)
+               // The tree-model endpoint is always the datasource's 
configured URL
+               // (d.Ulr), never the client-supplied "url" query parameter, so 
a caller
+               // cannot redirect this request to an arbitrary host (SSRF).
+               request, err := http.NewRequest(http.MethodPost, 
DataSourceUrlHandler(d.Ulr)+"/grafana/v1/variable", reader)
+               if err != nil {
+                       writeJSONError(w, http.StatusInternalServerError, 
err.Error())
+                       return
+               }
                request.Header.Set("Content-Type", "application/json")
                request.Header.Add("Authorization", authorization)
-               rsp, _ := client.Do(request)
+               rsp, err := client.Do(request)
+               if err != nil {
+                       log.DefaultLogger.Error("Data source is not working 
properly", err)
+                       writeJSONError(w, http.StatusInternalServerError, 
err.Error())
+                       return
+               }
                body, err := io.ReadAll(rsp.Body)
                if err != nil {
                        log.DefaultLogger.Error("Data source is not working 
properly", err)
@@ -108,7 +136,65 @@ func getVariables(authorization string, httpClient 
*http.Client) http.Handler {
        return http.HandlerFunc(fn)
 }
 
-func getNodes(authorization string, client *http.Client) http.Handler {
+// handleTableVariableQuery runs a table-model variable query and writes the
+// single-column result as a JSON string array (the shape Grafana's variable
+// dropdown expects), or a JSON error when parsing or execution fails.
+func (d *IoTDBDataSource) handleTableVariableQuery(w http.ResponseWriter, r 
*http.Request, query string) {
+       database, sql, err := parseTableVariableQuery(query)
+       if err != nil {
+               writeJSONError(w, http.StatusBadRequest, err.Error())
+               return
+       }
+       sql = d.expandVariableMacros(sql)
+       values, err := d.tableVariableValues(r.Context(), database, sql)
+       if err != nil {
+               log.DefaultLogger.Error("table-model variable query failed", 
"err", err)
+               writeJSONError(w, http.StatusInternalServerError, err.Error())
+               return
+       }
+       writeJSON(w, values)
+}
+
+// parseTableVariableQuery splits a table-model variable query of the form
+// "table:<database>:<SQL>" into its database and SQL parts. Both parts are
+// required; an empty or missing part is an error rather than a fallback to the
+// legacy path.
+func parseTableVariableQuery(query string) (database, sql string, err error) {
+       body := strings.TrimSpace(query)
+       if !strings.HasPrefix(body, tableVariablePrefix) {
+               return "", "", errors.New("table-model variable query must 
start with table:")
+       }
+       rest := body[len(tableVariablePrefix):]
+       separator := strings.IndexByte(rest, ':')
+       if separator < 0 {
+               return "", "", errors.New("table-model variable query is 
missing the database:SQL separator")
+       }
+       database = strings.TrimSpace(rest[:separator])
+       sql = strings.TrimSpace(rest[separator+1:])
+       if database == "" {
+               return "", "", errors.New("table-model variable query requires 
a database")
+       }
+       if sql == "" {
+               return "", "", errors.New("table-model variable query requires 
SQL")
+       }
+       return database, sql, nil
+}
+
+// writeJSON writes a value as a JSON response with the default 200 status.
+func writeJSON(w http.ResponseWriter, value interface{}) {
+       w.Header().Set("Content-Type", "application/json")
+       _ = json.NewEncoder(w).Encode(value)
+}
+
+// writeJSONError writes a machine-readable JSON error with an explicit HTTP
+// status so a failed variable query is never mistaken for an empty result.
+func writeJSONError(w http.ResponseWriter, status int, message string) {
+       w.Header().Set("Content-Type", "application/json")
+       w.WriteHeader(status)
+       _ = json.NewEncoder(w).Encode(queryResp{Code: status, Message: message})
+}
+
+func (d *IoTDBDataSource) getNodes(authorization string, client *http.Client) 
http.Handler {
        fn := func(w http.ResponseWriter, r *http.Request) {
                s, _ := ioutil.ReadAll(r.Body)
                if r.Method != http.MethodPost {
@@ -124,10 +210,22 @@ func getNodes(authorization string, client *http.Client) 
http.Handler {
                qpJson, _ := json.Marshal(nodeReq.Data)
                reader := bytes.NewReader(qpJson)
 
-               request, _ := http.NewRequest(http.MethodPost, 
nodeReq.Url+"/grafana/v1/node", reader)
+               // The node endpoint is always the datasource's configured URL 
(d.Ulr),
+               // never a client-supplied URL, so a caller cannot redirect 
this request
+               // to an arbitrary host (SSRF).
+               request, err := http.NewRequest(http.MethodPost, 
DataSourceUrlHandler(d.Ulr)+"/grafana/v1/node", reader)
+               if err != nil {
+                       http.Error(w, err.Error(), 
http.StatusInternalServerError)
+                       return
+               }
                request.Header.Set("Content-Type", "application/json")
                request.Header.Add("Authorization", authorization)
-               rsp, _ := client.Do(request)
+               rsp, err := client.Do(request)
+               if err != nil {
+                       log.DefaultLogger.Error("Data source is not working 
properly", err)
+                       http.Error(w, err.Error(), 
http.StatusInternalServerError)
+                       return
+               }
                body, err := io.ReadAll(rsp.Body)
                if err != nil {
                        log.DefaultLogger.Error("Data source is not working 
properly", err)
diff --git a/connectors/grafana-plugin/pkg/plugin/plugin.go 
b/connectors/grafana-plugin/pkg/plugin/plugin.go
index 8e527c4..e31e048 100644
--- a/connectors/grafana-plugin/pkg/plugin/plugin.go
+++ b/connectors/grafana-plugin/pkg/plugin/plugin.go
@@ -73,7 +73,17 @@ func ApacheIoTDBDatasource(ctx context.Context, d 
backend.DataSourceInstanceSett
                authorization = "Basic " + 
base64.StdEncoding.EncodeToString([]byte(dm.Username+":"+password))
        }
        password := d.DecryptedSecureJSONData["password"]
-       return &IoTDBDataSource{CallResourceHandler: 
iotdbResourceHandler(authorization, httpClient), Username: dm.Username, Ulr: 
dm.Url, RPCAddress: dm.RPCAddress, password: password, httpClient: httpClient}, 
nil
+       ds := &IoTDBDataSource{
+               Username:   dm.Username,
+               Ulr:        dm.Url,
+               RPCAddress: dm.RPCAddress,
+               password:   password,
+               httpClient: httpClient,
+       }
+       // The resource handler is a method so the table-model variable path can
+       // reach the native-client session pool.
+       ds.CallResourceHandler = ds.iotdbResourceHandler(authorization, 
httpClient)
+       return ds, nil
 }
 
 // SampleDatasource is an example datasource which can respond to data 
queries, reports
@@ -90,9 +100,20 @@ type IoTDBDataSource struct {
        // getTablePool on the first table query.
        tablePoolMu sync.Mutex
        tablePool   *client.TableSessionPool
+
        // tableQueryRunner is replaceable in tests so queryTableModel's 
response
        // behavior can be exercised without a live IoTDB RPC service.
        tableQueryRunner func(context.Context, *queryParam) 
(*tableQueryDataSet, error)
+
+       // tableExecutor runs a table-model statement and returns the fetched
+       // dataset. A nil value selects the real RPC executor; tests substitute 
a
+       // fake to exercise the variable-query path without a live server.
+       tableExecutor func(ctx context.Context, database, sql string) 
(*tableQueryDataSet, error)
+
+       // now returns the current instant used to compute the node-liveness 
window
+       // for table-model template-variable queries. A nil value selects 
time.Now;
+       // tests substitute a fixed clock so the window is deterministic.
+       now func() time.Time
 }
 
 // Dispose here tells plugin SDK that plugin wants to clean up resources when 
a new instance
diff --git a/connectors/grafana-plugin/pkg/plugin/table_query.go 
b/connectors/grafana-plugin/pkg/plugin/table_query.go
index 1a5c955..b03e3cc 100644
--- a/connectors/grafana-plugin/pkg/plugin/table_query.go
+++ b/connectors/grafana-plugin/pkg/plugin/table_query.go
@@ -80,8 +80,20 @@ var (
        intervalMSRe = regexp.MustCompile(`\$__interval_ms`)
 )
 
+// activeFromRe matches $__activeFrom, the lower bound of the node-liveness
+// window used by table-model template-variable queries. It is a variable-path
+// macro (not a panel-query macro) and is expanded by expandVariableMacros.
+var activeFromRe = regexp.MustCompile(`\$__activeFrom\b`)
+
 const invalidIntervalMacroMessage = "Grafana query interval must be positive 
when $__interval or $__interval_ms is used"
 
+// nodeActiveTTL is the template-variable liveness window. A node is considered
+// active only if its most recent sample falls within the last nodeActiveTTL.
+// The bridge writes samples with their Prometheus scrape timestamp, so this is
+// "the node produced a scrape within the last nodeActiveTTL"; a node that 
stops
+// producing samples ages out of the window and disappears from the variable.
+const nodeActiveTTL = 5 * time.Minute
+
 // formatTimeLiteral renders a panel-range bound as an ISO 8601 UTC timestamp
 // literal (e.g. 2020-09-13T12:26:40.000+00:00). The server parses such a
 // literal in its own configured timestamp precision, so the expansion works
@@ -91,6 +103,26 @@ func formatTimeLiteral(ms int64) string {
        return time.UnixMilli(ms).UTC().Format("2006-01-02T15:04:05.000") + 
"+00:00"
 }
 
+// currentTime returns the injectable clock, or the real wall clock when no
+// clock is configured.
+func (d *IoTDBDataSource) currentTime() time.Time {
+       if d.now != nil {
+               return d.now()
+       }
+       return time.Now()
+}
+
+// expandVariableMacros rewrites the template-variable-only macros before a
+// table-model variable query runs. $__activeFrom becomes the "now - TTL"
+// instant rendered as an ISO 8601 UTC timestamp literal — the same literal 
form
+// panel queries receive for $__timeFrom — so a variable query can restrict its
+// result to recently active nodes without the server evaluating a time
+// function of its own.
+func (d *IoTDBDataSource) expandVariableMacros(sql string) string {
+       from := d.currentTime().Add(-nodeActiveTTL).UnixMilli()
+       return activeFromRe.ReplaceAllString(sql, formatTimeLiteral(from))
+}
+
 // expandTableMacros rewrites the Grafana time and interval macros a dashboard
 // author can put in table-model SQL.
 //
@@ -335,6 +367,23 @@ func (d *IoTDBDataSource) queryTableModel(ctx 
context.Context, qp *queryParam) b
 // executeTableQuery runs and fetches one table-model query. queryTableModel
 // owns response semantics so the same zero-row path is covered in tests.
 func (d *IoTDBDataSource) executeTableQuery(ctx context.Context, qp 
*queryParam) (*tableQueryDataSet, error) {
+       sql, err := expandTableMacros(qp.Sql, qp.StartTime, qp.EndTime, 
qp.IntervalMS)
+       if err != nil {
+               return nil, err
+       }
+       return d.executeTableStatement(ctx, qp.Database, sql)
+}
+
+// executeTableStatement runs a table-model SQL statement against the given
+// database on a pooled native-client session and returns the fetched dataset.
+// The database is USEd on the session so a statement's table references 
resolve
+// against it regardless of any session state left by earlier queries. When
+// tableExecutor is non-nil it is used instead of the real RPC path.
+func (d *IoTDBDataSource) executeTableStatement(ctx context.Context, database, 
sql string) (*tableQueryDataSet, error) {
+       if d.tableExecutor != nil {
+               return d.tableExecutor(ctx, database, sql)
+       }
+
        pool, err := d.getTablePool()
        if err != nil {
                return nil, err
@@ -350,8 +399,8 @@ func (d *IoTDBDataSource) executeTableQuery(ctx 
context.Context, qp *queryParam)
                }
        }()
 
-       if database := strings.TrimSpace(qp.Database); database != "" {
-               if err := session.ExecuteNonQueryStatement("USE " + 
quoteTableIdentifier(database)); err != nil {
+       if db := strings.TrimSpace(database); db != "" {
+               if err := session.ExecuteNonQueryStatement("USE " + 
quoteTableIdentifier(db)); err != nil {
                        return nil, err
                }
        }
@@ -362,10 +411,6 @@ func (d *IoTDBDataSource) executeTableQuery(ctx 
context.Context, qp *queryParam)
                        timeout = ms
                }
        }
-       sql, err := expandTableMacros(qp.Sql, qp.StartTime, qp.EndTime, 
qp.IntervalMS)
-       if err != nil {
-               return nil, err
-       }
        resultSet, err := session.ExecuteQueryStatement(sql, &timeout)
        if err != nil {
                return nil, err
@@ -376,11 +421,38 @@ func (d *IoTDBDataSource) executeTableQuery(ctx 
context.Context, qp *queryParam)
                }
        }()
 
-       dataSet, err := fetchTableDataSet(resultSet)
+       return fetchTableDataSet(resultSet)
+}
+
+// tableVariableValues runs a table-model template-variable query and returns
+// its single string column as an ordered, NULL-free slice for Grafana's
+// variable dropdown.
+func (d *IoTDBDataSource) tableVariableValues(ctx context.Context, database, 
sql string) ([]string, error) {
+       dataSet, err := d.executeTableStatement(ctx, database, sql)
        if err != nil {
                return nil, err
        }
-       return dataSet, nil
+       return tableVariableStrings(dataSet)
+}
+
+// tableVariableStrings extracts the values of the single column a template
+// variable query must project. NULL cells are skipped and non-string cells are
+// rendered with the table-mode string coercion, preserving server order.
+func tableVariableStrings(dataSet *tableQueryDataSet) ([]string, error) {
+       if len(dataSet.ColumnNames) != 1 {
+               return nil, fmt.Errorf("template variable query must project 
exactly one column, got %d", len(dataSet.ColumnNames))
+       }
+       values := make([]string, 0, len(dataSet.Values))
+       for _, row := range dataSet.Values {
+               if len(row) == 0 {
+                       continue
+               }
+               if row[0] == nil {
+                       continue
+               }
+               values = append(values, toString(row[0]))
+       }
+       return values, nil
 }
 
 // buildTableResponseFrame turns a fetched dataset into the response frame,
diff --git a/connectors/grafana-plugin/pkg/plugin/table_variable_test.go 
b/connectors/grafana-plugin/pkg/plugin/table_variable_test.go
new file mode 100644
index 0000000..b444c32
--- /dev/null
+++ b/connectors/grafana-plugin/pkg/plugin/table_variable_test.go
@@ -0,0 +1,411 @@
+/*
+ * 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.
+ */
+
+package plugin
+
+import (
+       "context"
+       "encoding/base64"
+       "encoding/json"
+       "errors"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "net/url"
+       "reflect"
+       "strings"
+       "testing"
+       "time"
+)
+
+func TestParseTableVariableQuery(t *testing.T) {
+       database, sql, err := 
parseTableVariableQuery("table:metrics_validation:SELECT DISTINCT instance FROM 
sys_cpu_cores")
+       if err != nil {
+               t.Fatalf("valid query rejected: %v", err)
+       }
+       if database != "metrics_validation" {
+               t.Fatalf("database = %q, want metrics_validation", database)
+       }
+       if sql != "SELECT DISTINCT instance FROM sys_cpu_cores" {
+               t.Fatalf("sql = %q", sql)
+       }
+}
+
+func TestParseTableVariableQueryTrimsWhitespace(t *testing.T) {
+       database, sql, err := parseTableVariableQuery("  table: 
metrics_validation : SELECT 1 ")
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if database != "metrics_validation" || sql != "SELECT 1" {
+               t.Fatalf("database = %q, sql = %q", database, sql)
+       }
+}
+
+func TestParseTableVariableQueryRejectsNonTableQuery(t *testing.T) {
+       if _, _, err := parseTableVariableQuery("SELECT 1"); err == nil {
+               t.Fatalf("non-table query should not parse as a table query")
+       }
+}
+
+func TestParseTableVariableQueryRejectsEmptyDatabase(t *testing.T) {
+       if _, _, err := parseTableVariableQuery("table::SELECT 1"); err == nil 
|| !strings.Contains(err.Error(), "database") {
+               t.Fatalf("empty database error = %v", err)
+       }
+}
+
+func TestParseTableVariableQueryRejectsEmptySQL(t *testing.T) {
+       if _, _, err := parseTableVariableQuery("table:metrics_validation:"); 
err == nil || !strings.Contains(err.Error(), "SQL") {
+               t.Fatalf("empty SQL error = %v", err)
+       }
+}
+
+func TestParseTableVariableQueryRejectsMissingSeparator(t *testing.T) {
+       if _, _, err := parseTableVariableQuery("table:metrics_validation"); 
err == nil {
+               t.Fatalf("missing separator should be an error")
+       }
+}
+
+func TestTableVariableStringsSingleColumnPreservesOrder(t *testing.T) {
+       dataSet := &tableQueryDataSet{
+               ColumnNames: []string{"instance"},
+               DataTypes:   []string{"STRING"},
+               Values: [][]interface{}{
+                       {"node-a.example.test:9091"},
+                       {"node-b.example.test:9091"},
+                       {"node-c.example.test:9091"},
+               },
+       }
+       got, err := tableVariableStrings(dataSet)
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       want := []string{"node-a.example.test:9091", 
"node-b.example.test:9091", "node-c.example.test:9091"}
+       if !reflect.DeepEqual(got, want) {
+               t.Fatalf("values = %#v, want %#v", got, want)
+       }
+}
+
+func TestTableVariableStringsSkipsNulls(t *testing.T) {
+       dataSet := &tableQueryDataSet{
+               ColumnNames: []string{"instance"},
+               DataTypes:   []string{"STRING"},
+               Values: [][]interface{}{
+                       {"a"},
+                       {nil},
+                       {"b"},
+               },
+       }
+       got, err := tableVariableStrings(dataSet)
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if !reflect.DeepEqual(got, []string{"a", "b"}) {
+               t.Fatalf("values = %#v, want [a b]", got)
+       }
+}
+
+func TestTableVariableStringsCoercesNonStrings(t *testing.T) {
+       dataSet := &tableQueryDataSet{
+               ColumnNames: []string{"node_num"},
+               DataTypes:   []string{"INT64"},
+               Values: [][]interface{}{
+                       {int64(7)},
+               },
+       }
+       got, err := tableVariableStrings(dataSet)
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if !reflect.DeepEqual(got, []string{"7"}) {
+               t.Fatalf("values = %#v, want [7]", got)
+       }
+}
+
+func TestTableVariableStringsRejectsMultiColumn(t *testing.T) {
+       dataSet := &tableQueryDataSet{
+               ColumnNames: []string{"cluster", "instance"},
+               DataTypes:   []string{"STRING", "STRING"},
+               Values:      [][]interface{}{{"a", "b"}},
+       }
+       if _, err := tableVariableStrings(dataSet); err == nil || 
!strings.Contains(err.Error(), "exactly one column") {
+               t.Fatalf("multi-column error = %v", err)
+       }
+}
+
+func TestTableVariableValuesCallsExecutor(t *testing.T) {
+       var gotDB, gotSQL string
+       d := &IoTDBDataSource{
+               tableExecutor: func(ctx context.Context, database, sql string) 
(*tableQueryDataSet, error) {
+                       gotDB, gotSQL = database, sql
+                       return &tableQueryDataSet{
+                               ColumnNames: []string{"instance"},
+                               DataTypes:   []string{"STRING"},
+                               Values:      
[][]interface{}{{"node-a.example.test:9091"}},
+                       }, nil
+               },
+       }
+       values, err := d.tableVariableValues(context.Background(), 
"metrics_validation", "SELECT DISTINCT instance FROM sys_cpu_cores")
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if gotDB != "metrics_validation" {
+               t.Fatalf("executor database = %q, want metrics_validation", 
gotDB)
+       }
+       if gotSQL != "SELECT DISTINCT instance FROM sys_cpu_cores" {
+               t.Fatalf("executor sql = %q", gotSQL)
+       }
+       if !reflect.DeepEqual(values, []string{"node-a.example.test:9091"}) {
+               t.Fatalf("values = %#v", values)
+       }
+}
+
+func TestTableVariableValuesPropagatesExecutorError(t *testing.T) {
+       d := &IoTDBDataSource{
+               tableExecutor: func(ctx context.Context, database, sql string) 
(*tableQueryDataSet, error) {
+                       return nil, errors.New("connection refused")
+               },
+       }
+       if _, err := d.tableVariableValues(context.Background(), "db", "SELECT 
1"); err == nil || !strings.Contains(err.Error(), "connection refused") {
+               t.Fatalf("error = %v, want connection refused", err)
+       }
+}
+
+func TestGetVariablesTableModelQuery(t *testing.T) {
+       var gotDB, gotSQL string
+       d := &IoTDBDataSource{
+               tableExecutor: func(ctx context.Context, database, sql string) 
(*tableQueryDataSet, error) {
+                       gotDB, gotSQL = database, sql
+                       return &tableQueryDataSet{
+                               ColumnNames: []string{"instance"},
+                               DataTypes:   []string{"STRING"},
+                               Values: [][]interface{}{
+                                       {"node-a.example.test:9091"},
+                                       {nil},
+                                       {"node-b.example.test:9091"},
+                               },
+                       }, nil
+               },
+       }
+       handler := d.getVariables("", http.DefaultClient)
+       request := httptest.NewRequest(http.MethodGet, 
"/getVariables?url="+url.QueryEscape("http://iotdb:18080";)+"&sql="+url.QueryEscape("table:metrics_validation:SELECT
 DISTINCT instance FROM sys_cpu_cores"), nil)
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, request)
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, want 200; body = %s", recorder.Code, 
recorder.Body.String())
+       }
+       if gotDB != "metrics_validation" || gotSQL != "SELECT DISTINCT instance 
FROM sys_cpu_cores" {
+               t.Fatalf("executor got database %q, sql %q", gotDB, gotSQL)
+       }
+       var values []string
+       if err := json.Unmarshal(recorder.Body.Bytes(), &values); err != nil {
+               t.Fatalf("response is not a JSON array: %v; body = %s", err, 
recorder.Body.String())
+       }
+       if !reflect.DeepEqual(values, []string{"node-a.example.test:9091", 
"node-b.example.test:9091"}) {
+               t.Fatalf("values = %#v", values)
+       }
+}
+
+func TestGetVariablesTableModelMultiColumnError(t *testing.T) {
+       d := &IoTDBDataSource{
+               tableExecutor: func(ctx context.Context, database, sql string) 
(*tableQueryDataSet, error) {
+                       return &tableQueryDataSet{
+                               ColumnNames: []string{"cluster", "instance"},
+                               DataTypes:   []string{"STRING", "STRING"},
+                               Values:      [][]interface{}{{"a", "b"}},
+                       }, nil
+               },
+       }
+       handler := d.getVariables("", http.DefaultClient)
+       request := httptest.NewRequest(http.MethodGet, 
"/getVariables?url=x&sql="+url.QueryEscape("table:db:SELECT cluster, instance 
FROM t"), nil)
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, request)
+
+       if recorder.Code != http.StatusInternalServerError {
+               t.Fatalf("status = %d, want 500", recorder.Code)
+       }
+       var body queryResp
+       if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
+               t.Fatalf("error body is not JSON: %v; body = %s", err, 
recorder.Body.String())
+       }
+       if body.Message == "" || !strings.Contains(body.Message, "exactly one 
column") {
+               t.Fatalf("error message = %q", body.Message)
+       }
+}
+
+func TestGetVariablesTableModelParseError(t *testing.T) {
+       d := &IoTDBDataSource{}
+       handler := d.getVariables("", http.DefaultClient)
+       request := httptest.NewRequest(http.MethodGet, 
"/getVariables?url=x&sql="+url.QueryEscape("table:metrics_validation:"), nil)
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, request)
+
+       if recorder.Code != http.StatusBadRequest {
+               t.Fatalf("status = %d, want 400", recorder.Code)
+       }
+}
+
+func TestGetVariablesLegacyPath(t *testing.T) {
+       var gotPath, gotBody string
+       legacy := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               gotPath = r.URL.Path
+               var incoming queryReq
+               _ = json.NewDecoder(r.Body).Decode(&incoming)
+               gotBody = incoming.Sql
+               w.Header().Set("Content-Type", "application/json")
+               _, _ = w.Write([]byte(`["a","b"]`))
+       }))
+       defer legacy.Close()
+
+       // The request goes to the datasource's configured URL (d.Ulr); the
+       // client-supplied "url" query parameter is ignored so a caller cannot
+       // redirect the request (SSRF).
+       d := &IoTDBDataSource{Ulr: legacy.URL}
+       handler := d.getVariables("Bearer test", http.DefaultClient)
+       request := httptest.NewRequest(http.MethodGet, 
"/getVariables?url="+url.QueryEscape("http://169.254.169.254";)+"&sql="+url.QueryEscape("show
 timeseries"), nil)
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, request)
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, want 200", recorder.Code)
+       }
+       if gotPath != "/grafana/v1/variable" {
+               t.Fatalf("legacy path = %q, want /grafana/v1/variable", gotPath)
+       }
+       if gotBody != "show timeseries" {
+               t.Fatalf("legacy body = %q, want show timeseries", gotBody)
+       }
+       var values []string
+       if err := json.Unmarshal(recorder.Body.Bytes(), &values); err != nil || 
!reflect.DeepEqual(values, []string{"a", "b"}) {
+               t.Fatalf("legacy response = %s (err %v)", 
recorder.Body.String(), err)
+       }
+}
+
+func TestTableVariableQueryDoesNotLeakCredentials(t *testing.T) {
+       const secret = "sup3r-s3cret-password"
+       authorization := "Basic " + 
base64.StdEncoding.EncodeToString([]byte("root:"+secret))
+       d := &IoTDBDataSource{
+               password: secret,
+               tableExecutor: func(ctx context.Context, database, sql string) 
(*tableQueryDataSet, error) {
+                       return &tableQueryDataSet{
+                               ColumnNames: []string{"instance"},
+                               DataTypes:   []string{"STRING"},
+                               Values:      
[][]interface{}{{"node-a.example.test:9091"}},
+                       }, nil
+               },
+       }
+       handler := d.getVariables(authorization, http.DefaultClient)
+       request := httptest.NewRequest(http.MethodGet, 
"/getVariables?url=x&sql="+url.QueryEscape("table:db:SELECT DISTINCT instance 
FROM t"), nil)
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, request)
+
+       body := recorder.Body.String()
+       if strings.Contains(body, secret) {
+               t.Fatalf("response leaked the password: %s", body)
+       }
+       if strings.Contains(body, authorization) {
+               t.Fatalf("response leaked the authorization header: %s", body)
+       }
+       var values []string
+       if err := json.Unmarshal(recorder.Body.Bytes(), &values); err != nil || 
!reflect.DeepEqual(values, []string{"node-a.example.test:9091"}) {
+               t.Fatalf("response = %s (err %v)", body, err)
+       }
+}
+
+func TestExpandVariableMacrosActiveFrom(t *testing.T) {
+       fixed := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
+       d := &IoTDBDataSource{now: func() time.Time { return fixed }}
+       sql := "SELECT DISTINCT instance FROM sys_cpu_cores WHERE time >= 
$__activeFrom AND instance <> ''"
+       got := d.expandVariableMacros(sql)
+       if strings.Contains(got, "$__activeFrom") {
+               t.Fatalf("macro left unexpanded: %s", got)
+       }
+       want := formatTimeLiteral(fixed.Add(-nodeActiveTTL).UnixMilli())
+       if !strings.Contains(got, "time >= "+want) {
+               t.Fatalf("expanded SQL = %q, want lower bound %q", got, want)
+       }
+}
+
+func TestExpandVariableMacrosLeavesOtherSQLAlone(t *testing.T) {
+       d := &IoTDBDataSource{now: func() time.Time { return time.UnixMilli(0) 
}}
+       sql := "SELECT DISTINCT cluster FROM sys_cpu_cores WHERE cluster <> ''"
+       if got := d.expandVariableMacros(sql); got != sql {
+               t.Fatalf("SQL without $__activeFrom changed: %q", got)
+       }
+}
+
+func TestHandleTableVariableQueryExpandsActiveFrom(t *testing.T) {
+       fixed := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
+       var gotSQL string
+       d := &IoTDBDataSource{
+               now: func() time.Time { return fixed },
+               tableExecutor: func(ctx context.Context, database, sql string) 
(*tableQueryDataSet, error) {
+                       gotSQL = sql
+                       return &tableQueryDataSet{
+                               ColumnNames: []string{"instance"},
+                               DataTypes:   []string{"STRING"},
+                               Values:      
[][]interface{}{{"node-a.example.test:9091"}},
+                       }, nil
+               },
+       }
+       handler := d.getVariables("", http.DefaultClient)
+       request := httptest.NewRequest(http.MethodGet, 
"/getVariables?url=x&sql="+url.QueryEscape("table:db:SELECT DISTINCT instance 
FROM t WHERE time >= $__activeFrom"), nil)
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, request)
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, want 200; body = %s", recorder.Code, 
recorder.Body.String())
+       }
+       if strings.Contains(gotSQL, "$__activeFrom") {
+               t.Fatalf("executor received unexpanded macro: %q", gotSQL)
+       }
+       want := formatTimeLiteral(fixed.Add(-nodeActiveTTL).UnixMilli())
+       if !strings.Contains(gotSQL, "time >= "+want) {
+               t.Fatalf("executor SQL = %q, want lower bound %q", gotSQL, want)
+       }
+}
+
+func TestGetNodesUsesConfiguredURL(t *testing.T) {
+       var gotPath, gotBody string
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               gotPath = r.URL.Path
+               body, _ := io.ReadAll(r.Body)
+               gotBody = string(body)
+               w.Header().Set("Content-Type", "application/json")
+               _, _ = w.Write([]byte(`["root.sg"]`))
+       }))
+       defer server.Close()
+
+       // The node endpoint goes to the datasource's configured URL (d.Ulr); 
the
+       // client-supplied "url" field is ignored so a caller cannot redirect 
the
+       // request (SSRF).
+       d := &IoTDBDataSource{Ulr: server.URL}
+       handler := d.getNodes("", http.DefaultClient)
+       request := httptest.NewRequest(http.MethodPost, "/getNodes", 
strings.NewReader(`{"data":["root.sg"],"url":"http://169.254.169.254"}`))
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, request)
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, want 200; body = %s", recorder.Code, 
recorder.Body.String())
+       }
+       if gotPath != "/grafana/v1/node" {
+               t.Fatalf("node path = %q, want /grafana/v1/node", gotPath)
+       }
+       if gotBody != `["root.sg"]` {
+               t.Fatalf("node body = %q, want [\"root.sg\"]", gotBody)
+       }
+}

Reply via email to