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 608ec36   support legendFormat and show "No data" on empty results 
(#123)
608ec36 is described below

commit 608ec36bae0bc39b2b829dd8d6a8640d71453fcb
Author: Zhao Xinqi <[email protected]>
AuthorDate: Fri Aug 14 15:44:15 2026 +0800

     support legendFormat and show "No data" on empty results (#123)
---
 connectors/grafana-plugin/pkg/plugin/plugin.go     |   4 +
 .../grafana-plugin/pkg/plugin/table_query.go       | 146 +++++++-
 .../grafana-plugin/pkg/plugin/table_query_test.go  | 375 ++++++++++++++++++++-
 connectors/grafana-plugin/src/QueryEditor.tsx      |  19 ++
 connectors/grafana-plugin/src/types.ts             |   1 +
 5 files changed, 527 insertions(+), 18 deletions(-)

diff --git a/connectors/grafana-plugin/pkg/plugin/plugin.go 
b/connectors/grafana-plugin/pkg/plugin/plugin.go
index bc256cd..8e527c4 100644
--- a/connectors/grafana-plugin/pkg/plugin/plugin.go
+++ b/connectors/grafana-plugin/pkg/plugin/plugin.go
@@ -90,6 +90,9 @@ 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)
 }
 
 // Dispose here tells plugin SDK that plugin wants to clean up resources when 
a new instance
@@ -158,6 +161,7 @@ type queryParam struct {
        Sql          string   `json:"sql"`
        Format       string   `json:"format"`
        IntervalMS   int64    `json:"-"`
+       LegendFormat string   `json:"legendFormat"`
 }
 
 type QueryDataReq struct {
diff --git a/connectors/grafana-plugin/pkg/plugin/table_query.go 
b/connectors/grafana-plugin/pkg/plugin/table_query.go
index 7c115ae..1a5c955 100644
--- a/connectors/grafana-plugin/pkg/plugin/table_query.go
+++ b/connectors/grafana-plugin/pkg/plugin/table_query.go
@@ -310,16 +310,39 @@ func (d *IoTDBDataSource) queryTableModel(ctx 
context.Context, qp *queryParam) b
                return response
        }
 
-       pool, err := d.getTablePool()
+       runner := d.tableQueryRunner
+       if runner == nil {
+               runner = d.executeTableQuery
+       }
+       dataSet, err := runner(ctx, qp)
        if err != nil {
                response.Error = err
                return response
        }
+
+       if !strings.EqualFold(qp.Format, tableFormatTable) && 
!hasPlottableValue(dataSet) {
+               // Time Series with no plottable values — zero rows, or rows 
whose value
+               // columns are all NULL (a HOP/rate query over sparse data 
returns NULL
+               // windows) — has no frame so Grafana shows "No data" instead 
of bare
+               // axes. Table format keeps the empty frame so column headers 
stay visible.
+               return response
+       }
+
+       response.Frames = append(response.Frames, 
buildTableResponseFrame(dataSet, qp.Format, qp.LegendFormat))
+       return response
+}
+
+// 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) {
+       pool, err := d.getTablePool()
+       if err != nil {
+               return nil, err
+       }
        session, err := pool.GetSession()
        if err != nil {
-               response.Error = fmt.Errorf("cannot connect to the IoTDB RPC 
service: %w", err)
                log.DefaultLogger.Error("Cannot connect to the IoTDB RPC 
service", "err", err)
-               return response
+               return nil, fmt.Errorf("cannot connect to the IoTDB RPC 
service: %w", err)
        }
        defer func() {
                if closeErr := session.Close(); closeErr != nil {
@@ -329,8 +352,7 @@ func (d *IoTDBDataSource) queryTableModel(ctx 
context.Context, qp *queryParam) b
 
        if database := strings.TrimSpace(qp.Database); database != "" {
                if err := session.ExecuteNonQueryStatement("USE " + 
quoteTableIdentifier(database)); err != nil {
-                       response.Error = err
-                       return response
+                       return nil, err
                }
        }
 
@@ -342,13 +364,11 @@ func (d *IoTDBDataSource) queryTableModel(ctx 
context.Context, qp *queryParam) b
        }
        sql, err := expandTableMacros(qp.Sql, qp.StartTime, qp.EndTime, 
qp.IntervalMS)
        if err != nil {
-               response.Error = err
-               return response
+               return nil, err
        }
        resultSet, err := session.ExecuteQueryStatement(sql, &timeout)
        if err != nil {
-               response.Error = err
-               return response
+               return nil, err
        }
        defer func() {
                if closeErr := resultSet.Close(); closeErr != nil {
@@ -358,12 +378,9 @@ func (d *IoTDBDataSource) queryTableModel(ctx 
context.Context, qp *queryParam) b
 
        dataSet, err := fetchTableDataSet(resultSet)
        if err != nil {
-               response.Error = err
-               return response
+               return nil, err
        }
-
-       response.Frames = append(response.Frames, 
buildTableResponseFrame(dataSet, qp.Format))
-       return response
+       return dataSet, nil
 }
 
 // buildTableResponseFrame turns a fetched dataset into the response frame,
@@ -374,7 +391,7 @@ func (d *IoTDBDataSource) queryTableModel(ctx 
context.Context, qp *queryParam) b
 // lines instead of one interleaved series; when the pivot does not apply (or
 // fails, e.g. on a null timestamp) the plain frame is returned. The Table
 // format preserves the server's row order untouched.
-func buildTableResponseFrame(dataSet *tableQueryDataSet, format string) 
*data.Frame {
+func buildTableResponseFrame(dataSet *tableQueryDataSet, format string, 
legendFormat string) *data.Frame {
        // Anything that is not explicitly the Table format gets the default
        // time-series treatment, including queries saved before FORMAT existed.
        isTimeSeries := !strings.EqualFold(format, tableFormatTable)
@@ -387,9 +404,41 @@ func buildTableResponseFrame(dataSet *tableQueryDataSet, 
format string) *data.Fr
                        frame = wide
                }
        }
+       if isTimeSeries && !isNoopLegendFormat(legendFormat) {
+               applyLegendFormat(frame, legendFormat)
+       }
        return frame
 }
 
+// isNumericTableType reports whether a table-model result type carries a
+// plottable numeric value (as opposed to a tag/string, timestamp, or blob).
+func isNumericTableType(dataType string) bool {
+       switch strings.ToUpper(dataType) {
+       case "INT32", "INT64", "FLOAT", "DOUBLE":
+               return true
+       default:
+               return false
+       }
+}
+
+// hasPlottableValue reports whether the dataset has at least one non-null cell
+// in a numeric column. Zero-row datasets and value columns that are entirely
+// NULL both report false, so queryTableModel can collapse either into the
+// "No data" state instead of drawing bare axes.
+func hasPlottableValue(dataSet *tableQueryDataSet) bool {
+       for _, row := range dataSet.Values {
+               for col, cell := range row {
+                       if cell == nil {
+                               continue
+                       }
+                       if col < len(dataSet.DataTypes) && 
isNumericTableType(dataSet.DataTypes[col]) {
+                               return true
+                       }
+               }
+       }
+       return false
+}
+
 // sortRowsByFirstTimestamp stably sorts the row-major values ascending by the
 // first TIMESTAMP column, which both time-series rendering and the 
long-to-wide
 // pivot require. Rows whose time cell is null sort last.
@@ -570,3 +619,70 @@ func toString(v interface{}) string {
                return fmt.Sprintf("%v", v)
        }
 }
+
+// legendFormatRe matches Grafana legend format template placeholders like
+// {{instance}} or {{ instance }}; whitespace inside the braces is ignored.
+var legendFormatRe = regexp.MustCompile(`\{\{\s*([^{}]+?)\s*\}\}`)
+
+var prometheusLegendLabelAliases = map[string]string{
+       "nodeType":  "node_type",
+       "nodeId":    "node_id",
+       "name":      "label_name",
+       "type":      "label_type",
+       "database":  "database_name",
+       "interface": "interface_name",
+       "id":        "label_id",
+       "rate":      "label_rate",
+       "from":      "source_from",
+       "index":     "label_index",
+}
+
+// autoLegendFormat is Grafana's sentinel for "automatic legend": it must never
+// be applied as a literal series name, or every series would display "__auto".
+const autoLegendFormat = "__auto"
+
+// isNoopLegendFormat reports whether legendFormat should be left alone. An
+// empty format and Grafana's __auto sentinel both mean "do not override series
+// names", so Grafana falls back to its own automatic legend.
+func isNoopLegendFormat(legendFormat string) bool {
+       return legendFormat == "" || legendFormat == autoLegendFormat
+}
+
+// applyLegendFormat resolves the Grafana legendFormat template against
+// each non-time field's labels and sets the field's DisplayNameFromDS
+// so the series show user-friendly names instead of "value {labels...}".
+func applyLegendFormat(frame *data.Frame, legendFormat string) {
+       if isNoopLegendFormat(legendFormat) {
+               return
+       }
+       for _, field := range frame.Fields {
+               if field.Type().Time() {
+                       continue
+               }
+               displayName := resolveLegendFormat(legendFormat, field.Labels)
+               if field.Config == nil {
+                       field.Config = &data.FieldConfig{}
+               }
+               field.Config.DisplayNameFromDS = displayName
+       }
+}
+
+// resolveLegendFormat replaces {{labelName}} placeholders in format with
+// the corresponding value from labels, matching Grafana's behaviour:
+// optional whitespace inside the braces is ignored, known Prometheus names
+// can resolve from their IoTDB storage aliases, and missing or empty values
+// resolve to the label name itself, as Grafana's truthy-value check does.
+func resolveLegendFormat(format string, labels data.Labels) string {
+       return legendFormatRe.ReplaceAllStringFunc(format, func(match string) 
string {
+               key := strings.TrimSpace(match[2 : len(match)-2])
+               if value := labels[key]; value != "" {
+                       return value
+               }
+               if alias, ok := prometheusLegendLabelAliases[key]; ok {
+                       if value := labels[alias]; value != "" {
+                               return value
+                       }
+               }
+               return key
+       })
+}
diff --git a/connectors/grafana-plugin/pkg/plugin/table_query_test.go 
b/connectors/grafana-plugin/pkg/plugin/table_query_test.go
index 29c65eb..ad36944 100644
--- a/connectors/grafana-plugin/pkg/plugin/table_query_test.go
+++ b/connectors/grafana-plugin/pkg/plugin/table_query_test.go
@@ -433,7 +433,7 @@ func TestBuildTableResponseFrameLongToWide(t *testing.T) {
                },
        }
 
-       frame := buildTableResponseFrame(ds, "")
+       frame := buildTableResponseFrame(ds, "", "")
        if len(frame.Fields) != 3 {
                t.Fatalf("expected time + one series per device (3 fields), got 
%d", len(frame.Fields))
        }
@@ -477,7 +477,7 @@ func TestBuildTableResponseFrameTableFormatPreservesOrder(t 
*testing.T) {
                },
        }
 
-       frame := buildTableResponseFrame(ds, tableFormatTable)
+       frame := buildTableResponseFrame(ds, tableFormatTable, "")
        if len(frame.Fields) != 3 {
                t.Fatalf("expected 3 plain fields, got %d", len(frame.Fields))
        }
@@ -503,7 +503,7 @@ func TestBuildTableResponseFrameNullTimeFallsBack(t 
*testing.T) {
                },
        }
 
-       frame := buildTableResponseFrame(ds, tableFormatTimeSeries)
+       frame := buildTableResponseFrame(ds, tableFormatTimeSeries, "")
        if len(frame.Fields) != 3 {
                t.Fatalf("expected plain 3-field fallback frame, got %d 
fields", len(frame.Fields))
        }
@@ -561,3 +561,372 @@ func TestTableRPCEndpoint(t *testing.T) {
                })
        }
 }
+
+// TestResolveLegendFormat verifies template resolution: static strings,
+// label placeholders (with and without internal whitespace), missing labels
+// rendered as their names, canonical storage aliases, and multiple 
placeholders.
+func TestResolveLegendFormat(t *testing.T) {
+       labels := data.Labels{
+               "instance":       "192.168.130.36:9091",
+               "node_type":      "DATANODE",
+               "label_name":     "metric",
+               "database_name":  "root.db",
+               "label_type":     "READ",
+               "interface_name": "execute",
+               "label_id":       "region-1",
+               "empty":          "",
+       }
+
+       cases := []struct {
+               name   string
+               format string
+               want   string
+       }{
+               {name: "static text unchanged", format: "Average Disk Usage", 
want: "Average Disk Usage"},
+               {name: "single placeholder", format: "{{instance}}", want: 
"192.168.130.36:9091"},
+               {name: "placeholder with spaces", format: "{{ instance }}", 
want: "192.168.130.36:9091"},
+               {name: "multiple spaces in placeholder", format: "{{  instance  
}}", want: "192.168.130.36:9091"},
+               {name: "multiple placeholders", format: "{{instance}} - 
{{node_type}}", want: "192.168.130.36:9091 - DATANODE"},
+               {name: "mixed static and dynamic", format: "Node {{instance}}", 
want: "Node 192.168.130.36:9091"},
+               {name: "missing label keeps placeholder name", format: 
"{{missing}}", want: "missing"},
+               {name: "empty label keeps placeholder name", format: 
"{{empty}}", want: "empty"},
+               {name: "missing with whitespace keeps placeholder name", 
format: "{{ missing }}", want: "missing"},
+               {name: "partial match: one found, one missing", format: 
"{{instance}}-{{notfound}}", want: "192.168.130.36:9091-notfound"},
+               {name: "prometheus labels map to IoTDB columns", format: 
"{{name}}/{{database}}/{{type}}/{{interface}}/{{id}}", want: 
"metric/root.db/READ/execute/region-1"},
+               {name: "empty format string", format: "", want: ""},
+       }
+       for _, c := range cases {
+               t.Run(c.name, func(t *testing.T) {
+                       got := resolveLegendFormat(c.format, labels)
+                       if got != c.want {
+                               t.Fatalf("resolveLegendFormat(%q) = %q, want 
%q", c.format, got, c.want)
+                       }
+               })
+       }
+}
+
+func TestResolveLegendFormatCanonicalLabelAliases(t *testing.T) {
+       labels := data.Labels{
+               "node_type":      "DataNode",
+               "node_id":        "3",
+               "label_name":     "metric",
+               "database_name":  "root.db",
+               "label_type":     "READ",
+               "interface_name": "execute",
+               "label_id":       "region-1",
+               "label_rate":     "hit",
+               "source_from":    "cache",
+               "label_index":    "7",
+       }
+
+       format := 
"{{nodeType}}/{{nodeId}}/{{name}}/{{database}}/{{type}}/{{interface}}/{{id}}/{{rate}}/{{from}}/{{index}}"
+       want := "DataNode/3/metric/root.db/READ/execute/region-1/hit/cache/7"
+       if got := resolveLegendFormat(format, labels); got != want {
+               t.Fatalf("resolveLegendFormat() = %q, want %q", got, want)
+       }
+}
+
+// TestApplyLegendFormat checks that DisplayNameFromDS is set on non-time
+// fields after applying legendFormat, and that time fields are left alone.
+func TestApplyLegendFormat(t *testing.T) {
+       frame := data.NewFrame("test",
+               data.NewField("time", nil, []*time.Time{}),
+               data.NewField("value", data.Labels{"instance": 
"192.168.130.36:9091", "node_type": "DATANODE"}, []*float64{}),
+               data.NewField("value", data.Labels{"instance": 
"192.168.130.38:9091", "node_type": "DATANODE"}, []*float64{}),
+       )
+
+       applyLegendFormat(frame, "{{instance}}")
+
+       // Time field must not get DisplayNameFromDS.
+       if frame.Fields[0].Config != nil && 
frame.Fields[0].Config.DisplayNameFromDS != "" {
+               t.Fatalf("time field should not have DisplayNameFromDS, got 
%q", frame.Fields[0].Config.DisplayNameFromDS)
+       }
+       // Value fields must have DisplayNameFromDS resolved from labels.
+       if frame.Fields[1].Config == nil || 
frame.Fields[1].Config.DisplayNameFromDS != "192.168.130.36:9091" {
+               t.Fatalf("field 1 DisplayNameFromDS = %q, want %q",
+                       fieldDisplayName(frame.Fields[1]), 
"192.168.130.36:9091")
+       }
+       if frame.Fields[2].Config == nil || 
frame.Fields[2].Config.DisplayNameFromDS != "192.168.130.38:9091" {
+               t.Fatalf("field 2 DisplayNameFromDS = %q, want %q",
+                       fieldDisplayName(frame.Fields[2]), 
"192.168.130.38:9091")
+       }
+}
+
+func TestApplyLegendFormatSkipsNullableTime(t *testing.T) {
+       timestamp := ts(1000)
+       value := 1.0
+       frame := data.NewFrame("test",
+               data.NewField("time", nil, []*time.Time{&timestamp}),
+               data.NewField("value", data.Labels{"instance": "node-1"}, 
[]*float64{&value}),
+       )
+
+       applyLegendFormat(frame, "timestamp {{instance}}")
+
+       if frame.Fields[0].Config != nil && 
frame.Fields[0].Config.DisplayNameFromDS != "" {
+               t.Fatalf("nullable time field should not have 
DisplayNameFromDS, got %q", frame.Fields[0].Config.DisplayNameFromDS)
+       }
+}
+
+func fieldDisplayName(f *data.Field) string {
+       if f.Config == nil {
+               return ""
+       }
+       return f.Config.DisplayNameFromDS
+}
+
+// TestApplyLegendFormatStatic verifies that a static legendFormat (no
+// placeholders) is applied as-is to every non-time field.
+func TestApplyLegendFormatStatic(t *testing.T) {
+       frame := data.NewFrame("test",
+               data.NewField("time", nil, []*time.Time{}),
+               data.NewField("value", data.Labels{"instance": 
"192.168.130.36:9091"}, []*float64{}),
+       )
+
+       applyLegendFormat(frame, "Average Disk Usage")
+
+       if frame.Fields[1].Config == nil || 
frame.Fields[1].Config.DisplayNameFromDS != "Average Disk Usage" {
+               t.Fatalf("static legend not applied: DisplayNameFromDS = %q", 
fieldDisplayName(frame.Fields[1]))
+       }
+}
+
+// TestApplyLegendFormatNoop checks that both the empty format and Grafana's
+// __auto sentinel leave series names untouched (Config stays nil), so Grafana
+// falls back to its automatic legend instead of showing literal "__auto".
+func TestApplyLegendFormatNoop(t *testing.T) {
+       for _, format := range []string{"", autoLegendFormat} {
+               frame := data.NewFrame("test",
+                       data.NewField("time", nil, []*time.Time{}),
+                       data.NewField("value", data.Labels{"instance": 
"192.168.130.36:9091"}, []*float64{}),
+               )
+               applyLegendFormat(frame, format)
+               if frame.Fields[1].Config != nil {
+                       t.Fatalf("legendFormat %q should be a no-op, got Config 
%#v", format, frame.Fields[1].Config)
+               }
+       }
+}
+
+// TestBuildTableResponseFrameAutoLegend checks end-to-end that an __auto
+// legendFormat does not set DisplayNameFromDS on the pivoted value field.
+func TestBuildTableResponseFrameAutoLegend(t *testing.T) {
+       ds := &tableQueryDataSet{
+               ColumnNames: []string{"time", "instance", "value"},
+               DataTypes:   []string{"TIMESTAMP", "STRING", "DOUBLE"},
+               Values: [][]interface{}{
+                       {ts(1000), "192.168.130.36:9091", float64(11.0)},
+                       {ts(2000), "192.168.130.36:9091", float64(12.0)},
+               },
+       }
+       frame := buildTableResponseFrame(ds, "", autoLegendFormat)
+       if len(frame.Fields) != 2 {
+               t.Fatalf("expected time + 1 series = 2 fields, got %d", 
len(frame.Fields))
+       }
+       if frame.Fields[1].Config != nil && 
frame.Fields[1].Config.DisplayNameFromDS != "" {
+               t.Fatalf("__auto legend must not set DisplayNameFromDS, got 
%q", fieldDisplayName(frame.Fields[1]))
+       }
+}
+
+// TestApplyLegendFormatPreservesExistingConfig verifies that if a field
+// already has Config set, we only overwrite DisplayNameFromDS and leave
+// the rest untouched.
+func TestApplyLegendFormatPreservesExistingConfig(t *testing.T) {
+       frame := data.NewFrame("test",
+               data.NewField("time", nil, []*time.Time{}),
+       )
+       valField := data.NewField("value", data.Labels{"instance": "x"}, 
[]*float64{})
+       valField.Config = &data.FieldConfig{Unit: "percent"}
+       frame.Fields = append(frame.Fields, valField)
+
+       applyLegendFormat(frame, "{{instance}}")
+
+       if frame.Fields[1].Config.Unit != "percent" {
+               t.Fatalf("existing Config.Unit was clobbered: got %q, want %q", 
frame.Fields[1].Config.Unit, "percent")
+       }
+       if frame.Fields[1].Config.DisplayNameFromDS != "x" {
+               t.Fatalf("DisplayNameFromDS = %q, want %q", 
frame.Fields[1].Config.DisplayNameFromDS, "x")
+       }
+}
+
+// TestBuildTableResponseFrameLegendFormatEndToEnd checks that
+// buildTableResponseFrame wires legendFormat through to the final frame.
+func TestBuildTableResponseFrameLegendFormatEndToEnd(t *testing.T) {
+       ds := &tableQueryDataSet{
+               ColumnNames: []string{"time", "instance", "value"},
+               DataTypes:   []string{"TIMESTAMP", "STRING", "DOUBLE"},
+               Values: [][]interface{}{
+                       {ts(1000), "192.168.130.36:9091", float64(11.0)},
+                       {ts(2000), "192.168.130.36:9091", float64(12.0)},
+               },
+       }
+
+       frame := buildTableResponseFrame(ds, "", "{{instance}}")
+       // After LongToWide: time + one value field with instance label.
+       if len(frame.Fields) != 2 {
+               t.Fatalf("expected time + 1 series = 2 fields, got %d", 
len(frame.Fields))
+       }
+       if frame.Fields[1].Config == nil || 
frame.Fields[1].Config.DisplayNameFromDS != "192.168.130.36:9091" {
+               t.Fatalf("DisplayNameFromDS = %q, want %q", 
fieldDisplayName(frame.Fields[1]), "192.168.130.36:9091")
+       }
+}
+
+// TestBuildTableResponseFrameEmptyTimeSeries checks that a zero-row dataset
+// with Time Series format still builds a frame (the "No data" skip is the
+// caller's responsibility in queryTableModel).
+func TestBuildTableResponseFrameEmptyTimeSeries(t *testing.T) {
+       ds := &tableQueryDataSet{
+               ColumnNames: []string{"time", "device", "value"},
+               DataTypes:   []string{"TIMESTAMP", "STRING", "DOUBLE"},
+               Values:      [][]interface{}{},
+       }
+
+       frame := buildTableResponseFrame(ds, "", "")
+       if len(frame.Fields) != 3 {
+               t.Fatalf("expected 3 fields for empty dataset, got %d", 
len(frame.Fields))
+       }
+       if frame.Fields[0].Len() != 0 {
+               t.Fatalf("expected 0 rows, got %d", frame.Fields[0].Len())
+       }
+}
+
+// TestBuildTableResponseFrameEmptyTableFormat verifies that a zero-row
+// Table-format dataset retains its column-definition frame.
+func TestBuildTableResponseFrameEmptyTableFormat(t *testing.T) {
+       ds := &tableQueryDataSet{
+               ColumnNames: []string{"time", "device", "value"},
+               DataTypes:   []string{"TIMESTAMP", "STRING", "DOUBLE"},
+               Values:      [][]interface{}{},
+       }
+
+       frame := buildTableResponseFrame(ds, tableFormatTable, "")
+       if len(frame.Fields) != 3 {
+               t.Fatalf("Table format should retain column definitions, got %d 
fields", len(frame.Fields))
+       }
+       // Table format must not pivot.
+       if frame.TimeSeriesSchema().Type == data.TimeSeriesTypeWide {
+               t.Fatalf("Table format should not pivot to wide")
+       }
+}
+
+func TestQueryTableModelZeroRowsByFormat(t *testing.T) {
+       ds := &tableQueryDataSet{
+               ColumnNames: []string{"time", "device", "value"},
+               DataTypes:   []string{"TIMESTAMP", "STRING", "DOUBLE"},
+               Values:      [][]interface{}{},
+       }
+       d := &IoTDBDataSource{
+               tableQueryRunner: func(context.Context, *queryParam) 
(*tableQueryDataSet, error) {
+                       return ds, nil
+               },
+       }
+
+       timeSeriesResponse := d.queryTableModel(context.Background(), 
&queryParam{Format: tableFormatTimeSeries})
+       if len(timeSeriesResponse.Frames) != 0 {
+               t.Fatalf("zero-row Time series response has %d frames, want 0", 
len(timeSeriesResponse.Frames))
+       }
+
+       tableResponse := d.queryTableModel(context.Background(), 
&queryParam{Format: tableFormatTable})
+       if len(tableResponse.Frames) != 1 {
+               t.Fatalf("zero-row Table response has %d frames, want 1", 
len(tableResponse.Frames))
+       }
+       if got := len(tableResponse.Frames[0].Fields); got != 
len(ds.ColumnNames) {
+               t.Fatalf("zero-row Table frame has %d fields, want %d", got, 
len(ds.ColumnNames))
+       }
+}
+
+// TestQueryTableModelAllNullTimeSeries checks that rows whose value column is
+// entirely NULL (a HOP/rate query over sparse or absent data) also collapse to
+// the "No data" state in Time series format, while Table format keeps headers.
+func TestQueryTableModelAllNullTimeSeries(t *testing.T) {
+       ds := &tableQueryDataSet{
+               ColumnNames: []string{"time", "value"},
+               DataTypes:   []string{"TIMESTAMP", "DOUBLE"},
+               Values: [][]interface{}{
+                       {ts(1000), nil},
+                       {ts(2000), nil},
+               },
+       }
+       d := &IoTDBDataSource{
+               tableQueryRunner: func(context.Context, *queryParam) 
(*tableQueryDataSet, error) {
+                       return ds, nil
+               },
+       }
+
+       timeSeriesResponse := d.queryTableModel(context.Background(), 
&queryParam{Format: tableFormatTimeSeries})
+       if len(timeSeriesResponse.Frames) != 0 {
+               t.Fatalf("all-NULL Time series response has %d frames, want 0 
(No data)", len(timeSeriesResponse.Frames))
+       }
+
+       tableResponse := d.queryTableModel(context.Background(), 
&queryParam{Format: tableFormatTable})
+       if len(tableResponse.Frames) != 1 {
+               t.Fatalf("all-NULL Table response has %d frames, want 1 
(headers preserved)", len(tableResponse.Frames))
+       }
+}
+
+// TestHasPlottableValue pins what counts as data for the "No data" decision:
+// only non-null cells in numeric columns are plottable; timestamps, string 
tags,
+// and NULL cells are ignored.
+func TestHasPlottableValue(t *testing.T) {
+       cases := []struct {
+               name string
+               ds   *tableQueryDataSet
+               want bool
+       }{
+               {
+                       name: "zero rows",
+                       ds:   &tableQueryDataSet{ColumnNames: []string{"time", 
"value"}, DataTypes: []string{"TIMESTAMP", "DOUBLE"}, Values: 
[][]interface{}{}},
+                       want: false,
+               },
+               {
+                       name: "all null values",
+                       ds:   &tableQueryDataSet{ColumnNames: []string{"time", 
"value"}, DataTypes: []string{"TIMESTAMP", "DOUBLE"}, Values: 
[][]interface{}{{ts(1), nil}, {ts(2), nil}}},
+                       want: false,
+               },
+               {
+                       name: "some numeric values",
+                       ds:   &tableQueryDataSet{ColumnNames: []string{"time", 
"value"}, DataTypes: []string{"TIMESTAMP", "DOUBLE"}, Values: 
[][]interface{}{{ts(1), nil}, {ts(2), float64(3.0)}}},
+                       want: true,
+               },
+               {
+                       name: "non-null tag does not count as value",
+                       ds:   &tableQueryDataSet{ColumnNames: []string{"time", 
"device", "value"}, DataTypes: []string{"TIMESTAMP", "STRING", "DOUBLE"}, 
Values: [][]interface{}{{ts(1), "d1", nil}}},
+                       want: false,
+               },
+               {
+                       name: "int value counts",
+                       ds:   &tableQueryDataSet{ColumnNames: []string{"time", 
"value"}, DataTypes: []string{"TIMESTAMP", "INT64"}, Values: 
[][]interface{}{{ts(1), int64(7)}}},
+                       want: true,
+               },
+       }
+       for _, c := range cases {
+               t.Run(c.name, func(t *testing.T) {
+                       if got := hasPlottableValue(c.ds); got != c.want {
+                               t.Fatalf("hasPlottableValue() = %v, want %v", 
got, c.want)
+                       }
+               })
+       }
+}
+
+// TestQueryParamLegendFormatDeserialization verifies that legendFormat
+// survives the JSON -> queryParam round-trip.
+func TestQueryParamLegendFormatDeserialization(t *testing.T) {
+       body := `{"sqlType":"SQL: Table Model","sql":"SELECT 
1","database":"db1","legendFormat":"{{instance}}"}`
+       qp, msg := verifyQuery(backend.DataQuery{JSON: []byte(body)})
+       if msg != "" {
+               t.Fatalf("valid query rejected: %q", msg)
+       }
+       if qp.LegendFormat != "{{instance}}" {
+               t.Fatalf("LegendFormat = %q, want %q", qp.LegendFormat, 
"{{instance}}")
+       }
+}
+
+// TestQueryParamLegendFormatEmpty verifies that omitting legendFormat
+// from JSON yields an empty string (backward-compatible).
+func TestQueryParamLegendFormatEmpty(t *testing.T) {
+       body := `{"sqlType":"SQL: Table Model","sql":"SELECT 
1","database":"db1"}`
+       qp, msg := verifyQuery(backend.DataQuery{JSON: []byte(body)})
+       if msg != "" {
+               t.Fatalf("valid query rejected: %q", msg)
+       }
+       if qp.LegendFormat != "" {
+               t.Fatalf("LegendFormat should default to empty, got %q", 
qp.LegendFormat)
+       }
+}
diff --git a/connectors/grafana-plugin/src/QueryEditor.tsx 
b/connectors/grafana-plugin/src/QueryEditor.tsx
index 9704c46..7f3684c 100644
--- a/connectors/grafana-plugin/src/QueryEditor.tsx
+++ b/connectors/grafana-plugin/src/QueryEditor.tsx
@@ -49,6 +49,7 @@ interface State {
   database: string;
   sql: string;
   format: string;
+  legendFormat: string;
 }
 
 const selectElement = [
@@ -94,6 +95,7 @@ export class QueryEditor extends PureComponent<Props, State> {
     database: '',
     sql: '',
     format: tableFormats[0],
+    legendFormat: '',
   };
 
 
@@ -161,6 +163,13 @@ export class QueryEditor extends PureComponent<Props, 
State> {
     onChange({ ...query, format: value });
   };
 
+  onLegendFormatChange = (event: ChangeEvent<HTMLInputElement>) => {
+    const { onChange, query } = this.props;
+    const legendFormat = event.target.value;
+    this.setState({ legendFormat });
+    onChange({ ...query, legendFormat });
+  };
+
   onSelectTypeChange = (event: ChangeEvent<HTMLInputElement>) => {
     const { onChange, query } = this.props;
     onChange({ ...query });
@@ -207,6 +216,7 @@ export class QueryEditor extends PureComponent<Props, 
State> {
         database: this.props.query.database ?? '',
         sql: this.props.query.sql ?? '',
         format: this.props.query.format ?? tableFormats[0],
+        legendFormat: this.props.query.legendFormat ?? '',
       });
     } else {
       this.props.query.sqlType = selectType[0];
@@ -414,6 +424,15 @@ export class QueryEditor extends PureComponent<Props, 
State> {
                     />
                   </QueryInlineField>
                 </div>
+                <div className="gf-form">
+                  <QueryInlineField label={'LEGEND'}>
+                    <Input
+                      value={query.legendFormat ?? this.state.legendFormat}
+                      placeholder={'{{instance}}'}
+                      onChange={this.onLegendFormatChange}
+                    />
+                  </QueryInlineField>
+                </div>
               </>
             )}
           </>
diff --git a/connectors/grafana-plugin/src/types.ts 
b/connectors/grafana-plugin/src/types.ts
index 8f793f5..fb8a300 100644
--- a/connectors/grafana-plugin/src/types.ts
+++ b/connectors/grafana-plugin/src/types.ts
@@ -40,6 +40,7 @@ export interface IoTDBQuery extends DataQuery {
   database?: string;
   sql?: string;
   format?: string;
+  legendFormat?: string;
 }
 
 export interface GroupBy {

Reply via email to