diff --git a/pkg/api/api.go b/pkg/api/api.go index 1260547da9b..cd64251b4ab 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -613,6 +613,7 @@ func (hs *HTTPServer) registerRoutes() { // Public API if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { r.Get("/api/public/dashboards/:uid", routing.Wrap(hs.GetPublicDashboard)) + r.Post("/api/public/dashboards/:uid/panels/:panelId/query", routing.Wrap(hs.QueryPublicDashboard)) } // Frontend logs diff --git a/pkg/api/dashboard_public.go b/pkg/api/dashboard_public.go index fb63c6e141f..ee83e6b5432 100644 --- a/pkg/api/dashboard_public.go +++ b/pkg/api/dashboard_public.go @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" + "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" @@ -69,6 +70,30 @@ func (hs *HTTPServer) SavePublicDashboardConfig(c *models.ReqContext) response.R return response.JSON(http.StatusOK, pdc) } +// QueryPublicDashboard returns all results for a given panel on a public dashboard +// POST /api/public/dashboard/:uid/panels/:panelId/query +func (hs *HTTPServer) QueryPublicDashboard(c *models.ReqContext) response.Response { + panelId, err := strconv.ParseInt(web.Params(c.Req)[":panelId"], 10, 64) + if err != nil { + return response.Error(http.StatusBadRequest, "invalid panel ID", err) + } + + reqDTO, err := hs.dashboardService.BuildPublicDashboardMetricRequest( + c.Req.Context(), + web.Params(c.Req)[":uid"], + panelId, + ) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to get queries for public dashboard", err) + } + + resp, err := hs.queryDataService.QueryDataMultipleSources(c.Req.Context(), nil, c.SkipCache, reqDTO, true) + if err != nil { + return hs.handleQueryMetricsError(err) + } + return hs.toJsonStreamingResponse(resp) +} + // util to help us unpack a dashboard err or use default http code and message func handleDashboardErr(defaultCode int, defaultMsg string, err error) response.Response { var dashboardErr models.DashboardErr diff --git a/pkg/api/dashboard_public_test.go b/pkg/api/dashboard_public_test.go index 1da2d26c298..0bef1e8e42e 100644 --- a/pkg/api/dashboard_public_test.go +++ b/pkg/api/dashboard_public_test.go @@ -1,9 +1,11 @@ package api import ( + "context" "encoding/json" "errors" "fmt" + "io/ioutil" "net/http" "strings" "testing" @@ -12,11 +14,17 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/web/webtest" + + fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" ) func TestAPIGetPublicDashboard(t *testing.T) { @@ -238,3 +246,262 @@ func TestApiSavePublicDashboardConfig(t *testing.T) { }) } } + +// `/public/dashboards/:uid/query`` endpoint test +func TestAPIQueryPublicDashboard(t *testing.T) { + queryReturnsError := false + + qds := query.ProvideService( + nil, + &fakeDatasources.FakeCacheService{ + DataSources: []*models.DataSource{ + {Uid: "mysqlds"}, + {Uid: "promds"}, + {Uid: "promds2"}, + }, + }, + nil, + &fakePluginRequestValidator{}, + &fakeDatasources.FakeDataSourceService{}, + &fakePluginClient{ + QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + if queryReturnsError { + return nil, errors.New("error") + } + + resp := backend.Responses{} + + for _, query := range req.Queries { + resp[query.RefID] = backend.DataResponse{ + Frames: []*data.Frame{ + { + RefID: query.RefID, + Name: "query-" + query.RefID, + }, + }, + } + } + return &backend.QueryDataResponse{Responses: resp}, nil + }, + }, + &fakeOAuthTokenService{}, + ) + + setup := func(enabled bool) (*webtest.Server, *dashboards.FakeDashboardService) { + fakeDashboardService := &dashboards.FakeDashboardService{} + + return SetupAPITestServer(t, func(hs *HTTPServer) { + hs.queryDataService = qds + hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards, enabled) + hs.dashboardService = fakeDashboardService + }), fakeDashboardService + } + + t.Run("Status code is 404 when feature toggle is disabled", func(t *testing.T) { + server, _ := setup(false) + + req := server.NewPostRequest( + "/api/public/dashboards/abc123/panels/2/query", + strings.NewReader("{}"), + ) + resp, err := server.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) { + server, _ := setup(true) + + req := server.NewPostRequest( + "/api/public/dashboards/abc123/panels/notanumber/query", + strings.NewReader("{}"), + ) + resp, err := server.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Returns query data when feature toggle is enabled", func(t *testing.T) { + server, fakeDashboardService := setup(true) + + fakeDashboardService.On( + "BuildPublicDashboardMetricRequest", + mock.Anything, + "abc123", + int64(2), + ).Return(dtos.MetricRequest{ + Queries: []*simplejson.Json{ + simplejson.MustJson([]byte(` + { + "datasource": { + "type": "prometheus", + "uid": "promds" + }, + "exemplar": true, + "expr": "query_2_A", + "interval": "", + "legendFormat": "", + "refId": "A" + } + `)), + }, + }, nil) + req := server.NewPostRequest( + "/api/public/dashboards/abc123/panels/2/query", + strings.NewReader("{}"), + ) + resp, err := server.SendJSON(req) + require.NoError(t, err) + bodyBytes, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq( + t, + `{ + "results": { + "A": { + "frames": [ + { + "data": { + "values": [] + }, + "schema": { + "fields": [], + "refId": "A", + "name": "query-A" + } + } + ] + } + } + }`, + string(bodyBytes), + ) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("Status code is 500 when the query fails", func(t *testing.T) { + server, fakeDashboardService := setup(true) + + fakeDashboardService.On( + "BuildPublicDashboardMetricRequest", + mock.Anything, + "abc123", + int64(2), + ).Return(dtos.MetricRequest{ + Queries: []*simplejson.Json{ + simplejson.MustJson([]byte(` + { + "datasource": { + "type": "prometheus", + "uid": "promds" + }, + "exemplar": true, + "expr": "query_2_A", + "interval": "", + "legendFormat": "", + "refId": "A" + } + `)), + }, + }, nil) + req := server.NewPostRequest( + "/api/public/dashboards/abc123/panels/2/query", + strings.NewReader("{}"), + ) + queryReturnsError = true + resp, err := server.SendJSON(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) + queryReturnsError = false + }) + + t.Run("Status code is 200 when a panel has queries from multiple datasources", func(t *testing.T) { + server, fakeDashboardService := setup(true) + + fakeDashboardService.On( + "BuildPublicDashboardMetricRequest", + mock.Anything, + "abc123", + int64(2), + ).Return(dtos.MetricRequest{ + Queries: []*simplejson.Json{ + simplejson.MustJson([]byte(` + { + "datasource": { + "type": "prometheus", + "uid": "promds" + }, + "exemplar": true, + "expr": "query_2_A", + "interval": "", + "legendFormat": "", + "refId": "A" + } + `)), + simplejson.MustJson([]byte(` + { + "datasource": { + "type": "prometheus", + "uid": "promds2" + }, + "exemplar": true, + "expr": "query_2_B", + "interval": "", + "legendFormat": "", + "refId": "B" + } + `)), + }, + }, nil) + req := server.NewPostRequest( + "/api/public/dashboards/abc123/panels/2/query", + strings.NewReader("{}"), + ) + resp, err := server.SendJSON(req) + require.NoError(t, err) + bodyBytes, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq( + t, + `{ + "results": { + "A": { + "frames": [ + { + "data": { + "values": [] + }, + "schema": { + "fields": [], + "refId": "A", + "name": "query-A" + } + } + ] + }, + "B": { + "frames": [ + { + "data": { + "values": [] + }, + "schema": { + "fields": [], + "refId": "B", + "name": "query-B" + } + } + ] + } + } + }`, + string(bodyBytes), + ) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) +} diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index dbed74c01b7..c0fe84cb2dc 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -73,6 +73,16 @@ type MetricRequest struct { HTTPRequest *http.Request `json:"-"` } +func (mr *MetricRequest) CloneWithQueries(queries []*simplejson.Json) MetricRequest { + return MetricRequest{ + From: mr.From, + To: mr.To, + Queries: queries, + Debug: mr.Debug, + HTTPRequest: mr.HTTPRequest, + } +} + func GetGravatarUrl(text string) string { if setting.DisableGravatar { return setting.AppSubUrl + "/public/img/user_profile.png" diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go index e61a36fe8ff..5492b7b854b 100644 --- a/pkg/api/metrics_test.go +++ b/pkg/api/metrics_test.go @@ -8,14 +8,15 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/web/webtest" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/models" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/query" - "github.com/grafana/grafana/pkg/web/webtest" ) var queryDatasourceInput = `{ diff --git a/pkg/components/simplejson/simplejson.go b/pkg/components/simplejson/simplejson.go index 8d763350a21..75249fb424d 100644 --- a/pkg/components/simplejson/simplejson.go +++ b/pkg/components/simplejson/simplejson.go @@ -9,6 +9,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "log" ) @@ -48,6 +49,17 @@ func NewJson(body []byte) (*Json, error) { return j, nil } +// MustJson returns a pointer to a new `Json` object, panicking if `body` cannot be parsed. +func MustJson(body []byte) *Json { + j, err := NewJson(body) + + if err != nil { + panic(fmt.Sprintf("could not unmarshal JSON: %q", err)) + } + + return j +} + // New returns a pointer to a new, empty `Json` object func New() *Json { return &Json{ diff --git a/pkg/components/simplejson/simplejson_test.go b/pkg/components/simplejson/simplejson_test.go index 0c01e8a41b9..a7da0d49285 100644 --- a/pkg/components/simplejson/simplejson_test.go +++ b/pkg/components/simplejson/simplejson_test.go @@ -263,3 +263,12 @@ func TestPathWillOverwriteExisting(t *testing.T) { assert.Equal(t, nil, err) assert.Equal(t, "bar", s) } + +func TestMustJson(t *testing.T) { + js := MustJson([]byte(`{"foo": "bar"}`)) + assert.Equal(t, js.Get("foo").MustString(), "bar") + + assert.PanicsWithValue(t, "could not unmarshal JSON: \"unexpected EOF\"", func() { + MustJson([]byte(`{`)) + }) +} diff --git a/pkg/models/dashboard_queries.go b/pkg/models/dashboard_queries.go index 8aa3ed6ebd9..0490071b116 100644 --- a/pkg/models/dashboard_queries.go +++ b/pkg/models/dashboard_queries.go @@ -27,3 +27,23 @@ func GetQueriesFromDashboard(dashboard *simplejson.Json) map[int64][]*simplejson return result } + +func GroupQueriesByDataSource(queries []*simplejson.Json) (result [][]*simplejson.Json) { + byDataSource := make(map[string][]*simplejson.Json) + + for _, query := range queries { + dataSourceUid, err := query.GetPath("datasource", "uid").String() + + if err != nil { + continue + } + + byDataSource[dataSourceUid] = append(byDataSource[dataSourceUid], query) + } + + for _, queries := range byDataSource { + result = append(result, queries) + } + + return +} diff --git a/pkg/models/dashboard_queries_test.go b/pkg/models/dashboard_queries_test.go index a26ee0e50ce..95daa3bae22 100644 --- a/pkg/models/dashboard_queries_test.go +++ b/pkg/models/dashboard_queries_test.go @@ -36,6 +36,17 @@ const ( "interval": "", "legendFormat": "", "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "promds2" + }, + "exemplar": true, + "expr": "query2", + "interval": "", + "legendFormat": "", + "refId": "B" } ], "title": "Panel Title", @@ -94,7 +105,7 @@ func TestGetQueriesFromDashboard(t *testing.T) { queries := GetQueriesFromDashboard(json) require.Len(t, queries, 1) require.Contains(t, queries, int64(2)) - require.Len(t, queries[2], 1) + require.Len(t, queries[2], 2) query, err := queries[2][0].MarshalJSON() require.NoError(t, err) require.JSONEq(t, `{ @@ -108,6 +119,19 @@ func TestGetQueriesFromDashboard(t *testing.T) { "legendFormat": "", "refId": "A" }`, string(query)) + query, err = queries[2][1].MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, `{ + "datasource": { + "type": "prometheus", + "uid": "promds2" + }, + "exemplar": true, + "expr": "query2", + "interval": "", + "legendFormat": "", + "refId": "B" + }`, string(query)) }) t.Run("can extract queries from old-style panels", func(t *testing.T) { @@ -130,3 +154,57 @@ func TestGetQueriesFromDashboard(t *testing.T) { }`, string(query)) }) } + +func TestGroupQueriesByDataSource(t *testing.T) { + t.Run("can divide queries by datasource", func(t *testing.T) { + queries := []*simplejson.Json{ + simplejson.MustJson([]byte(`{ + "datasource": { + "type": "prometheus", + "uid": "_yxMP8Ynk" + }, + "exemplar": true, + "expr": "go_goroutines{job=\"$job\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + }`)), + simplejson.MustJson([]byte(`{ + "datasource": { + "type": "prometheus", + "uid": "promds2" + }, + "exemplar": true, + "expr": "query2", + "interval": "", + "legendFormat": "", + "refId": "B" + }`)), + } + + queriesByDatasource := GroupQueriesByDataSource(queries) + require.Len(t, queriesByDatasource, 2) + require.Contains(t, queriesByDatasource, []*simplejson.Json{simplejson.MustJson([]byte(`{ + "datasource": { + "type": "prometheus", + "uid": "_yxMP8Ynk" + }, + "exemplar": true, + "expr": "go_goroutines{job=\"$job\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + }`))}) + require.Contains(t, queriesByDatasource, []*simplejson.Json{simplejson.MustJson([]byte(`{ + "datasource": { + "type": "prometheus", + "uid": "promds2" + }, + "exemplar": true, + "expr": "query2", + "interval": "", + "legendFormat": "", + "refId": "B" + }`))}) + }) +} diff --git a/pkg/models/dashboards_public.go b/pkg/models/dashboards_public.go index 8804174f809..42c02eb76c2 100644 --- a/pkg/models/dashboards_public.go +++ b/pkg/models/dashboards_public.go @@ -10,6 +10,11 @@ var ( StatusCode: 404, Status: "not-found", } + ErrPublicDashboardPanelNotFound = DashboardErr{ + Reason: "Panel not found in dashboard", + StatusCode: 404, + Status: "not-found", + } ErrPublicDashboardIdentifierNotSet = DashboardErr{ Reason: "No Uid for public dashboard specified", StatusCode: 400, diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 8dbdd93de15..e2388ae1e62 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -3,12 +3,14 @@ package dashboards import ( "context" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/models" ) //go:generate mockery --name DashboardService --structname FakeDashboardService --inpackage --filename dashboard_service_mock.go // DashboardService is a service for operating on dashboards. type DashboardService interface { + BuildPublicDashboardMetricRequest(ctx context.Context, publicDashboardUid string, panelId int64) (dtos.MetricRequest, error) BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 08a0de76b44..0793f2a6089 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -1,13 +1,15 @@ -// Code generated by mockery v2.12.1. DO NOT EDIT. +// Code generated by mockery v2.12.2. DO NOT EDIT. package dashboards import ( context "context" - models "github.com/grafana/grafana/pkg/models" + dtos "github.com/grafana/grafana/pkg/api/dtos" mock "github.com/stretchr/testify/mock" + models "github.com/grafana/grafana/pkg/models" + testing "testing" ) @@ -16,6 +18,27 @@ type FakeDashboardService struct { mock.Mock } +// BuildPublicDashboardMetricRequest provides a mock function with given fields: ctx, publicDashboardUid, panelId +func (_m *FakeDashboardService) BuildPublicDashboardMetricRequest(ctx context.Context, publicDashboardUid string, panelId int64) (dtos.MetricRequest, error) { + ret := _m.Called(ctx, publicDashboardUid, panelId) + + var r0 dtos.MetricRequest + if rf, ok := ret.Get(0).(func(context.Context, string, int64) dtos.MetricRequest); ok { + r0 = rf(ctx, publicDashboardUid, panelId) + } else { + r0 = ret.Get(0).(dtos.MetricRequest) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, int64) error); ok { + r1 = rf(ctx, publicDashboardUid, panelId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // BuildSaveDashboardCommand provides a mock function with given fields: ctx, dto, shouldValidateAlerts, validateProvisionedDashboard func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) { ret := _m.Called(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard) diff --git a/pkg/services/dashboards/service/dashboard_public.go b/pkg/services/dashboards/service/dashboard_public.go index d282fd70cc5..fb05124627a 100644 --- a/pkg/services/dashboards/service/dashboard_public.go +++ b/pkg/services/dashboards/service/dashboard_public.go @@ -2,7 +2,9 @@ package service import ( "context" + "encoding/json" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" ) @@ -23,7 +25,7 @@ func (dr *DashboardServiceImpl) GetPublicDashboard(ctx context.Context, dashboar return nil, models.ErrPublicDashboardNotFound } - // FIXME insert logic to substitute pdc.TimeSettings into d + // FIXME maybe insert logic to substitute pdc.TimeSettings into d return d, nil } @@ -58,3 +60,35 @@ func (dr *DashboardServiceImpl) SavePublicDashboardConfig(ctx context.Context, d return pdc, nil } + +func (dr *DashboardServiceImpl) BuildPublicDashboardMetricRequest(ctx context.Context, publicDashboardUid string, panelId int64) (dtos.MetricRequest, error) { + publicDashboardConfig, dashboard, err := dr.dashboardStore.GetPublicDashboard(publicDashboardUid) + if err != nil { + return dtos.MetricRequest{}, err + } + + if !dashboard.IsPublic { + return dtos.MetricRequest{}, models.ErrPublicDashboardNotFound + } + + var timeSettings struct { + From string `json:"from"` + To string `json:"to"` + } + err = json.Unmarshal([]byte(publicDashboardConfig.TimeSettings), &timeSettings) + if err != nil { + return dtos.MetricRequest{}, err + } + + queriesByPanel := models.GetQueriesFromDashboard(dashboard.Data) + + if _, ok := queriesByPanel[panelId]; !ok { + return dtos.MetricRequest{}, models.ErrPublicDashboardPanelNotFound + } + + return dtos.MetricRequest{ + From: timeSettings.From, + To: timeSettings.To, + Queries: queriesByPanel[panelId], + }, nil +} diff --git a/pkg/services/dashboards/service/dashboard_public_test.go b/pkg/services/dashboards/service/dashboard_public_test.go index a75890bf1f1..d6b24098057 100644 --- a/pkg/services/dashboards/service/dashboard_public_test.go +++ b/pkg/services/dashboards/service/dashboard_public_test.go @@ -140,6 +140,103 @@ func TestSavePublicDashboard(t *testing.T) { }) } +func TestBuildPublicDashboardMetricRequest(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t) + dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) + nonPublicDashboard := insertTestDashboard(t, dashboardStore, "testNonPublicDashie", 1, 0, true) + + service := &DashboardServiceImpl{ + log: log.New("test.logger"), + dashboardStore: dashboardStore, + } + + dto := &dashboards.SavePublicDashboardConfigDTO{ + DashboardUid: dashboard.Uid, + OrgId: dashboard.OrgId, + PublicDashboardConfig: &models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + DashboardUid: "NOTTHESAME", + OrgId: 9999999, + TimeSettings: `{"from": "FROM", "to": "TO"}`, + }, + }, + } + + pdc, err := service.SavePublicDashboardConfig(context.Background(), dto) + require.NoError(t, err) + + nonPublicDto := &dashboards.SavePublicDashboardConfigDTO{ + DashboardUid: nonPublicDashboard.Uid, + OrgId: nonPublicDashboard.OrgId, + PublicDashboardConfig: &models.PublicDashboardConfig{ + IsPublic: false, + PublicDashboard: models.PublicDashboard{ + DashboardUid: "NOTTHESAME", + OrgId: 9999999, + TimeSettings: `{"from": "FROM", "to": "TO"}`, + }, + }, + } + + nonPublicPdc, err := service.SavePublicDashboardConfig(context.Background(), nonPublicDto) + require.NoError(t, err) + + t.Run("extracts queries from provided dashboard", func(t *testing.T) { + reqDTO, err := service.BuildPublicDashboardMetricRequest( + context.Background(), + pdc.PublicDashboard.Uid, + 1, + ) + require.NoError(t, err) + + require.Equal(t, "FROM", reqDTO.From) + require.Equal(t, "TO", reqDTO.To) + require.Len(t, reqDTO.Queries, 2) + require.Equal( + t, + simplejson.MustJson([]byte(`{ + "datasource": { + "type": "mysql", + "uid": "ds1" + }, + "refId": "A" + }`)), + reqDTO.Queries[0], + ) + require.Equal( + t, + simplejson.MustJson([]byte(`{ + "datasource": { + "type": "prometheus", + "uid": "ds2" + }, + "refId": "B" + }`)), + reqDTO.Queries[1], + ) + }) + + t.Run("returns an error when panel missing", func(t *testing.T) { + _, err := service.BuildPublicDashboardMetricRequest( + context.Background(), + pdc.PublicDashboard.Uid, + 49, + ) + require.ErrorContains(t, err, "Panel not found") + }) + + t.Run("returns an error when dashboard not public", func(t *testing.T) { + _, err := service.BuildPublicDashboardMetricRequest( + context.Background(), + nonPublicPdc.PublicDashboard.Uid, + 2, + ) + require.ErrorContains(t, err, "Public dashboard not found") + }) +} + func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, title string, orgId int64, folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { t.Helper() @@ -151,6 +248,39 @@ func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, "id": nil, "title": title, "tags": tags, + "panels": []map[string]interface{}{ + { + "id": 1, + "targets": []map[string]interface{}{ + { + "datasource": map[string]string{ + "type": "mysql", + "uid": "ds1", + }, + "refId": "A", + }, + { + "datasource": map[string]string{ + "type": "prometheus", + "uid": "ds2", + }, + "refId": "B", + }, + }, + }, + { + "id": 2, + "targets": []map[string]interface{}{ + { + "datasource": map[string]string{ + "type": "mysql", + "uid": "ds3", + }, + "refId": "C", + }, + }, + }, + }, }), } dash, err := dashboardStore.SaveDashboard(cmd) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 206d06ed39e..9017b0465bb 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -83,6 +83,33 @@ func (s *Service) QueryData(ctx context.Context, user *models.SignedInUser, skip return s.handleQueryData(ctx, user, parsedReq) } +// QueryData can process queries and return query responses. +func (s *Service) QueryDataMultipleSources(ctx context.Context, user *models.SignedInUser, skipCache bool, reqDTO dtos.MetricRequest, handleExpressions bool) (*backend.QueryDataResponse, error) { + byDataSource := models.GroupQueriesByDataSource(reqDTO.Queries) + + if len(byDataSource) == 1 { + return s.QueryData(ctx, user, skipCache, reqDTO, handleExpressions) + } else { + resp := backend.NewQueryDataResponse() + + for _, queries := range byDataSource { + subDTO := reqDTO.CloneWithQueries(queries) + + subResp, err := s.QueryData(ctx, user, skipCache, subDTO, handleExpressions) + + if err != nil { + return nil, err + } + + for refId, queryResponse := range subResp.Responses { + resp.Responses[refId] = queryResponse + } + } + + return resp, nil + } +} + // handleExpressions handles POST /api/ds/query when there is an expression. func (s *Service) handleExpressions(ctx context.Context, user *models.SignedInUser, parsedReq *parsedRequest) (*backend.QueryDataResponse, error) { exprReq := expr.Request{