diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 8d4775e9a30..91901fdad41 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4108,7 +4108,7 @@ "count": 4 }, "@typescript-eslint/no-explicit-any": { - "count": 10 + "count": 8 } }, "public/app/plugins/datasource/graphite/gfunc.ts": { diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 62b932763af..512598da1b7 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -109,8 +109,10 @@ func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, par if params.QueryParams != nil { queryValues := u.Query() - for k, v := range params.QueryParams { - queryValues.Set(k, v) + for key, values := range params.QueryParams { + for _, value := range values { + queryValues.Add(key, value) + } } u.RawQuery = queryValues.Encode() } diff --git a/pkg/tsdb/graphite/graphite_test.go b/pkg/tsdb/graphite/graphite_test.go index bff49068f30..9234c6bf4a3 100644 --- a/pkg/tsdb/graphite/graphite_test.go +++ b/pkg/tsdb/graphite/graphite_test.go @@ -26,7 +26,7 @@ func Test_CreateRequest(t *testing.T) { expectedMethod string expectedError string checkHeaders map[string]string - checkQuery map[string]string + checkQuery map[string][]string }{ { name: "basic request with default GET method", @@ -57,16 +57,16 @@ func Test_CreateRequest(t *testing.T) { name: "request with query parameters", dsInfo: dsInfo, params: URLParams{ - QueryParams: map[string]string{ - "query": "stats.counters.*", - "format": "json", + QueryParams: map[string][]string{ + "query": {"stats.counters.*"}, + "format": {"json"}, }, }, expectedURL: "http://graphite.example.com", expectedMethod: "GET", - checkQuery: map[string]string{ - "query": "stats.counters.*", - "format": "json", + checkQuery: map[string][]string{ + "query": {"stats.counters.*"}, + "format": {"json"}, }, }, { @@ -99,9 +99,9 @@ func Test_CreateRequest(t *testing.T) { params: URLParams{ SubPath: "/metrics/expand", Method: "POST", - QueryParams: map[string]string{ - "groupByExpr": "true", - "leavesOnly": "false", + QueryParams: map[string][]string{ + "groupByExpr": {"true"}, + "leavesOnly": {"false"}, }, Headers: map[string]string{ "X-Custom-Header": "test-value", @@ -110,9 +110,9 @@ func Test_CreateRequest(t *testing.T) { }, expectedURL: "http://graphite.example.com/metrics/expand", expectedMethod: "POST", - checkQuery: map[string]string{ - "groupByExpr": "true", - "leavesOnly": "false", + checkQuery: map[string][]string{ + "groupByExpr": {"true"}, + "leavesOnly": {"false"}, }, checkHeaders: map[string]string{ "X-Custom-Header": "test-value", @@ -130,16 +130,30 @@ func Test_CreateRequest(t *testing.T) { name: "empty query parameter values", dsInfo: dsInfo, params: URLParams{ - QueryParams: map[string]string{ - "empty": "", - "valid": "value", + QueryParams: map[string][]string{ + "empty": {""}, + "valid": {"value"}, }, }, expectedURL: "http://graphite.example.com", expectedMethod: "GET", - checkQuery: map[string]string{ - "empty": "", - "valid": "value", + checkQuery: map[string][]string{ + "empty": {""}, + "valid": {"value"}, + }, + }, + { + name: "multi-valued query parameter", + dsInfo: dsInfo, + params: URLParams{ + QueryParams: map[string][]string{ + "valid": {"value1", "value2"}, + }, + }, + expectedURL: "http://graphite.example.com", + expectedMethod: "GET", + checkQuery: map[string][]string{ + "valid": {"value1", "value2"}, }, }, } @@ -163,9 +177,13 @@ func Test_CreateRequest(t *testing.T) { assert.Equal(t, tt.expectedMethod, req.Method) if tt.checkQuery != nil { - for key, expectedValue := range tt.checkQuery { - actualValue := req.URL.Query().Get(key) - assert.Equal(t, expectedValue, actualValue, "Query parameter %s", key) + for key, expectedValues := range tt.checkQuery { + actualValue := req.URL.Query()[key] + assert.NotZero(t, len(actualValue)) + + for _, expectedValue := range expectedValues { + assert.Contains(t, actualValue, expectedValue, "Query parameter %s", key) + } } } diff --git a/pkg/tsdb/graphite/resource_handler.go b/pkg/tsdb/graphite/resource_handler.go index d8a1c3508ec..dfd1a3c6568 100644 --- a/pkg/tsdb/graphite/resource_handler.go +++ b/pkg/tsdb/graphite/resource_handler.go @@ -26,6 +26,8 @@ func (s *Service) newResourceMux() *http.ServeMux { mux.HandleFunc("/metrics/expand", handleResourceReq(s.handleMetricsExpand, s)) mux.HandleFunc("/functions", handleResourceReq(s.handleFunctions, s)) mux.HandleFunc("/tags/autoComplete/tags", handleResourceReq(s.handleTagsAutocomplete, s)) + mux.HandleFunc("/tags/autoComplete/values", handleResourceReq(s.handleTagValuesAutocomplete, s)) + return mux } @@ -87,12 +89,12 @@ func handleResourceReq[T any](handlerFn resourceHandler[T], s *Service) func(rw } func (s *Service) handleEvents(ctx context.Context, dsInfo *datasourceInfo, eventsRequestJson *GraphiteEventsRequest) ([]byte, int, error) { - queryParams := map[string]string{ - "from": eventsRequestJson.From, - "until": eventsRequestJson.Until, + queryParams := map[string][]string{ + "from": {eventsRequestJson.From}, + "until": {eventsRequestJson.Until}, } if eventsRequestJson.Tags != "" { - queryParams["tags"] = eventsRequestJson.Tags + queryParams["tags"] = []string{eventsRequestJson.Tags} } req, err := s.createRequest(ctx, dsInfo, URLParams{ @@ -128,12 +130,12 @@ func (s *Service) handleMetricsFind(ctx context.Context, dsInfo *datasourceInfo, data := url.Values{} data.Set("query", metricsFindRequestJson.Query) - queryParams := map[string]string{} + queryParams := map[string][]string{} if metricsFindRequestJson.From != "" { - queryParams["from"] = metricsFindRequestJson.From + queryParams["from"] = []string{metricsFindRequestJson.From} } if metricsFindRequestJson.Until != "" { - queryParams["until"] = metricsFindRequestJson.Until + queryParams["until"] = []string{metricsFindRequestJson.Until} } req, err := s.createRequest(ctx, dsInfo, URLParams{ @@ -165,14 +167,14 @@ func (s *Service) handleMetricsExpand(ctx context.Context, dsInfo *datasourceInf return nil, http.StatusBadRequest, fmt.Errorf("query is required") } - queryParams := map[string]string{ - "query": metricsExpandRequestJson.Query, + queryParams := map[string][]string{ + "query": {metricsExpandRequestJson.Query}, } if metricsExpandRequestJson.From != "" { - queryParams["from"] = metricsExpandRequestJson.From + queryParams["from"] = []string{metricsExpandRequestJson.From} } if metricsExpandRequestJson.Until != "" { - queryParams["until"] = metricsExpandRequestJson.Until + queryParams["until"] = []string{metricsExpandRequestJson.Until} } req, err := s.createRequest(ctx, dsInfo, URLParams{ @@ -205,11 +207,11 @@ func (s *Service) handleMetricsExpand(ctx context.Context, dsInfo *datasourceInf } func (s *Service) handleTagsAutocomplete(ctx context.Context, dsInfo *datasourceInfo, tagsAutocompleteRequestJson *GraphiteTagsRequest) ([]byte, int, error) { - queryParams := map[string]string{ - "from": tagsAutocompleteRequestJson.From, - "until": tagsAutocompleteRequestJson.Until, - "limit": fmt.Sprintf("%d", tagsAutocompleteRequestJson.Limit), - "tagPrefix": tagsAutocompleteRequestJson.TagPrefix, + queryParams := map[string][]string{ + "from": {tagsAutocompleteRequestJson.From}, + "until": {tagsAutocompleteRequestJson.Until}, + "limit": {fmt.Sprintf("%d", tagsAutocompleteRequestJson.Limit)}, + "tagPrefix": {tagsAutocompleteRequestJson.TagPrefix}, } req, err := s.createRequest(ctx, dsInfo, URLParams{ SubPath: "tags/autoComplete/tags", @@ -217,7 +219,7 @@ func (s *Service) handleTagsAutocomplete(ctx context.Context, dsInfo *datasource QueryParams: queryParams, }) if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("failed to create metrics expand request %v", err) + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create tags autocomplete request %v", err) } tags, _, statusCode, err := doGraphiteRequest[[]string](ctx, dsInfo, s.logger, req, false) @@ -233,6 +235,38 @@ func (s *Service) handleTagsAutocomplete(ctx context.Context, dsInfo *datasource return tagsResponse, statusCode, nil } +func (s *Service) handleTagValuesAutocomplete(ctx context.Context, dsInfo *datasourceInfo, tagValuesAutocompleteRequestJson *GraphiteTagValuesRequest) ([]byte, int, error) { + queryParams := map[string][]string{ + "expr": tagValuesAutocompleteRequestJson.Expr, + "tag": {tagValuesAutocompleteRequestJson.Tag}, + "from": {tagValuesAutocompleteRequestJson.From}, + "until": {tagValuesAutocompleteRequestJson.Until}, + "limit": {fmt.Sprintf("%d", tagValuesAutocompleteRequestJson.Limit)}, + "valuePrefix": {tagValuesAutocompleteRequestJson.ValuePrefix}, + } + + req, err := s.createRequest(ctx, dsInfo, URLParams{ + SubPath: "tags/autoComplete/values", + Method: http.MethodGet, + QueryParams: queryParams, + }) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create tag values autocomplete request %v", err) + } + + tagValues, _, statusCode, err := doGraphiteRequest[[]string](ctx, dsInfo, s.logger, req, false) + if err != nil { + return nil, statusCode, fmt.Errorf("tag values autocomplete request failed: %v", err) + } + + tagValuesResponse, err := json.Marshal(tagValues) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to marshal tag values autocomplete response: %s", err) + } + + return tagValuesResponse, statusCode, nil +} + func (s *Service) handleFunctions(ctx context.Context, dsInfo *datasourceInfo, _ *any) ([]byte, int, error) { req, err := s.createRequest(ctx, dsInfo, URLParams{ SubPath: "functions", diff --git a/pkg/tsdb/graphite/resource_handler_test.go b/pkg/tsdb/graphite/resource_handler_test.go index c331c2d2cab..c4ba2e201d2 100644 --- a/pkg/tsdb/graphite/resource_handler_test.go +++ b/pkg/tsdb/graphite/resource_handler_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -498,6 +499,127 @@ func TestHandleTagsAutocomplete(t *testing.T) { }) } } + +func TestHandleTagValuesAutocomplete(t *testing.T) { + tests := []struct { + name string + request GraphiteTagValuesRequest + responseBody string + statusCode int + expectError bool + errorContains string + expectedData []string + }{ + { + name: "successful tag values autocomplete request", + request: GraphiteTagValuesRequest{ + Expr: []string{"app=*"}, + Tag: "environment", + From: "1h", + Until: "now", + Limit: 5, + ValuePrefix: "prod", + }, + responseBody: `["production", "prod-eu", "prod-us"]`, + statusCode: 200, + expectedData: []string{"production", "prod-eu", "prod-us"}, + }, + { + name: "multiple expressions", + request: GraphiteTagValuesRequest{ + Expr: []string{"app=*", "region=us-*"}, + Tag: "environment", + From: "1h", + Until: "now", + Limit: 5, + ValuePrefix: "prod", + }, + responseBody: `["production", "prod-eu", "prod-us"]`, + statusCode: 200, + expectedData: []string{"production", "prod-eu", "prod-us"}, + }, + { + name: "tag values autocomplete with empty response", + request: GraphiteTagValuesRequest{ + Expr: []string{"app=nonexistent"}, + Tag: "environment", + ValuePrefix: "staging", + }, + responseBody: `[]`, + statusCode: 200, + expectedData: []string{}, + }, + { + name: "tag values autocomplete server error", + request: GraphiteTagValuesRequest{ + Expr: []string{"invalid-expr"}, + Tag: "env", + }, + responseBody: `invalid json response`, + statusCode: 400, + expectError: true, + errorContains: "tag values autocomplete request failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockTransport := &mockRoundTripper{ + respBody: []byte(tt.responseBody), + status: tt.statusCode, + } + + dsInfo := &datasourceInfo{ + HTTPClient: &http.Client{Transport: mockTransport}, + URL: "http://graphite.example.com", + } + + service := &Service{ + logger: log.NewNullLogger(), + } + + result, statusCode, err := service.handleTagValuesAutocomplete(context.Background(), dsInfo, &tt.request) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.statusCode, statusCode) + + var tagValues []string + err = json.Unmarshal(result, &tagValues) + assert.NoError(t, err) + assert.Equal(t, tt.expectedData, tagValues) + } + + if !tt.expectError { + expectedURL := "http://graphite.example.com/tags/autoComplete/values" + assert.Contains(t, mockTransport.lastRequest.URL.String(), expectedURL) + + for _, expr := range tt.request.Expr { + assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("expr=%s", url.QueryEscape(expr))) + } + assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("tag=%s", tt.request.Tag)) + + if tt.request.From != "" { + assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("from=%s", tt.request.From)) + } + if tt.request.Until != "" { + assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("until=%s", tt.request.Until)) + } + if tt.request.Limit != 0 { + assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("limit=%d", tt.request.Limit)) + } + if tt.request.ValuePrefix != "" { + assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("valuePrefix=%s", tt.request.ValuePrefix)) + } + } + }) + } +} func TestHandleFunctions(t *testing.T) { tests := []struct { name string diff --git a/pkg/tsdb/graphite/types.go b/pkg/tsdb/graphite/types.go index 2e427f1d327..04a3c8e812a 100644 --- a/pkg/tsdb/graphite/types.go +++ b/pkg/tsdb/graphite/types.go @@ -16,7 +16,7 @@ type URLParams struct { SubPath string Method string Body io.Reader - QueryParams map[string]string + QueryParams map[string][]string Headers map[string]string } @@ -66,3 +66,12 @@ type GraphiteTagsRequest struct { Limit int `json:"limit,omitempty"` TagPrefix string `json:"tagPrefix,omitempty"` } + +type GraphiteTagValuesRequest struct { + Expr []string `json:"expr"` + Tag string `json:"tag"` + From string `json:"from"` + Until string `json:"until"` + Limit int `json:"limit,omitempty"` + ValuePrefix string `json:"valuePrefix,omitempty"` +} diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index 14b1139f3ad..2c09afd4b83 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -789,71 +789,6 @@ export class GraphiteDatasource ); } - getTags(optionalOptions: any) { - const options = optionalOptions || {}; - const params: BackendSrvRequest['params'] = {}; - - if (options.range) { - params.from = this.translateTime(options.range.from, false, options.timezone); - params.until = this.translateTime(options.range.to, true, options.timezone); - } - - const httpOptions: BackendSrvRequest = { - method: 'GET', - url: '/tags', - // for cancellations - requestId: options.requestId, - params, - }; - - return lastValueFrom( - this.doGraphiteRequest(httpOptions).pipe( - map((results: FetchResponse) => { - return _map(results.data, (tag) => { - return { - text: tag.tag, - id: tag.id, - }; - }); - }) - ) - ); - } - - getTagValues(options: any = {}) { - const params: BackendSrvRequest['params'] = {}; - - if (options.range) { - params.from = this.translateTime(options.range.from, false, options.timezone); - params.until = this.translateTime(options.range.to, true, options.timezone); - } - - const httpOptions: BackendSrvRequest = { - method: 'GET', - url: '/tags/' + this.templateSrv.replace(options.key), - // for cancellations - requestId: options.requestId, - params, - }; - - return lastValueFrom( - this.doGraphiteRequest(httpOptions).pipe( - map((results: FetchResponse) => { - if (results.data && results.data.values) { - return _map(results.data.values, (value) => { - return { - text: value.value, - id: value.id, - }; - }); - } else { - return []; - } - }) - ) - ); - } - async getTagsAutoComplete(expressions: string[], tagPrefix?: string, optionalOptions?: any) { const options = optionalOptions || {}; const params: BackendSrvRequest['params'] = { @@ -894,7 +829,7 @@ export class GraphiteDatasource return lastValueFrom(this.doGraphiteRequest(httpOptions).pipe(mapToTags())); } - getTagValuesAutoComplete(expressions: string[], tag: string, valuePrefix?: string, optionalOptions?: any) { + async getTagValuesAutoComplete(expressions: string[], tag: string, valuePrefix?: string, optionalOptions?: any) { const options = optionalOptions || {}; const params: BackendSrvRequest['params'] = { expr: _map(expressions, (expression) => this.templateSrv.replace((expression || '').trim())), @@ -911,6 +846,20 @@ export class GraphiteDatasource params.until = this.translateTime(options.range.to, true, options.timezone); } + if (config.featureToggles.graphiteBackendMode) { + const tagValues = await this.postResource('tags/autoComplete/values', { + from: typeof params.from === 'string' ? params.from : `${params.from}`, + until: typeof params.until === 'string' ? params.until : `${params.until}`, + expr: params.expr, + tag: params.tag, + valuePrefix, + limit: options.limit, + }); + return tagValues.map((tag) => ({ + text: tag, + })); + } + const httpOptions: BackendSrvRequest = { method: 'GET', url: '/tags/autoComplete/values',