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{×tamp}),
+ 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 {