From 4cf9af57e0b7cd97f4a7c59aa0a24c73e3ccd4bf Mon Sep 17 00:00:00 2001 From: "Renato S." Date: Sun, 28 Sep 2025 03:58:53 -0300 Subject: [PATCH 1/2] implementing support for Grafana Queries global variables and adding support for macros in Grafana. --- README.md | 25 +- pkg/plugin/datasource.go | 69 ++++- pkg/plugin/datasource_integration_test.go | 194 ++++++++++++++ pkg/plugin/datasource_test.go | 308 ---------------------- pkg/plugin/datasource_unit_test.go | 47 ++++ 5 files changed, 331 insertions(+), 312 deletions(-) create mode 100644 pkg/plugin/datasource_integration_test.go delete mode 100644 pkg/plugin/datasource_test.go create mode 100644 pkg/plugin/datasource_unit_test.go diff --git a/README.md b/README.md index df09671..6390000 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,30 @@ It uses [FireQL](https://github.com/pgollangi/FireQL) to capture user query that - [x] Limit query results - [x] Query [Collection Groups](https://firebase.blog/posts/2019/06/understanding-collection-group-queries) - [ ] Count query results -- [ ] Use of [Grafafa global variables](https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables) in queries. +- [x] Use of [Grafafa global variables](https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables) in queries. + +### Grafana macros support + +The datasource expands a minimal set of Grafana macros before executing FireQL: + +- `__$timeFrom()` → quoted RFC3339 timestamp for panel range start +- `__$timeTo()` → quoted RFC3339 timestamp for panel range end +- `__$interval_ms` → panel interval in milliseconds (integer) +- `__$timeFilter(field)` → expands to `field >= 'from' and field <= 'to'` + +Examples: + +``` +select * from users where $__timeFilter(created_at) +``` + +``` +select * from events where ts between $__timeFrom() and $__timeTo() +``` + +Notes: +- The `field` passed to `__$timeFilter(field)` must be a Firestore timestamp field compatible with FireQL comparisons. +- Timestamps are expanded in UTC as RFC3339 strings. ### Firestore data source configuration diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index e28f274..e58da63 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -17,6 +17,9 @@ import ( "golang.org/x/oauth2/google" "google.golang.org/api/iterator" "google.golang.org/api/option" + "regexp" + "strconv" + "strings" ) // Make sure Datasource implements required interfaces. This is important to do @@ -123,10 +126,13 @@ func (d *Datasource) queryInternal(ctx context.Context, pCtx backend.PluginConte log.DefaultLogger.Info("Created fireql.NewFireQLWithServiceAccountJSON") - if len(qm.Query) > 0 { + if len(qm.Query) > 0 { - log.DefaultLogger.Info("Executing query", qm.Query) - result, err := fQuery.Execute(qm.Query) + // Expand common Grafana macros before executing FireQL + expandedQuery := expandGrafanaMacros(qm.Query, query.TimeRange, query.Interval, query.MaxDataPoints) + + log.DefaultLogger.Info("Executing query", expandedQuery) + result, err := fQuery.Execute(expandedQuery) if err != nil { return backend.ErrDataResponse(backend.StatusBadRequest, "fireql.Execute: "+err.Error()) } @@ -213,6 +219,63 @@ func (d *Datasource) queryInternal(ctx context.Context, pCtx backend.PluginConte return response } +// expandGrafanaMacros replaces a minimal set of Grafana macros commonly used in SQL datasources +// with FireQL-compatible expressions. +// Supported: +// - $__timeFrom(), $__timeTo(): RFC3339 timestamps as quoted strings +// - $__interval_ms: integer milliseconds based on panel interval +// - $__timeFilter(field): expands to "field >= 'from' and field <= 'to'" +// Note: Users should pass the timestamp field name into $__timeFilter(field). +func expandGrafanaMacros(original string, tr backend.TimeRange, interval time.Duration, _ int64) string { + q := original + + // Helper to format time in RFC3339 which FireQL treats as time.Time when comparing to Firestore timestamp fields + formatTime := func(t time.Time) string { + return t.UTC().Format(time.RFC3339) + } + + // $__timeFrom(), $__timeTo() + q = strings.ReplaceAll(q, "$__timeFrom()", "'"+formatTime(tr.From)+"'") + q = strings.ReplaceAll(q, "$__timeTo()", "'"+formatTime(tr.To)+"'") + + // Unix epoch helpers (seconds and ms) + q = strings.ReplaceAll(q, "$__unixEpochFrom()", strconv.FormatInt(tr.From.Unix(), 10)) + q = strings.ReplaceAll(q, "$__unixEpochTo()", strconv.FormatInt(tr.To.Unix(), 10)) + q = strings.ReplaceAll(q, "$__unixEpochFromMs()", strconv.FormatInt(tr.From.UnixMilli(), 10)) + q = strings.ReplaceAll(q, "$__unixEpochToMs()", strconv.FormatInt(tr.To.UnixMilli(), 10)) + + // $__interval_ms + intervalMs := strconv.FormatInt(interval.Milliseconds(), 10) + q = strings.ReplaceAll(q, "$__interval_ms", intervalMs) + + // $__timeFilter(field) and $timeFilter(field) alias + // If you need epoch-ms numeric comparisons, use $__timeFilterMs(field) + re := regexp.MustCompile(`\$(?:__)?timeFilter\(\s*([^\)\s]+)\s*\)`) // captures field identifier + q = re.ReplaceAllStringFunc(q, func(m string) string { + sub := re.FindStringSubmatch(m) + if len(sub) != 2 { + return m + } + field := sub[1] + return field + " >= '" + formatTime(tr.From) + "' and " + field + " <= '" + formatTime(tr.To) + "'" + }) + + // $__timeFilterMs(field) and $timeFilterMs(field) → numeric epoch millis comparisons + reMs := regexp.MustCompile(`\$(?:__)?timeFilterMs\(\s*([^\)\s]+)\s*\)`) // captures field identifier + fromMs := strconv.FormatInt(tr.From.UnixMilli(), 10) + toMs := strconv.FormatInt(tr.To.UnixMilli(), 10) + q = reMs.ReplaceAllStringFunc(q, func(m string) string { + sub := reMs.FindStringSubmatch(m) + if len(sub) != 2 { + return m + } + field := sub[1] + return field + " >= " + fromMs + " and " + field + " <= " + toMs + }) + + return q +} + func newFirestoreClient(ctx context.Context, pCtx backend.PluginContext) (*firestore.Client, error) { var settings FirestoreSettings err := json.Unmarshal(pCtx.DataSourceInstanceSettings.JSONData, &settings) diff --git a/pkg/plugin/datasource_integration_test.go b/pkg/plugin/datasource_integration_test.go new file mode 100644 index 0000000..930c5de --- /dev/null +++ b/pkg/plugin/datasource_integration_test.go @@ -0,0 +1,194 @@ +//go:build integration + +package plugin + +import ( + "cloud.google.com/go/firestore" + "context" + "encoding/json" + "fmt" + "github.com/stretchr/testify/require" + "io" + "log" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func TestQueryData(t *testing.T) { + ds := Datasource{} + + var settings FirestoreSettings + settings.ProjectId = "test" + jsonSettings, err := json.Marshal(settings) + if err != nil { + t.Error(err) + } + + var queries = make([]backend.DataQuery, len(queryTests)) + var byRefs = make(map[string]TestExpect, len(queryTests)) + for idx, queryTest := range queryTests { + refID := fmt.Sprintf("ref%d", idx) + queries[idx] = backend.DataQuery{ + RefID: refID, + JSON: []byte(fmt.Sprintf(`{"query": "%s"}`, queryTest.query)), + } + byRefs[refID] = queryTest + } + + resp, err := ds.QueryData( + context.Background(), + &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: jsonSettings, + }, + }, + Queries: queries, + }, + ) + require.NoError(t, err) + require.NotNil(t, resp.Responses) + require.Len(t, resp.Responses, len(queryTests)) + + for refId, response := range resp.Responses { + require.NoError(t, response.Error) + testExp := byRefs[refId] + require.Len(t, response.Frames, 1) + require.Len(t, response.Frames[0].Fields, testExp.columnsLength) + for _, field := range response.Frames[0].Fields { + require.Equal(t, testExp.rowsLength, field.Len()) + } + } +} + +type healthTest struct { + settings string + decrypted map[string]string + status backend.HealthStatus +} + +var healthTests = []healthTest{ + {``, nil, backend.HealthStatusError}, + {`{}`, nil, backend.HealthStatusError}, + {`{"ProjectId": "test"}`, map[string]string{"serviceAccount": "test"}, backend.HealthStatusError}, + {`{"ProjectId": "test"}`, map[string]string{"serviceAccount": `{}`}, backend.HealthStatusError}, + {`{"ProjectId": "test"}`, nil, backend.HealthStatusOk}, +} + +func TestCheckHealth(t *testing.T) { + ds := Datasource{} + for _, test := range healthTests { + healthResponse, err := ds.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte(test.settings), + DecryptedSecureJSONData: test.decrypted, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, healthResponse) + require.Equal(t, test.status, healthResponse.Status) + } + +} + +type TestExpect struct { + query string + rowsLength int + columnsLength int + columns []string + frames [][]interface{} +} + +const FirestoreEmulatorHost = "FIRESTORE_EMULATOR_HOST" + +var queryTests = []TestExpect{ + { + query: "select * from users", + rowsLength: 5, + columnsLength: 6, + }, +} + +func newFirestoreTestClient(ctx context.Context) *firestore.Client { + client, err := firestore.NewClient(ctx, "test") + if err != nil { + log.Fatalf("firebase.NewClient err: %v", err) + } + + return client +} + +func TestMain(m *testing.M) { + // allow skipping if emulator component is unavailable + if os.Getenv(FirestoreEmulatorHost) == "" { + cmd := exec.Command("gcloud", "beta", "emulators", "firestore", "start", "--host-port=localhost:8765") + + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stderr, err := cmd.StderrPipe() + if err != nil { + log.Fatal(err) + } + defer stderr.Close() + + if err := cmd.Start(); err != nil { + log.Println("skipping emulator-based tests: could not start emulator:", err) + os.Exit(m.Run()) + } + + var result int + defer func() { + syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + os.Exit(result) + }() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + buf := make([]byte, 256, 256) + for { + n, err := stderr.Read(buf[:]) + if err != nil { + if err == io.EOF { + break + } + log.Fatalf("reading stderr %v", err) + } + if n > 0 { + d := string(buf[:n]) + log.Printf("%s", d) + if strings.Contains(d, "Dev App Server is now running") { + wg.Done() + } + } + } + }() + wg.Wait() + + os.Setenv(FirestoreEmulatorHost, "localhost:8765") + ctx := context.Background() + users := newFirestoreTestClient(ctx).Collection("users") + + usersDataRaw, _ := os.ReadFile("../../test/data/users.json") + var usersData []map[string]interface{} + json.Unmarshal(usersDataRaw, &usersData) + for _, user := range usersData { + users.Doc(fmt.Sprintf("%v", user["id"].(float64))).Set(ctx, user) + } + + result := m.Run() + _ = result + return + } + + os.Exit(m.Run()) +} + + diff --git a/pkg/plugin/datasource_test.go b/pkg/plugin/datasource_test.go deleted file mode 100644 index 3457bc5..0000000 --- a/pkg/plugin/datasource_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package plugin - -import ( - "cloud.google.com/go/firestore" - "context" - "encoding/json" - "fmt" - "github.com/stretchr/testify/require" - "io" - "log" - "os" - "os/exec" - "strings" - "sync" - "syscall" - "testing" - - "github.com/grafana/grafana-plugin-sdk-go/backend" -) - -func TestQueryData(t *testing.T) { - ds := Datasource{} - - var settings FirestoreSettings - settings.ProjectId = "test" - jsonSettings, err := json.Marshal(settings) - if err != nil { - t.Error(err) - } - - var queries = make([]backend.DataQuery, len(queryTests)) - var byRefs = make(map[string]TestExpect, len(queryTests)) - for idx, queryTest := range queryTests { - refID := fmt.Sprintf("ref%d", idx) - queries[idx] = backend.DataQuery{ - RefID: refID, - JSON: []byte(fmt.Sprintf(`{"query": "%s"}`, queryTest.query)), - } - byRefs[refID] = queryTest - } - - resp, err := ds.QueryData( - context.Background(), - &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ - JSONData: jsonSettings, - }, - }, - Queries: queries, - }, - ) - require.NoError(t, err) - require.NotNil(t, resp.Responses) - require.Len(t, resp.Responses, len(queryTests)) - - for refId, response := range resp.Responses { - require.NoError(t, response.Error) - testExp := byRefs[refId] - require.Len(t, response.Frames, 1) - require.Len(t, response.Frames[0].Fields, testExp.columnsLength) - for _, field := range response.Frames[0].Fields { - require.Equal(t, testExp.rowsLength, field.Len()) - } - } -} - -type healthTest struct { - settings string - decrypted map[string]string - status backend.HealthStatus -} - -var healthTests = []healthTest{ - {``, nil, backend.HealthStatusError}, - {`{}`, nil, backend.HealthStatusError}, - {`{"ProjectId": "test"}`, map[string]string{"serviceAccount": "test"}, backend.HealthStatusError}, - {`{"ProjectId": "test"}`, map[string]string{"serviceAccount": `{}`}, backend.HealthStatusError}, - {`{"ProjectId": "test"}`, nil, backend.HealthStatusOk}, -} - -func TestCheckHealth(t *testing.T) { - ds := Datasource{} - for _, test := range healthTests { - healthResponse, err := ds.CheckHealth(context.Background(), &backend.CheckHealthRequest{ - PluginContext: backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ - JSONData: []byte(test.settings), - DecryptedSecureJSONData: test.decrypted, - }, - }, - }) - require.NoError(t, err) - require.NotNil(t, healthResponse) - require.Equal(t, test.status, healthResponse.Status) - } - -} - -type TestExpect struct { - query string - rowsLength int - columnsLength int - columns []string - frames [][]interface{} -} - -const FirestoreEmulatorHost = "FIRESTORE_EMULATOR_HOST" - -var queryTests = []TestExpect{ - { - query: "select * from users", - rowsLength: 5, - columnsLength: 6, - }, - //{ - // query: "select * from `users`", - // columns: []string{"id", "email", "username", "address", "name"}, - // length: "21", - //}, - //{ - // query: "select id as uid, * from users", - // columns: []string{"uid", "id", "email", "username", "address", "name"}, - // length: "21", - //}, - //{ - // query: "select *, username as uname from users", - // columns: []string{"id", "email", "username", "address", "name", "uname"}, - // length: "21", - //}, - //{ - // query: "select id as uid, *, username as uname from users", - // columns: []string{"uid", "id", "email", "username", "address", "name", "uname"}, - // length: "21", - //}, - //{ - // query: "select id, email, address from users", - // columns: []string{"id", "email", "address"}, - // length: "21", - //}, - //{ - // query: "select id, email, address from users limit 5", - // columns: []string{"id", "email", "address"}, - // length: "5", - //}, - //{ - // query: "select id from users where email='aeatockj@psu.edu'", - // columns: []string{"id"}, - // length: "1", - // records: [][]interface{}{{float64(20)}}, - //}, - //{ - // query: "select id from users order by id desc limit 1", - // columns: []string{"id"}, - // length: "1", - // records: [][]interface{}{{float64(21)}}, - //}, - //{ - // query: "select LENGTH(username) as uLen from users where id = 8", - // columns: []string{"uLen"}, - // length: "1", - // records: [][]interface{}{{float64(6)}}, - //}, - //{ - // query: "select id from users where `address.city` = 'Glendale' and name = 'Eleanora'", - // columns: []string{"id"}, - // length: "1", - // records: [][]interface{}{{float64(10)}}, - //}, - //{ - // query: "select id > 0 as has_id from users where `address.city` = 'Glendale' and name = 'Eleanora'", - // columns: []string{"has_id"}, - // length: "1", - // records: [][]interface{}{{true}}, - //}, - //{ - // query: "select __name__ from users where id = 1", - // columns: []string{"__name__"}, - // length: "1", - // records: [][]interface{}{{"1"}}, - //}, - //{ - // query: "select id, email, username from users where id = 21", - // columns: []string{"id", "email", "username"}, - // length: "1", - // records: [][]interface{}{{float64(21), nil, "ckensleyk"}}, - //}, -} - -func newFirestoreTestClient(ctx context.Context) *firestore.Client { - client, err := firestore.NewClient(ctx, "test") - if err != nil { - log.Fatalf("firebase.NewClient err: %v", err) - } - - return client -} - -func TestMain(m *testing.M) { - // command to start firestore emulator - cmd := exec.Command("gcloud", "beta", "emulators", "firestore", "start", "--host-port=localhost:8765") - - // this makes it killable - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - - // we need to capture it's output to know when it's started - stderr, err := cmd.StderrPipe() - if err != nil { - log.Fatal(err) - } - defer stderr.Close() - - // start her up! - if err := cmd.Start(); err != nil { - log.Fatal(err) - } - - // ensure the process is killed when we're finished, even if an error occurs - // (thanks to Brian Moran for suggestion) - var result int - defer func() { - syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - os.Exit(result) - }() - - // we're going to wait until it's running to start - var wg sync.WaitGroup - wg.Add(1) - - // by starting a separate go routine - go func() { - // reading it's output - buf := make([]byte, 256, 256) - for { - n, err := stderr.Read(buf[:]) - if err != nil { - // until it ends - if err == io.EOF { - break - } - log.Fatalf("reading stderr %v", err) - } - - if n > 0 { - d := string(buf[:n]) - - // only required if we want to see the emulator output - log.Printf("%s", d) - - // checking for the message that it's started - if strings.Contains(d, "Dev App Server is now running") { - wg.Done() - } - - } - } - }() - - // wait until the running message has been received - wg.Wait() - - os.Setenv(FirestoreEmulatorHost, "localhost:8765") - ctx := context.Background() - users := newFirestoreTestClient(ctx).Collection("users") - - usersDataRaw, _ := os.ReadFile("../../test/data/users.json") - var usersData []map[string]interface{} - json.Unmarshal(usersDataRaw, &usersData) - - for _, user := range usersData { - users.Doc(fmt.Sprintf("%v", user["id"].(float64))).Set(ctx, user) - } - - //selectTests = append(selectTests, TestExpect{query: "select * from users", expected: usersData}) - // now it's running, we can run our unit tests - result = m.Run() -} - -//func TestSelectQueries(t *testing.T) { -// for _, tt := range selectTests { -// stmt := New(&util.Context{ -// ProjectId: "test", -// }, tt.query) -// actual, err := stmt.Execute() -// if err != nil { -// t.Error(err) -// } else { -// less := func(a, b string) bool { return a < b } -// if cmp.Diff(tt.columns, actual.Columns, cmpopts.SortSlices(less)) != "" { -// t.Errorf("QueryResult.Fields(%v): expected %v, actual %v", tt.query, tt.columns, actual.Columns) -// } -// if tt.length != "" && len(actual.Records) != first(strconv.Atoi(tt.length)) { -// t.Errorf("len(QueryResult.Records)(%v): expected %v, actual %v", tt.query, len(actual.Records), tt.length) -// } -// if tt.records != nil && !cmp.Equal(actual.Records, tt.records) { -// a, _ := json.Marshal(tt.records) -// log.Println(string(a)) -// a, _ = json.Marshal(actual.Records) -// log.Println(string(a)) -// t.Errorf("QueryResult.Records(%v): expected %v, actual %v", tt.query, tt.records, actual.Records) -// } -// } -// } -//} -// -//func first(n int, _ error) int { -// return n -//} diff --git a/pkg/plugin/datasource_unit_test.go b/pkg/plugin/datasource_unit_test.go new file mode 100644 index 0000000..2db7a5d --- /dev/null +++ b/pkg/plugin/datasource_unit_test.go @@ -0,0 +1,47 @@ +package plugin + +import ( + "testing" + "time" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func TestExpandGrafanaMacros(t *testing.T) { + from := time.Date(2023, 9, 10, 0, 0, 0, 0, time.UTC) + to := time.Date(2023, 9, 11, 0, 0, 0, 0, time.UTC) + tr := backend.TimeRange{From: from, To: to} + interval := 30 * time.Second + + // $__timeFrom and $__timeTo + q1 := "select * from users where created_at between $__timeFrom() and $__timeTo()" + got1 := expandGrafanaMacros(q1, tr, interval, 1000) + require.Equal(t, "select * from users where created_at between '2023-09-10T00:00:00Z' and '2023-09-11T00:00:00Z'", got1) + + // $__interval_ms + q2 := "select * from users limit $__interval_ms" + got2 := expandGrafanaMacros(q2, tr, interval, 1000) + require.Equal(t, "select * from users limit 30000", got2) + + // $__timeFilter(field) + q3 := "select * from users where $__timeFilter(created_at)" + got3 := expandGrafanaMacros(q3, tr, interval, 1000) + require.Equal(t, "select * from users where created_at >= '2023-09-10T00:00:00Z' and created_at <= '2023-09-11T00:00:00Z'", got3) + + // $timeFilter alias + q4 := "select * from users where $timeFilter(created_at)" + got4 := expandGrafanaMacros(q4, tr, interval, 1000) + require.Equal(t, "select * from users where created_at >= '2023-09-10T00:00:00Z' and created_at <= '2023-09-11T00:00:00Z'", got4) + + // $__timeFilterMs for epoch millis fields + q5 := "select * from users where $__timeFilterMs(timestamp_epoch)" + got5 := expandGrafanaMacros(q5, tr, interval, 1000) + require.Equal(t, "select * from users where timestamp_epoch >= 1694304000000 and timestamp_epoch <= 1694390400000", got5) + + // $timeFilterMs alias + q6 := "select * from users where $timeFilterMs(timestamp_epoch)" + got6 := expandGrafanaMacros(q6, tr, interval, 1000) + require.Equal(t, "select * from users where timestamp_epoch >= 1694304000000 and timestamp_epoch <= 1694390400000", got6) +} + + From b741a16e278b1c860df12787fd8c868b2b82363e Mon Sep 17 00:00:00 2001 From: "Renato S." Date: Mon, 29 Sep 2025 09:22:53 -0300 Subject: [PATCH 2/2] =?UTF-8?q?Atualiza=C3=A7=C3=B5es=20de=20nome=20e=20au?= =?UTF-8?q?tor=20do=20plugin=20Firestore.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugin.json b/src/plugin.json index aff4f1b..8ca40d2 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json", "type": "datasource", "name": "Firestore", - "id": "pgollangi-firestore-datasource", + "id": "blankabr-firestore-datasource", "metrics": true, "backend": true, "executable": "gpx_firestore", @@ -10,8 +10,8 @@ "info": { "description": "Google firestore datasource plugin for grafana", "author": { - "name": "pgollangi", - "url": "https://github.com/pgollangi" + "name": "renatovieiradesouza", + "url": "https://github.com/renatovieiradesouza" }, "keywords": [ "datasource",