From ece6ca10ac11eef61c92b418bbe25ff4185031a8 Mon Sep 17 00:00:00 2001 From: zxq <3322351820@qq.com> Date: Wed, 12 Aug 2026 19:57:38 +0800 Subject: [PATCH] support legendFormat and show "No data" on empty results --- .../grafana-plugin/pkg/plugin/plugin.go | 4 + .../grafana-plugin/pkg/plugin/table_query.go | 146 ++++++- .../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 { database: '', sql: '', format: tableFormats[0], + legendFormat: '', }; @@ -161,6 +163,13 @@ export class QueryEditor extends PureComponent { onChange({ ...query, format: value }); }; + onLegendFormatChange = (event: ChangeEvent) => { + const { onChange, query } = this.props; + const legendFormat = event.target.value; + this.setState({ legendFormat }); + onChange({ ...query, legendFormat }); + }; + onSelectTypeChange = (event: ChangeEvent) => { const { onChange, query } = this.props; onChange({ ...query }); @@ -207,6 +216,7 @@ export class QueryEditor extends PureComponent { 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 { /> +
+ + + +
)} 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 {