Graphite: Decouple from core Grafana (#110294)

* Add lint rules

* Backend decoupling

- Add standalone files
- Add graphite query type
- Add logger to Service
- Create logger in the ProvideService method
- Use a pointer for the HTTP client provider
- Update logger usage everywhere
- Update tracer type
- Replace simplejson with json
- Add dummy CallResource and CheckHealth methods
- Update tests

* Update ConfigEditor imports

* Update types imports

* Update datasource

- Switch to using semver package
- Update imports

* Update store imports

* Update helper imports and notification creation

* Update context import

* Update version numbers and logic

* Copy array_move from core

* Test updates

* Add required files and update plugin.json

* Update core references and packages

* Remove commented code

* Update wire

* Lint

* Fix import

* Copy null type

* More lint

* Update snapshot

* Add tests

* Review

* Fix packages
This commit is contained in:
Andreas Christou
2025-09-10 18:48:29 +01:00
committed by GitHub
parent 2c1821e77b
commit d3c0980c84
35 changed files with 973 additions and 290 deletions
+2
View File
@@ -101,6 +101,8 @@ linters:
- '**/pkg/tsdb/azuremonitor/**/*'
- '**/pkg/tsdb/cloud-monitoring/*'
- '**/pkg/tsdb/cloud-monitoring/**/*'
- '**/pkg/tsdb/graphite/*'
- '**/pkg/tsdb/graphite/**/*'
- '**/pkg/tsdb/mysql/*'
- '**/pkg/tsdb/mysql/**/*'
- '**/pkg/tsdb/parca/*'
+1
View File
@@ -417,6 +417,7 @@ module.exports = [
'public/app/plugins/datasource/grafana-postgresql-datasource/**/*.{ts,tsx}',
'public/app/plugins/datasource/grafana-pyroscope-datasource/**/*.{ts,tsx}',
'public/app/plugins/datasource/grafana-testdata-datasource/**/*.{ts,tsx}',
'public/app/plugins/datasource/graphite/**/*.{ts,tsx}',
'public/app/plugins/datasource/jaeger/**/*.{ts,tsx}',
'public/app/plugins/datasource/loki/**/*.{ts,tsx}',
'public/app/plugins/datasource/loki/**/*.{ts,tsx}',
+4 -4
View File
@@ -383,9 +383,9 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
cloudwatchService := cloudwatch.ProvideService()
cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider)
elasticsearchService := elasticsearch.ProvideService(httpclientProvider)
graphiteService := graphite.ProvideService(httpclientProvider, tracingService)
influxdbService := influxdb.ProvideService(httpclientProvider, featureToggles)
tracer := otelTracer()
graphiteService := graphite.ProvideService(httpclientProvider, tracer)
influxdbService := influxdb.ProvideService(httpclientProvider, featureToggles)
lokiService := loki.ProvideService(httpclientProvider, tracer)
opentsdbService := opentsdb.ProvideService(httpclientProvider)
prometheusService := prometheus.ProvideService(httpclientProvider)
@@ -974,9 +974,9 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
cloudwatchService := cloudwatch.ProvideService()
cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider)
elasticsearchService := elasticsearch.ProvideService(httpclientProvider)
graphiteService := graphite.ProvideService(httpclientProvider, tracingService)
influxdbService := influxdb.ProvideService(httpclientProvider, featureToggles)
tracer := otelTracer()
graphiteService := graphite.ProvideService(httpclientProvider, tracer)
influxdbService := influxdb.ProvideService(httpclientProvider, featureToggles)
lokiService := loki.ProvideService(httpclientProvider, tracer)
opentsdbService := opentsdb.ProvideService(httpclientProvider)
prometheusService := prometheus.ProvideService(httpclientProvider)
@@ -209,7 +209,7 @@
"path": "public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png"
}
],
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": [
"azure",
@@ -880,7 +880,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -934,7 +934,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": [
"grafana",
@@ -1000,12 +1000,12 @@
},
"build": {},
"screenshots": null,
"version": "",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
"dependencies": {
"grafanaDependency": "",
"grafanaDependency": "\u003e=10.3.0",
"grafanaVersion": "*",
"plugins": [],
"extensions": {
@@ -1217,7 +1217,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -1325,7 +1325,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -1375,7 +1375,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -1425,7 +1425,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -1629,7 +1629,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": [
"grafana",
@@ -1734,7 +1734,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -2042,7 +2042,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -2092,7 +2092,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -2445,7 +2445,7 @@
},
"build": {},
"screenshots": null,
"version": "12.1.0-pre",
"version": "12.2.0-pre",
"updated": "",
"keywords": null
},
@@ -2467,4 +2467,4 @@
"signatureOrg": "",
"angularDetected": false
}
]
]
+52 -49
View File
@@ -16,23 +16,19 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/data"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/setting"
"go.opentelemetry.io/otel/trace"
)
var logger = log.New("tsdb.graphite")
type Service struct {
im instancemgmt.InstanceManager
tracer tracing.Tracer
tracer trace.Tracer
logger log.Logger
}
const (
@@ -40,10 +36,12 @@ const (
TargetModelField = "target"
)
func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Tracer) *Service {
func ProvideService(httpClientProvider *httpclient.Provider, tracer trace.Tracer) *Service {
logger := backend.NewLoggerWith("logger", "graphite")
return &Service{
im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)),
tracer: tracer,
logger: logger,
}
}
@@ -53,7 +51,7 @@ type datasourceInfo struct {
Id int64
}
func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc {
func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc {
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
opts, err := settings.HTTPClientOptions(ctx)
if err != nil {
@@ -89,8 +87,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return nil, fmt.Errorf("query contains no queries")
}
logger := logger.FromContext(ctx)
// get datasource info from context
dsInfo, err := s.getDSInfo(ctx, req.PluginContext)
if err != nil {
@@ -103,7 +99,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
formData url.Values
}{}
for _, query := range req.Queries {
graphiteReq, formData, emptyQuery, err := s.createGraphiteRequest(ctx, query, logger, dsInfo)
graphiteReq, formData, emptyQuery, err := s.createGraphiteRequest(ctx, query, dsInfo)
if err != nil {
return nil, err
}
@@ -124,7 +120,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
var result = backend.QueryDataResponse{}
if len(emptyQueries) != 0 {
logger.Warn("Found query models without targets", "models without targets", strings.Join(emptyQueries, "\n"))
s.logger.Warn("Found query models without targets", "models without targets", strings.Join(emptyQueries, "\n"))
// If no queries had a valid target, return an error; otherwise, attempt with the targets we have
if len(emptyQueries) == len(req.Queries) {
if result.Responses == nil {
@@ -141,7 +137,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
frames := data.Frames{}
for refId, graphiteReq := range graphiteQueries {
ctx, span := s.tracer.Start(ctx, "graphite query")
_, span := s.tracer.Start(ctx, "graphite query")
defer span.End()
targetStr := strings.Join(graphiteReq.formData["target"], ",")
span.SetAttributes(
@@ -152,7 +148,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
attribute.Int64("datasource_id", dsInfo.Id),
attribute.Int64("org_id", req.PluginContext.OrgID),
)
s.tracer.Inject(ctx, graphiteReq.req.Header, span)
res, err := dsInfo.HTTPClient.Do(graphiteReq.req)
if res != nil {
span.SetAttributes(attribute.Int("graphite.response.code", res.StatusCode))
@@ -166,11 +161,11 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
defer func() {
err := res.Body.Close()
if err != nil {
logger.Warn("Failed to close response body", "error", err)
s.logger.Warn("Failed to close response body", "error", err)
}
}()
queryFrames, err := s.toDataFrames(logger, res, refId)
queryFrames, err := s.toDataFrames(res, refId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
@@ -200,28 +195,29 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
// processQuery converts a Graphite data source query to a Graphite query target. It returns the target,
// and the model if the target is invalid
func (s *Service) processQuery(logger log.Logger, query backend.DataQuery) (string, *simplejson.Json, error) {
model, err := simplejson.NewJson(query.JSON)
func (s *Service) processQuery(query backend.DataQuery) (string, *GraphiteQuery, error) {
queryJSON := GraphiteQuery{}
err := json.Unmarshal(query.JSON, &queryJSON)
if err != nil {
return "", nil, err
}
logger.Debug("Graphite", "query", model)
currTarget := ""
if fullTarget, err := model.Get(TargetFullModelField).String(); err == nil {
currTarget = fullTarget
} else {
currTarget = model.Get(TargetModelField).MustString()
return "", &queryJSON, fmt.Errorf("failed to decode the Graphite query: %w", err)
}
s.logger.Debug("Graphite", "query", queryJSON)
currTarget := queryJSON.TargetFull
if currTarget == "" {
logger.Debug("Graphite", "empty query target", model)
return "", model, nil
currTarget = queryJSON.Target
}
if currTarget == "" {
s.logger.Debug("Graphite", "empty query target", queryJSON)
return "", &queryJSON, nil
}
target := fixIntervalFormat(currTarget)
return target, nil, nil
}
func (s *Service) createRequest(ctx context.Context, l log.Logger, dsInfo *datasourceInfo, data url.Values) (*http.Request, error) {
func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, data url.Values) (*http.Request, error) {
u, err := url.Parse(dsInfo.URL)
if err != nil {
return nil, err
@@ -230,7 +226,7 @@ func (s *Service) createRequest(ctx context.Context, l log.Logger, dsInfo *datas
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(data.Encode()))
if err != nil {
logger.Info("Failed to create request", "error", err)
s.logger.Info("Failed to create request", "error", err)
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -238,7 +234,7 @@ func (s *Service) createRequest(ctx context.Context, l log.Logger, dsInfo *datas
return req, err
}
func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQuery, logger log.Logger, dsInfo *datasourceInfo) (*http.Request, url.Values, *simplejson.Json, error) {
func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQuery, dsInfo *datasourceInfo) (*http.Request, url.Values, *GraphiteQuery, error) {
/*
graphite doc about from and until, with sdk we are getting absolute instead of relative time
https://graphite-api.readthedocs.io/en/latest/api.html#from-until
@@ -252,23 +248,21 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ
"target": []string{},
}
target, emptyQuery, err := s.processQuery(logger, query)
target, emptyQuery, err := s.processQuery(query)
if err != nil {
return nil, formData, nil, err
}
if emptyQuery != nil {
logger.Debug("Graphite", "empty query target", emptyQuery)
s.logger.Debug("Graphite", "empty query target", emptyQuery)
return nil, formData, emptyQuery, nil
}
formData["target"] = []string{target}
if setting.Env == setting.Dev {
logger.Debug("Graphite request", "params", formData)
}
s.logger.Debug("Graphite request", "params", formData)
graphiteReq, err := s.createRequest(ctx, logger, dsInfo, formData)
graphiteReq, err := s.createRequest(ctx, dsInfo, formData)
if err != nil {
return nil, formData, nil, err
}
@@ -276,34 +270,34 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ
return graphiteReq, formData, emptyQuery, nil
}
func (s *Service) parseResponse(logger log.Logger, res *http.Response) ([]TargetResponseDTO, error) {
func (s *Service) parseResponse(res *http.Response) ([]TargetResponseDTO, error) {
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
defer func() {
if err := res.Body.Close(); err != nil {
logger.Warn("Failed to close response body", "err", err)
s.logger.Warn("Failed to close response body", "err", err)
}
}()
if res.StatusCode/100 != 2 {
logger.Info("Request failed", "status", res.Status, "body", string(body))
s.logger.Info("Request failed", "status", res.Status, "body", string(body))
return nil, fmt.Errorf("request failed, status: %s", res.Status)
}
var data []TargetResponseDTO
err = json.Unmarshal(body, &data)
if err != nil {
logger.Info("Failed to unmarshal graphite response", "error", err, "status", res.Status, "body", string(body))
s.logger.Info("Failed to unmarshal graphite response", "error", err, "status", res.Status, "body", string(body))
return nil, err
}
return data, nil
}
func (s *Service) toDataFrames(logger log.Logger, response *http.Response, refId string) (frames data.Frames, error error) {
responseData, err := s.parseResponse(logger, response)
func (s *Service) toDataFrames(response *http.Response, refId string) (frames data.Frames, error error) {
responseData, err := s.parseResponse(response)
if err != nil {
return nil, err
}
@@ -340,9 +334,7 @@ func (s *Service) toDataFrames(logger log.Logger, response *http.Response, refId
data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: series.Target})).SetMeta(
&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}))
if setting.Env == setting.Dev {
logger.Debug("Graphite response", "target", series.Target, "datapoints", len(series.DataPoints))
}
s.logger.Debug("Graphite response", "target", series.Target, "datapoints", len(series.DataPoints))
}
return frames, nil
}
@@ -381,3 +373,14 @@ func parseDataTimePoint(dataTimePoint DataTimePoint) (time.Time, *float64, error
return timestamp, nil, nil
}
}
func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
return nil
}
func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
return &backend.CheckHealthResult{
Status: backend.HealthStatusOk,
Message: "Successfully connected to Graphite.",
}, nil
}
+19 -16
View File
@@ -12,13 +12,11 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/tracing"
"go.opentelemetry.io/otel/trace/noop"
)
func TestFixIntervalFormat(t *testing.T) {
@@ -58,8 +56,9 @@ func TestFixIntervalFormat(t *testing.T) {
}
func TestProcessQuery(t *testing.T) {
service := &Service{}
log := logger.FromContext(context.Background())
service := &Service{
logger: backend.Logger,
}
t.Run("Parses single valid query", func(t *testing.T) {
queries := []backend.DataQuery{
{
@@ -69,7 +68,7 @@ func TestProcessQuery(t *testing.T) {
}`),
},
}
target, jsonModel, err := service.processQuery(log, queries[0])
target, jsonModel, err := service.processQuery(queries[0])
assert.NoError(t, err)
assert.Nil(t, jsonModel)
assert.Equal(t, "app.grafana.*.dashboards.views.1M.count", target)
@@ -84,10 +83,10 @@ func TestProcessQuery(t *testing.T) {
}`),
},
}
jsonEqual, _ := simplejson.NewJson([]byte(`{"target": ""}`))
target, jsonModel, err := service.processQuery(log, queries[0])
emptyQuery := GraphiteQuery{Target: ""}
target, jsonModel, err := service.processQuery(queries[0])
assert.NoError(t, err)
assert.Equal(t, jsonEqual, jsonModel)
assert.Equal(t, &emptyQuery, jsonModel)
assert.Equal(t, "", target)
})
@@ -107,7 +106,7 @@ func TestProcessQuery(t *testing.T) {
},
}
service := ProvideService(httpclient.NewProvider(), tracing.NewNoopTracerService())
service := ProvideService(httpclient.NewProvider(), noop.NewTracerProvider().Tracer("graphite-tests"))
rsp, err := service.QueryData(context.Background(), &backend.QueryDataRequest{
PluginContext: backend.PluginContext{
@@ -124,7 +123,9 @@ func TestProcessQuery(t *testing.T) {
})
t.Run("QueryData with no queries returns an error", func(t *testing.T) {
service := &Service{}
service := &Service{
logger: backend.Logger,
}
rsp, err := service.QueryData(context.Background(), &backend.QueryDataRequest{})
assert.Nil(t, rsp)
@@ -143,7 +144,7 @@ func TestProcessQuery(t *testing.T) {
}))
t.Cleanup(server.Close)
service := ProvideService(httpclient.NewProvider(), tracing.NewNoopTracerService())
service := ProvideService(httpclient.NewProvider(), noop.NewTracerProvider().Tracer("graphite-tests"))
queries := []backend.DataQuery{
{
@@ -175,7 +176,9 @@ func TestProcessQuery(t *testing.T) {
}
func TestConvertResponses(t *testing.T) {
service := &Service{}
service := &Service{
logger: backend.Logger,
}
t.Run("Converts response without tags to data frames", func(*testing.T) {
body := `
@@ -195,7 +198,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(logger, httpResponse, refId)
dataFrames, err := service.toDataFrames(httpResponse, refId)
require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -229,7 +232,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(logger, httpResponse, refId)
dataFrames, err := service.toDataFrames(httpResponse, refId)
require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) {
+143
View File
@@ -0,0 +1,143 @@
// Copied from Grafana core https://github.com/grafana/grafana/blob/main/pkg/components/null/float.go
// Graphite DataPoints utilise the Float type here https://github.com/grafana/grafana/blob/main/pkg/tsdb/graphite/types.go#L10
package graphite
import (
"database/sql"
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
)
const (
nullString = "null"
)
// Float is a nullable float64.
// It does not consider zero values to be null.
// It will decode to null, not zero, if null.
type Float struct {
sql.NullFloat64
}
// NewFloat creates a new Float
func NewFloat(f float64, valid bool) Float {
return Float{
NullFloat64: sql.NullFloat64{
Float64: f,
Valid: valid,
},
}
}
// FloatFrom creates a new Float that will always be valid.
func FloatFrom(f float64) Float {
return NewFloat(f, true)
}
// FloatFromPtr creates a new Float that be null if f is nil.
func FloatFromPtr(f *float64) Float {
if f == nil {
return NewFloat(0, false)
}
return NewFloat(*f, true)
}
// FloatFromString creates a new Float from string f.
// If the string is equal to the value of nullString then the Float will be null.
// An empty string f will return an error.
func FloatFromString(f string, nullString string) (Float, error) {
if f == nullString {
return FloatFromPtr(nil), nil
}
fV, err := strconv.ParseFloat(f, 64)
if err != nil {
return Float{}, err
}
return FloatFrom(fV), nil
}
// UnmarshalJSON implements json.Unmarshaler.
// It supports number and null input.
// 0 will not be considered a null Float.
// It also supports unmarshaling a sql.NullFloat64.
func (f *Float) UnmarshalJSON(data []byte) error {
var err error
var v any
if err = json.Unmarshal(data, &v); err != nil {
return err
}
switch x := v.(type) {
case float64:
f.Float64 = x
case map[string]any:
err = json.Unmarshal(data, &f.NullFloat64)
case nil:
f.Valid = false
return nil
default:
err = fmt.Errorf("json: cannot unmarshal %v into Go value of type null.Float", reflect.TypeOf(v).Name())
}
f.Valid = err == nil
return err
}
// UnmarshalText implements encoding.TextUnmarshaler.
// It will unmarshal to a null Float if the input is a blank or not an integer.
// It will return an error if the input is not an integer, blank, or "null".
func (f *Float) UnmarshalText(text []byte) error {
str := string(text)
if str == "" || str == nullString {
f.Valid = false
return nil
}
var err error
f.Float64, err = strconv.ParseFloat(string(text), 64)
f.Valid = err == nil
return err
}
// MarshalJSON implements json.Marshaler.
// It will encode null if this Float is null, NaN, of Inf.
func (f Float) MarshalJSON() ([]byte, error) {
if !f.Valid || math.IsNaN(f.Float64) || math.IsInf(f.Float64, 0) {
return []byte(nullString), nil
}
return []byte(strconv.FormatFloat(f.Float64, 'f', -1, 64)), nil
}
// MarshalText implements encoding.TextMarshaler.
// It will encode a blank string if this Float is null.
func (f Float) MarshalText() ([]byte, error) {
if !f.Valid {
return []byte{}, nil
}
return []byte(strconv.FormatFloat(f.Float64, 'f', -1, 64)), nil
}
// MarshalText implements encoding.TextMarshaler.
// It will encode a blank string if this Float is null.
func (f Float) String() string {
if !f.Valid {
return nullString
}
return fmt.Sprintf("%1.3f", f.Float64)
}
// FullString returns float as string in full precision
func (f Float) FullString() string {
if !f.Valid {
return nullString
}
return fmt.Sprintf("%f", f.Float64)
}
// IsZero returns true for invalid Floats, for future omitempty support (Go 1.4?)
// A non-null Float with a 0 value will not be considered zero.
func (f Float) IsZero() bool {
return !f.Valid
}
+238
View File
@@ -0,0 +1,238 @@
package graphite
import (
"database/sql"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewFloat(t *testing.T) {
tests := []struct {
name string
input float64
valid bool
expected Float
}{
{
name: "valid float",
input: 42.5,
valid: true,
expected: Float{
NullFloat64: sql.NullFloat64{Float64: 42.5, Valid: true},
},
},
{
name: "invalid float",
input: 42.5,
valid: false,
expected: Float{
NullFloat64: sql.NullFloat64{Float64: 42.5, Valid: false},
},
},
{
name: "zero",
input: 0,
valid: true,
expected: Float{
NullFloat64: sql.NullFloat64{Float64: 0, Valid: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NewFloat(tt.input, tt.valid)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFloatFrom(t *testing.T) {
result := FloatFrom(123.456)
expected := Float{NullFloat64: sql.NullFloat64{Float64: 123.456, Valid: true}}
assert.Equal(t, expected, result)
}
func TestFloatFromPtr(t *testing.T) {
f := 42.5
result := FloatFromPtr(&f)
expected := Float{NullFloat64: sql.NullFloat64{Float64: 42.5, Valid: true}}
assert.Equal(t, expected, result)
result = FloatFromPtr(nil)
expected = Float{NullFloat64: sql.NullFloat64{Valid: false}}
assert.Equal(t, expected, result)
}
func TestFloatFromString(t *testing.T) {
result, err := FloatFromString("123.456", "null")
require.NoError(t, err)
expected := Float{NullFloat64: sql.NullFloat64{Float64: 123.456, Valid: true}}
assert.Equal(t, expected, result)
result, err = FloatFromString("null", "null")
require.NoError(t, err)
expected = Float{NullFloat64: sql.NullFloat64{Valid: false}}
assert.Equal(t, expected, result)
_, err = FloatFromString("number", "null")
assert.Error(t, err)
}
func TestFloat_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input []byte
expected Float
expectError bool
}{
{
name: "valid float",
input: []byte("123.456"),
expected: Float{
NullFloat64: sql.NullFloat64{Float64: 123.456, Valid: true},
},
},
{
name: "null",
input: []byte("null"),
expected: Float{
NullFloat64: sql.NullFloat64{Valid: false},
},
},
{
name: "sql.NullFloat64 - valid",
input: []byte(`{"Float64": 42.5, "Valid": true}`),
expected: Float{
NullFloat64: sql.NullFloat64{Float64: 42.5, Valid: true},
},
},
{
name: "sql.NullFloat64 - invalid",
input: []byte(`{"Float64": 0, "Valid": false}`),
expected: Float{
NullFloat64: sql.NullFloat64{Float64: 0, Valid: true}, // Valid gets overridden to true when unmarshaling succeeds
},
},
{
name: "invalid JSON",
input: []byte("invalid"),
expectError: true,
},
{
name: "string input",
input: []byte(`"123.456"`),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result Float
err := result.UnmarshalJSON(tt.input)
if tt.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestFloat_UnmarshalText(t *testing.T) {
var f Float
err := f.UnmarshalText([]byte("123.456"))
require.NoError(t, err)
expected := Float{NullFloat64: sql.NullFloat64{Float64: 123.456, Valid: true}}
assert.Equal(t, expected, f)
var f2 Float
err = f2.UnmarshalText([]byte(""))
require.NoError(t, err)
expected = Float{NullFloat64: sql.NullFloat64{Valid: false}}
assert.Equal(t, expected, f2)
var f3 Float
err = f3.UnmarshalText([]byte("null"))
require.NoError(t, err)
expected = Float{NullFloat64: sql.NullFloat64{Valid: false}}
assert.Equal(t, expected, f3)
var f4 Float
err = f4.UnmarshalText([]byte("not-a-number"))
assert.Error(t, err)
}
func TestFloat_MarshalJSON(t *testing.T) {
f := Float{NullFloat64: sql.NullFloat64{Float64: 123.456, Valid: true}}
data, err := f.MarshalJSON()
require.NoError(t, err)
assert.Equal(t, "123.456", string(data))
f = Float{NullFloat64: sql.NullFloat64{Valid: false}}
data, err = f.MarshalJSON()
require.NoError(t, err)
assert.Equal(t, "null", string(data))
}
func TestFloat_MarshalText(t *testing.T) {
f := Float{NullFloat64: sql.NullFloat64{Float64: 123.456, Valid: true}}
data, err := f.MarshalText()
require.NoError(t, err)
assert.Equal(t, "123.456", string(data))
f = Float{NullFloat64: sql.NullFloat64{Valid: false}}
data, err = f.MarshalText()
require.NoError(t, err)
assert.Equal(t, "", string(data))
}
func TestFloat_String(t *testing.T) {
f := Float{NullFloat64: sql.NullFloat64{Float64: 123.456789, Valid: true}}
assert.Equal(t, "123.457", f.String())
f = Float{NullFloat64: sql.NullFloat64{Valid: false}}
assert.Equal(t, "null", f.String())
}
func TestFloat_FullString(t *testing.T) {
f := Float{NullFloat64: sql.NullFloat64{Float64: 123.456789, Valid: true}}
assert.Equal(t, "123.456789", f.FullString())
f = Float{NullFloat64: sql.NullFloat64{Valid: false}}
assert.Equal(t, "null", f.FullString())
}
func TestFloat_IsZero(t *testing.T) {
f := Float{NullFloat64: sql.NullFloat64{Float64: 0, Valid: true}}
assert.False(t, f.IsZero())
f = Float{NullFloat64: sql.NullFloat64{Valid: false}}
assert.True(t, f.IsZero())
}
func TestFloat_JSONRoundTrip(t *testing.T) {
testCases := []Float{
{NullFloat64: sql.NullFloat64{Float64: 123.456, Valid: true}},
{NullFloat64: sql.NullFloat64{Float64: 0, Valid: true}},
{NullFloat64: sql.NullFloat64{Valid: false}},
}
for _, original := range testCases {
// Marshal to JSON
data, err := json.Marshal(original)
require.NoError(t, err)
// Unmarshal back
var result Float
err = json.Unmarshal(data, &result)
require.NoError(t, err)
// Compare
assert.Equal(t, original, result)
}
}
@@ -0,0 +1,47 @@
package main
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
graphite "github.com/grafana/grafana/pkg/tsdb/graphite"
)
var (
_ backend.QueryDataHandler = (*Datasource)(nil)
_ backend.CheckHealthHandler = (*Datasource)(nil)
_ backend.CallResourceHandler = (*Datasource)(nil)
)
func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return &Datasource{
Service: graphite.ProvideService(httpclient.NewProvider(), tracing.DefaultTracer()),
}, nil
}
type Datasource struct {
Service *graphite.Service
}
func contextualMiddlewares(ctx context.Context) context.Context {
cfg := backend.GrafanaConfigFromContext(ctx)
return httpclient.WithContextualMiddleware(ctx, httpclient.ResponseLimitMiddleware(cfg.ResponseLimit()))
}
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
ctx = contextualMiddlewares(ctx)
return d.Service.QueryData(ctx, req)
}
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
ctx = contextualMiddlewares(ctx)
return d.Service.CallResource(ctx, req, sender)
}
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
ctx = contextualMiddlewares(ctx)
return d.Service.CheckHealth(ctx, req)
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"os"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
)
func main() {
// Start listening to requests sent from Grafana. This call is blocking so
// it won't finish until Grafana shuts down the process or the plugin choose
// to exit by itself using os.Exit. Manage automatically manages life cycle
// of datasource instances. It accepts datasource instance factory as first
// argument. This factory will be automatically called on incoming request
// from Grafana to create different instances of SampleDatasource (per datasource
// ID). When datasource configuration changed Dispose method will be called and
// new datasource instance created using NewSampleDatasource factory.
if err := datasource.Manage("graphite", NewDatasource, datasource.ManageOpts{}); err != nil {
log.DefaultLogger.Error(err.Error())
os.Exit(1)
}
}
+10 -5
View File
@@ -1,9 +1,5 @@
package graphite
import (
"github.com/grafana/grafana/pkg/components/null"
)
type TargetResponseDTO struct {
Target string `json:"target"`
DataPoints DataTimeSeriesPoints `json:"datapoints"`
@@ -11,5 +7,14 @@ type TargetResponseDTO struct {
Tags map[string]any `json:"tags"`
}
type DataTimePoint [2]null.Float
type DataTimePoint [2]Float
type DataTimeSeriesPoints []DataTimePoint
type GraphiteQuery struct {
QueryType string `json:"queryType"`
TextEditor *bool `json:"textEditor,omitempty"`
Target string `json:"target,omitempty"`
TargetFull string `json:"targetFull,omitempty"`
Tags []string `json:"tags,omitempty"`
FromAnnotations *bool `json:"fromAnnotations,omitempty"`
}
@@ -1,5 +1,3 @@
const graphitePlugin = async () =>
await import(/* webpackChunkName: "graphitePlugin" */ 'app/plugins/datasource/graphite/module');
const cloudwatchPlugin = async () =>
await import(/* webpackChunkName: "cloudwatchPlugin" */ 'app/plugins/datasource/cloudwatch/module');
const dashboardDSPlugin = async () =>
@@ -72,7 +70,6 @@ const nodeGraph = async () =>
const builtInPlugins: Record<string, System.Module | (() => Promise<System.Module>)> = {
// datasources
'core:plugin/graphite': graphitePlugin,
'core:plugin/cloudwatch': cloudwatchPlugin,
'core:plugin/dashboard': dashboardDSPlugin,
'core:plugin/elasticsearch': elasticsearchPlugin,
@@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css';
import { PureComponent } from 'react';
import { MetadataInspectorProps, rangeUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import { GraphiteDatasource } from '../datasource';
import { getRollupNotice, getRuntimeConsolidationNotice, parseSchemaRetentions } from '../meta';
@@ -5,10 +5,10 @@ import {
updateDatasourcePluginJsonDataOption,
onUpdateDatasourceJsonDataOptionSelect,
onUpdateDatasourceJsonDataOptionChecked,
store,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, DataSourceHttpSettings, Field, FieldSet, Select, Switch } from '@grafana/ui';
import { config } from 'app/core/config';
import store from 'app/core/store';
import { GraphiteOptions, GraphiteType } from '../types';
import { DEFAULT_GRAPHITE_VERSION, GRAPHITE_VERSIONS } from '../versions';
@@ -1,7 +1,6 @@
import { isArray } from 'lodash';
import moment from 'moment';
import { of } from 'rxjs';
import { createFetchResponse } from 'test/helpers/createFetchResponse';
import {
AbstractLabelMatcher,
@@ -11,19 +10,27 @@ import {
dateTime,
getFrameDisplayName,
MetricFindValue,
ScopedVars,
} from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
import { TemplateSrv } from 'app/features/templating/template_srv';
import { BackendSrvRequest, FetchResponse, getTemplateSrv, TemplateSrv, VariableInterpolation } from '@grafana/runtime';
import { fromString } from './configuration/parseLokiLabelMappings';
import { GraphiteDatasource } from './datasource';
import { GraphiteQuery, GraphiteQueryType } from './types';
import { DEFAULT_GRAPHITE_VERSION } from './versions';
const fetchMock = jest.fn();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
...(jest.requireActual('@grafana/runtime') as unknown as object),
getBackendSrv: () => ({
fetch: fetchMock,
}),
getTemplateSrv: () => {
return {
replace: (s: string) => s,
};
},
}));
interface Context {
@@ -31,9 +38,19 @@ interface Context {
ds: GraphiteDatasource;
}
describe('graphiteDatasource', () => {
const fetchMock = jest.spyOn(backendSrv, 'fetch');
const createFetchResponse = <T>(data: T): FetchResponse<T> => ({
data,
status: 200,
url: 'http://localhost:3000/api/query',
config: { url: 'http://localhost:3000/api/query' },
type: 'basic',
statusText: 'Ok',
redirected: false,
headers: new Headers(),
ok: true,
});
describe('graphiteDatasource', () => {
let ctx = {} as Context;
beforeEach(() => {
@@ -46,8 +63,9 @@ describe('graphiteDatasource', () => {
rollupIndicatorEnabled: true,
},
};
const templateSrv = new TemplateSrv();
const templateSrv = getTemplateSrv();
const ds = new GraphiteDatasource(instanceSettings, templateSrv);
ctx = { templateSrv, ds };
});
@@ -449,7 +467,22 @@ describe('graphiteDatasource', () => {
});
it('should replace target placeholder when nesting query references with template variables', () => {
ctx.templateSrv.init([{ type: 'query', name: 'metric', current: { value: ['aMetricName'] } }]);
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('[[metric]]')) {
return target.replaceAll('[[metric]]', 'aMetricName');
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
const originalTargetMap = {
A: '[[metric]]',
B: 'sumSeries(#A)',
@@ -465,7 +498,22 @@ describe('graphiteDatasource', () => {
});
it('should use scoped variables when nesting query references', () => {
ctx.templateSrv.init([{ type: 'query', name: 'metric', current: { value: ['globalValue'] } }]);
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('$metric')) {
return target.replaceAll('$metric', 'scopedValue');
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
const originalTargetMap = {
A: '$metric',
@@ -488,7 +536,22 @@ describe('graphiteDatasource', () => {
});
it('should apply scoped variables to nested references with hidden targets', () => {
ctx.templateSrv.init([{ type: 'query', name: 'server', current: { value: ['global'] } }]);
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('$server')) {
return target.replaceAll('$server', scopedVars?.server?.value);
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
const originalTargetMap = {
A: '$server.cpu',
@@ -594,13 +657,22 @@ describe('graphiteDatasource', () => {
describe('when formatting targets', () => {
it('does not attempt to glob for one variable', () => {
ctx.templateSrv.init([
{
type: 'query',
name: 'metric',
current: { value: ['b'] },
},
]);
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('$metric')) {
return target.replaceAll('$metric', 'b');
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
const originalTargetMap = {
A: 'my.$metric.*',
@@ -615,13 +687,23 @@ describe('graphiteDatasource', () => {
});
it('globs for more than one variable', () => {
ctx.templateSrv.init([
{
type: 'query',
name: 'metric',
current: { value: ['a', 'b'] },
},
]);
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('[[metric]]')) {
return target.replaceAll('[[metric]]', '{a,b}');
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
const originalTargetMap = { A: 'my.[[metric]].*' };
const results = ctx.ds.buildGraphiteParams(
{
@@ -721,13 +803,22 @@ describe('graphiteDatasource', () => {
});
it('/metrics/find should be POST', () => {
ctx.templateSrv.init([
{
type: 'query',
name: 'foo',
current: { value: ['bar'] },
},
]);
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('[[foo]]')) {
return target.replaceAll('[[foo]]', 'bar');
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
ctx.ds.metricFindQuery('[[foo]]').then((data) => {
results = data;
});
@@ -739,6 +830,22 @@ describe('graphiteDatasource', () => {
});
it('should interpolate $__searchFilter with searchFilter', () => {
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('$__searchFilter')) {
return target.replaceAll('$__searchFilter', 'backend*');
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
ctx.ds.metricFindQuery('app.$__searchFilter', { searchFilter: 'backend' }).then((data) => {
results = data;
});
@@ -750,6 +857,22 @@ describe('graphiteDatasource', () => {
});
it('should interpolate $__searchFilter with default when searchFilter is missing', () => {
const originalReplace = ctx.templateSrv.replace;
ctx.templateSrv.replace = jest
.fn()
.mockImplementation(
(
target?: string | undefined,
scopedVars?: ScopedVars | undefined,
format?: string | Function,
interpolations?: VariableInterpolation[]
): string => {
if (target?.includes('$__searchFilter')) {
return target.replaceAll('$__searchFilter', '*');
}
return originalReplace(target, scopedVars, format, interpolations);
}
);
ctx.ds.metricFindQuery('app.$__searchFilter', {}).then((data) => {
results = data;
});
@@ -988,7 +1111,7 @@ describe('graphiteDatasource', () => {
function accessScenario(name: string, url: string, fn: ({ headers }: { headers: Record<string, unknown> }) => void) {
describe('access scenario ' + name, () => {
const ctx = {
templateSrv: new TemplateSrv(),
templateSrv: getTemplateSrv(),
instanceSettings: { url: 'url', name: 'graphiteProd', jsonData: {} },
};
@@ -2,6 +2,7 @@ import { map as _map, each, indexOf, isArray, isString } from 'lodash';
import moment from 'moment';
import { lastValueFrom, merge, Observable, of, OperatorFunction, pipe, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { coerce, gte, SemVer, valid } from 'semver';
import {
AbstractLabelMatcher,
@@ -22,16 +23,14 @@ import {
TimeRange,
toDataFrame,
} from '@grafana/data';
import { BackendSrvRequest, FetchResponse, getBackendSrv } from '@grafana/runtime';
import { BackendSrvRequest, FetchResponse, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { isVersionGtOrEq, SemVersion } from 'app/core/utils/version';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
import { getRollupNotice, getRuntimeConsolidationNotice } from 'app/plugins/datasource/graphite/meta';
import { AnnotationEditor } from './components/AnnotationsEditor';
import { convertToGraphiteQueryObject } from './components/helpers';
import gfunc, { FuncDef, FuncDefs, FuncInstance } from './gfunc';
import GraphiteQueryModel from './graphite_query';
import { getRollupNotice, getRuntimeConsolidationNotice } from './meta';
import { prepareAnnotation } from './migrations';
// Types
import {
@@ -889,8 +888,8 @@ export class GraphiteDatasource
this.doGraphiteRequest(httpOptions).pipe(
map((results: FetchResponse) => {
if (results.data) {
const semver = new SemVersion(results.data);
return semver.isValid() ? results.data : '';
const semver = new SemVer(results.data);
return valid(semver) ? results.data : '';
}
return '';
}),
@@ -1079,11 +1078,19 @@ export class GraphiteDatasource
}
function supportsTags(version: string): boolean {
return isVersionGtOrEq(version, '1.1');
const fullVersion = coerce(version);
if (!fullVersion) {
return false;
}
return gte(fullVersion, '1.1.0');
}
function supportsFunctionIndex(version: string): boolean {
return isVersionGtOrEq(version, '1.1');
const fullVersion = coerce(version);
if (!fullVersion) {
return false;
}
return gte(fullVersion, '1.1.0');
}
function mapToTags(): OperatorFunction<FetchResponse, Array<{ text: string }>> {
@@ -1,10 +1,6 @@
import { of } from 'rxjs';
import { EventBusExtended } from '@grafana/data';
import { setBackendSrv } from '@grafana/runtime';
import { BackendSrv } from 'app/core/services/backend_srv';
import { ContextSrv, User } from '../../../core/services/context_srv';
import { BackendSrv, getBackendSrv, setBackendSrv } from '@grafana/runtime';
import { GraphiteDatasource } from './datasource';
@@ -12,12 +8,13 @@ interface Context {
ds: GraphiteDatasource;
}
let origBackendSrv: BackendSrv;
describe('graphiteDatasource integration with backendSrv and fetch', () => {
let ctx = {} as Context;
beforeEach(() => {
jest.clearAllMocks();
origBackendSrv = getBackendSrv();
const instanceSettings = {
url: '/api/datasources/proxy/1',
name: 'graphiteProd',
@@ -29,12 +26,15 @@ describe('graphiteDatasource integration with backendSrv and fetch', () => {
ctx = { ds };
});
afterEach(() => {
setBackendSrv(origBackendSrv);
});
describe('returns a list of functions', () => {
it('should return a list of functions with invalid JSON', async () => {
const INVALID_JSON =
'{"testFunction":{"name":"function","description":"description","module":"graphite.render.functions","group":"Transform","params":[{"name":"param","type":"intOrInf","required":true,"default":Infinity}]}}';
mockBackendSrv(INVALID_JSON);
setBackendSrv({ ...origBackendSrv, fetch: jest.fn().mockReturnValue(of({ data: INVALID_JSON })) });
const funcDefs = await ctx.ds.getFuncDefs();
@@ -61,8 +61,7 @@ describe('graphiteDatasource integration with backendSrv and fetch', () => {
it('should return a list of functions with valid JSON', async () => {
const VALID_JSON =
'{"testFunction":{"name":"function","description":"description","module":"graphite.render.functions","group":"Transform","params":[{"name":"param","type":"intOrInf","required":true,"default":1e9999}]}}';
mockBackendSrv(VALID_JSON);
setBackendSrv({ ...origBackendSrv, fetch: jest.fn().mockReturnValue(of({ data: VALID_JSON })) });
const funcDefs = await ctx.ds.getFuncDefs();
@@ -87,62 +86,3 @@ describe('graphiteDatasource integration with backendSrv and fetch', () => {
});
});
});
function mockBackendSrv(data: string) {
const defaults = {
data: '',
ok: true,
status: 200,
statusText: 'Ok',
isSignedIn: true,
orgId: 1337,
redirected: false,
type: 'basic',
url: 'http://localhost:3000/api/some-mock',
};
const props = { ...defaults };
props.data = data;
const textMock = jest.fn().mockResolvedValue(props.data);
const fromFetchMock = jest.fn().mockImplementation(() => {
const mockedResponse = {
ok: props.ok,
status: props.status,
statusText: props.statusText,
text: textMock,
redirected: false,
type: 'basic',
url: 'http://localhost:3000/api/some-mock',
headers: new Headers({
method: 'GET',
url: '/functions',
// to work around Graphite returning invalid JSON
responseType: 'text',
}),
};
return of(mockedResponse);
});
const appEventsMock = {} as EventBusExtended;
const user: User = {
isSignedIn: props.isSignedIn,
orgId: props.orgId,
} as unknown as User;
const contextSrvMock: ContextSrv = {
user,
} as unknown as ContextSrv;
const logoutMock = jest.fn();
const mockedBackendSrv = new BackendSrv({
fromFetch: fromFetchMock,
appEvents: appEventsMock,
contextSrv: contextSrvMock,
logout: logoutMock,
});
setBackendSrv(mockedBackendSrv);
}
+37 -32
View File
@@ -1,7 +1,7 @@
import { assign, each, filter, forEach, get, includes, isString, last, map, toString, isFinite } from 'lodash';
import { assign, each, filter, forEach, get, includes, isFinite, isString, last, map, toString } from 'lodash';
import { coerce, gte } from 'semver';
import { InterpolateFunction } from '@grafana/data';
import { isVersionGtOrEq } from 'app/core/utils/version';
export type ParamDef = {
name: string;
@@ -665,7 +665,7 @@ addFuncDef({
},
],
defaultParams: ['avg'],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -673,7 +673,7 @@ addFuncDef({
category: 'Filter Series',
params: [{ name: 'n', type: 'int' }],
defaultParams: [95],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -681,7 +681,7 @@ addFuncDef({
category: 'Transform',
params: [{ name: 'steps', type: 'int' }],
defaultParams: [1],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -695,7 +695,7 @@ addFuncDef({
},
],
defaultParams: [10],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -703,7 +703,7 @@ addFuncDef({
category: 'Special',
params: [{ name: 'fallback', type: 'string' }],
defaultParams: ['constantLine(0)'],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -711,7 +711,7 @@ addFuncDef({
category: 'Filter Series',
params: [{ name: 'grep', type: 'string' }],
defaultParams: ['grep'],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -731,7 +731,7 @@ addFuncDef({
},
],
defaultParams: ['sum', 3],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -745,7 +745,7 @@ addFuncDef({
},
],
defaultParams: ['1d'],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -753,19 +753,19 @@ addFuncDef({
category: 'Transform',
params: [{ name: 'limit', type: 'int', optional: true }],
defaultParams: [],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
name: 'invert',
category: 'Transform',
version: '1.0',
version: '1.0.0',
});
addFuncDef({
name: 'isNonNull',
category: 'Combine',
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -786,7 +786,7 @@ addFuncDef({
},
],
defaultParams: [],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -795,7 +795,7 @@ addFuncDef({
params: [{ name: 'node', type: 'int' }],
defaultParams: [3],
category: 'Combine',
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -809,7 +809,7 @@ addFuncDef({
},
],
defaultParams: [10],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -823,7 +823,7 @@ addFuncDef({
},
],
defaultParams: [10],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -837,7 +837,7 @@ addFuncDef({
},
],
defaultParams: [10],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -852,13 +852,13 @@ addFuncDef({
},
],
defaultParams: [2],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
name: 'offsetToZero',
category: 'Transform',
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -866,7 +866,7 @@ addFuncDef({
category: 'Transform',
params: [{ name: 'factor', type: 'int' }],
defaultParams: [10],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -874,7 +874,7 @@ addFuncDef({
category: 'Transform',
params: optionalSeriesRefArgs,
defaultParams: [''],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -895,7 +895,7 @@ addFuncDef({
],
defaultParams: ['asPercent', 2, 'used_bytes'],
category: 'Combine',
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -903,19 +903,19 @@ addFuncDef({
category: 'Filter Series',
params: [{ name: 'n', type: 'int' }],
defaultParams: [95],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
name: 'removeEmptySeries',
category: 'Filter Series',
version: '1.0',
version: '1.0.0',
});
addFuncDef({
name: 'squareRoot',
category: 'Transform',
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -935,7 +935,7 @@ addFuncDef({
},
],
defaultParams: ['-1h'],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
@@ -950,14 +950,14 @@ addFuncDef({
},
],
defaultParams: ['#A', 4],
version: '1.0',
version: '1.0.0',
});
addFuncDef({
name: 'seriesByTag',
category: 'Special',
params: [{ name: 'tagExpression', type: 'string', multiple: true }],
version: '1.1',
version: '1.1.0',
});
addFuncDef({
@@ -972,7 +972,7 @@ addFuncDef({
{ name: 'tag', type: 'string', multiple: true },
],
defaultParams: ['sum', 'tag'],
version: '1.1',
version: '1.1.0',
});
addFuncDef({
@@ -980,11 +980,16 @@ addFuncDef({
category: 'Alias',
params: [{ name: 'tag', type: 'string', multiple: true }],
defaultParams: ['tag'],
version: '1.1',
version: '1.1.0',
});
function isVersionRelatedFunction(obj: { version?: string }, graphiteVersion: string) {
return !obj.version || isVersionGtOrEq(graphiteVersion, obj.version);
const fullVersion = coerce(graphiteVersion);
if (!fullVersion) {
return false;
}
return !obj.version || gte(fullVersion, obj.version);
}
export class FuncInstance {
@@ -2,12 +2,12 @@ import { compact, each, findIndex, flatten, get, join, keyBy, last, map, reduce,
import { ScopedVars } from '@grafana/data';
import { TemplateSrv } from '@grafana/runtime';
import { arrayMove } from 'app/core/utils/arrayMove';
import { GraphiteDatasource } from './datasource';
import { FuncInstance } from './gfunc';
import { AstNode, Parser } from './parser';
import { GraphiteSegment } from './types';
import { arrayMove } from './utils';
export type GraphiteTagOperator = '=' | '=~' | '!=' | '!=~';
@@ -0,0 +1 @@
import '@grafana/plugin-configs/jest/jest-setup';
@@ -0,0 +1,3 @@
import defaultConfig from '@grafana/plugin-configs/jest/jest.config.js';
export default defaultConfig;
@@ -0,0 +1,56 @@
{
"name": "@grafana-plugins/graphite",
"description": "Graphite data source plugin for Grafana",
"private": true,
"version": "12.2.0-pre",
"dependencies": {
"@emotion/css": "11.13.5",
"@grafana/data": "12.2.0-pre",
"@grafana/plugin-ui": "^0.10.10",
"@grafana/runtime": "12.2.0-pre",
"@grafana/schema": "12.2.0-pre",
"@grafana/ui": "12.2.0-pre",
"@reduxjs/toolkit": "2.8.2",
"lodash": "4.17.21",
"moment": "2.30.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-use": "17.6.0",
"redux": "5.0.1",
"rxjs": "7.8.2",
"semver": "7.7.2",
"tslib": "2.8.1",
"uuid": "11.1.0"
},
"devDependencies": {
"@grafana/e2e-selectors": "12.2.0-pre",
"@grafana/plugin-configs": "12.2.0-pre",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.6.4",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/node": "22.17.0",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/semver": "7.7.0",
"@types/uuid": "10.0.0",
"jest": "29.7.0",
"ts-node": "10.9.2",
"typescript": "5.9.2",
"webpack": "5.101.0"
},
"peerDependencies": {
"@grafana/runtime": "*"
},
"scripts": {
"build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production",
"build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)",
"dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development",
"test": "jest --watch --onlyChanged",
"test:ci": "jest --maxWorkers 4"
},
"packageManager": "yarn@4.9.2",
"type": "module"
}
@@ -18,7 +18,7 @@
"maxDataPoints": true,
"cacheTimeout": true
},
"executable": "gpx_graphite",
"info": {
"description": "Open source time series database",
"author": {
@@ -37,6 +37,11 @@
},
{ "name": "Raise issue", "url": "https://github.com/grafana/grafana/issues/new" },
{ "name": "Documentation", "url": "https://grafana.com/docs/grafana/latest/datasources/graphite/" }
]
],
"version": "%VERSION%"
},
"dependencies": {
"grafanaDependency": ">=10.3.0",
"plugins": []
}
}
@@ -0,0 +1,9 @@
{
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:plugin", "type:datasource"],
"targets": {
"build": {},
"dev": {}
}
}
@@ -1,4 +1,4 @@
import { TemplateSrvStub } from 'test/specs/helpers';
import { getTemplateSrv } from '@grafana/runtime';
import gfunc from '../gfunc';
import GraphiteQuery from '../graphite_query';
@@ -12,7 +12,7 @@ describe('Graphite query model', () => {
createFuncInstance: gfunc.createFuncInstance,
},
// @ts-ignore
templateSrv: new TemplateSrvStub(),
templateSrv: getTemplateSrv(),
targets: [],
};
@@ -1,22 +1,25 @@
import { TemplateSrvStub } from 'test/specs/helpers';
import { dispatch } from 'app/store/store';
import { getTemplateSrv } from '@grafana/runtime';
import { GraphiteDatasource } from '../datasource';
import gfunc from '../gfunc';
import { actions } from '../state/actions';
import {
getAltSegmentsSelectables,
getTagsSelectables,
getTagsAsSegmentsSelectables,
getTagsSelectables,
getTagValuesSelectables,
} from '../state/providers';
import { createStore } from '../state/store';
import { GraphiteSegment } from '../types';
jest.mock('app/store/store', () => ({
dispatch: jest.fn(),
const mockPublish = jest.fn();
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getAppEvents: () => ({
publish: mockPublish,
}),
// getTemplateSrv: jest.fn().mockReturnValue({ replace: jest.fn(), getVariables: jest.fn() }),
}));
const mockDispatch = dispatch as jest.Mock;
/**
* Simulate switching to text editor, changing the query and switching back to visual editor
@@ -30,22 +33,28 @@ async function changeTarget(ctx: any, target: string): Promise<void> {
describe('Graphite actions', () => {
const ctx = {
datasource: {
metricFindQuery: jest.fn(() => Promise.resolve([])),
getFuncDefs: jest.fn(() => Promise.resolve(gfunc.getFuncDefs('1.0'))),
getFuncDef: gfunc.getFuncDef,
waitForFuncDefsLoaded: jest.fn(() => Promise.resolve(null)),
createFuncInstance: gfunc.createFuncInstance,
getTagsAutoComplete: jest.fn().mockReturnValue(Promise.resolve([])),
getTagValuesAutoComplete: jest.fn().mockReturnValue(Promise.resolve([])),
},
target: { target: 'aliasByNode(scaleToSeconds(test.prod.*,1),2)' },
} as any;
beforeEach(async () => {
jest.clearAllMocks();
ctx.state = null;
const instanceSettings = {
url: '/api/datasources/proxy/1',
name: 'graphiteProd',
jsonData: {
rollupIndicatorEnabled: true,
},
};
ctx.datasource = new GraphiteDatasource(instanceSettings);
ctx.datasource.metricFindQuery = jest.fn(() => Promise.resolve([]));
ctx.datasource.getFuncDefs = jest.fn(() => Promise.resolve(gfunc.getFuncDefs('1.0')));
ctx.datasource.getFuncDef = gfunc.getFuncDef;
ctx.datasource.waitForFuncDefsLoaded = jest.fn(() => Promise.resolve(null));
ctx.datasource.createFuncInstance = gfunc.createFuncInstance;
ctx.datasource.getTagsAutoComplete = jest.fn().mockReturnValue(Promise.resolve([]));
ctx.datasource.getTagValuesAutoComplete = jest.fn().mockReturnValue(Promise.resolve([]));
ctx.state = { datasource: ctx.datasource };
ctx.dispatch = createStore((state) => {
ctx.state = state;
});
@@ -57,7 +66,7 @@ describe('Graphite actions', () => {
refresh: jest.fn(),
queries: [],
//@ts-ignore
templateSrv: new TemplateSrvStub(),
templateSrv: getTemplateSrv(),
})
);
});
@@ -220,7 +229,8 @@ describe('Graphite actions', () => {
});
it('limit is passed when getting list of tag values', async () => {
await getTagValuesSelectables(ctx.state, { key: 'key', operator: '=', value: 'value' }, 1, 'test');
ctx.state.templateSrv = { replace: jest.fn(), getVariables: jest.fn() };
ctx.datasource = await getTagValuesSelectables(ctx.state, { key: 'key', operator: '=', value: 'value' }, 1, 'test');
expect(ctx.state.datasource.getTagValuesAutoComplete).toHaveBeenCalledWith([], 'key', 'test', { limit: 5000 });
});
@@ -237,20 +247,18 @@ describe('Graphite actions', () => {
it('getAltSegmentsSelectables should handle autocomplete errors', async () => {
await expect(async () => {
await getAltSegmentsSelectables(ctx.state, 0, 'any');
expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'appNotifications/notifyApp',
})
expect(mockPublish).toHaveBeenCalledWith(
expect.objectContaining({ payload: ['Fetching metrics failed: .'], type: 'alert-error' })
);
}).not.toThrow();
});
it('getAltSegmentsSelectables should display the error message only once', async () => {
await getAltSegmentsSelectables(ctx.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
expect(mockPublish.mock.calls.length).toBe(1);
await getAltSegmentsSelectables(ctx.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
expect(mockPublish.mock.calls.length).toBe(1);
});
});
@@ -267,39 +275,35 @@ describe('Graphite actions', () => {
it('getTagsSelectables should handle autocomplete errors', async () => {
await expect(async () => {
await getTagsSelectables(ctx.state, 0, 'any');
expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'appNotifications/notifyApp',
})
expect(mockPublish).toHaveBeenCalledWith(
expect.objectContaining({ payload: ['Fetching tags failed: .'], type: 'alert-error' })
);
}).not.toThrow();
});
it('getTagsSelectables should display the error message only once', async () => {
await getTagsSelectables(ctx.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
expect(mockPublish.mock.calls.length).toBe(1);
await getTagsSelectables(ctx.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
expect(mockPublish.mock.calls.length).toBe(1);
});
it('getTagsAsSegmentsSelectables should handle autocomplete errors', async () => {
await expect(async () => {
await getTagsAsSegmentsSelectables(ctx.state, 'any');
expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'appNotifications/notifyApp',
})
expect(mockPublish).toHaveBeenCalledWith(
expect.objectContaining({ payload: ['Fetching tags failed: .'], type: 'alert-error' })
);
}).not.toThrow();
});
it('getTagsAsSegmentsSelectables should display the error message only once', async () => {
await getTagsAsSegmentsSelectables(ctx.state, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
expect(mockPublish.mock.calls.length).toBe(1);
await getTagsAsSegmentsSelectables(ctx.state, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
expect(mockPublish.mock.calls.length).toBe(1);
});
});
@@ -491,6 +495,7 @@ describe('Graphite actions', () => {
it('uses limited metrics and tags list', async () => {
ctx.state.supportsTags = true;
ctx.state.templateSrv = { replace: jest.fn(), getVariables: jest.fn() };
const segments = await getAltSegmentsSelectables(ctx.state, 0, '');
expect(segments).toHaveLength(10000);
expect(segments[0].value!.value).toBe('*'); // * - is a fixed metric name, always added at the top
@@ -501,6 +506,7 @@ describe('Graphite actions', () => {
it('uses correct limit for metrics and tags list when tags are not supported', async () => {
ctx.state.supportsTags = false;
ctx.state.templateSrv = { replace: jest.fn(), getVariables: jest.fn() };
const segments = await getAltSegmentsSelectables(ctx.state, 0, '');
expect(segments).toHaveLength(5000);
expect(segments[0].value!.value).toBe('*'); // * - is a fixed metric name, always added at the top
@@ -508,6 +514,7 @@ describe('Graphite actions', () => {
});
it('uses limited metrics when adding more metrics', async () => {
ctx.state.templateSrv = { replace: jest.fn(), getVariables: jest.fn() };
const segments = await getAltSegmentsSelectables(ctx.state, 1, '');
expect(segments).toHaveLength(5000);
});
@@ -3,7 +3,7 @@ import { createContext, Dispatch, PropsWithChildren, useContext, useEffect, useM
import { usePrevious } from 'react-use';
import { QueryEditorProps } from '@grafana/data';
import { getTemplateSrv } from 'app/features/templating/template_srv';
import { getTemplateSrv } from '@grafana/runtime';
import { GraphiteDatasource } from '../datasource';
import { GraphiteOptions, GraphiteQuery } from '../types';
@@ -1,8 +1,8 @@
import { clone, some } from 'lodash';
import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store';
import { AppEvents } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { FuncInstance } from '../gfunc';
import { GraphiteTagOperator } from '../types';
@@ -185,7 +185,10 @@ export function handleMetricsAutoCompleteError(
): GraphiteQueryEditorState {
if (!state.metricAutoCompleteErrorShown) {
state.metricAutoCompleteErrorShown = true;
dispatch(notifyApp(createErrorNotification(`Fetching metrics failed: ${error.message}.`)));
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [`Fetching metrics failed: ${error.message}.`],
});
}
return state;
}
@@ -196,7 +199,10 @@ export function handleMetricsAutoCompleteError(
export function handleTagsAutoCompleteError(state: GraphiteQueryEditorState, error: Error): GraphiteQueryEditorState {
if (!state.tagsAutoCompleteErrorShown) {
state.tagsAutoCompleteErrorShown = true;
dispatch(notifyApp(createErrorNotification(`Fetching tags failed: ${error.message}.`)));
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [`Fetching tags failed: ${error.message}.`],
});
}
return state;
}
@@ -2,8 +2,8 @@ import { AnyAction } from '@reduxjs/toolkit';
import { Action, Dispatch } from 'redux';
import { DataQuery, TimeRange } from '@grafana/data';
import { TemplateSrv } from '@grafana/runtime';
import { TemplateSrv } from '../../../../features/templating/template_srv';
import { GraphiteDatasource } from '../datasource';
import { FuncDefs } from '../gfunc';
import GraphiteQuery, { GraphiteTarget } from '../graphite_query';
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"jsx": "react-jsx"
},
"extends": "@grafana/plugin-configs/tsconfig.json",
"include": ["."]
}
@@ -1,6 +1,6 @@
import { DataQuery, DataQueryRequest, DataSourceJsonData, TimeRange } from '@grafana/data';
import { TemplateSrv } from '../../../features/templating/template_srv';
import { DataQueryRequest, DataSourceJsonData, TimeRange } from '@grafana/data';
import { TemplateSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { GraphiteDatasource } from './datasource';
@@ -25,3 +25,8 @@ export function reduceError(error: any) {
export function isGraphiteParserError(e: unknown): e is GraphiteParserError {
return typeof e === 'object' && e !== null && 'message' in e && 'pos' in e;
}
export const arrayMove = <T>(array: T[], fromIndex: number, toIndex: number): T[] => {
array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
return array;
};
@@ -0,0 +1,4 @@
import config from '@grafana/plugin-configs/webpack.config.ts';
// eslint-disable-next-line no-barrel-files/no-barrel-files
export default config;
+43
View File
@@ -2582,6 +2582,49 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana-plugins/graphite@workspace:public/app/plugins/datasource/graphite":
version: 0.0.0-use.local
resolution: "@grafana-plugins/graphite@workspace:public/app/plugins/datasource/graphite"
dependencies:
"@emotion/css": "npm:11.13.5"
"@grafana/data": "npm:12.2.0-pre"
"@grafana/e2e-selectors": "npm:12.2.0-pre"
"@grafana/plugin-configs": "npm:12.2.0-pre"
"@grafana/plugin-ui": "npm:^0.10.10"
"@grafana/runtime": "npm:12.2.0-pre"
"@grafana/schema": "npm:12.2.0-pre"
"@grafana/ui": "npm:12.2.0-pre"
"@reduxjs/toolkit": "npm:2.8.2"
"@testing-library/dom": "npm:10.4.1"
"@testing-library/jest-dom": "npm:6.6.4"
"@testing-library/react": "npm:16.3.0"
"@testing-library/user-event": "npm:14.6.1"
"@types/jest": "npm:29.5.14"
"@types/lodash": "npm:4.17.20"
"@types/node": "npm:22.17.0"
"@types/react": "npm:18.3.18"
"@types/react-dom": "npm:18.3.5"
"@types/semver": "npm:7.7.0"
"@types/uuid": "npm:10.0.0"
jest: "npm:29.7.0"
lodash: "npm:4.17.21"
moment: "npm:2.30.1"
react: "npm:18.3.1"
react-dom: "npm:18.3.1"
react-use: "npm:17.6.0"
redux: "npm:5.0.1"
rxjs: "npm:7.8.2"
semver: "npm:7.7.2"
ts-node: "npm:10.9.2"
tslib: "npm:2.8.1"
typescript: "npm:5.9.2"
uuid: "npm:11.1.0"
webpack: "npm:5.101.0"
peerDependencies:
"@grafana/runtime": "*"
languageName: unknown
linkType: soft
"@grafana-plugins/jaeger@workspace:public/app/plugins/datasource/jaeger":
version: 0.0.0-use.local
resolution: "@grafana-plugins/jaeger@workspace:public/app/plugins/datasource/jaeger"