mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Start of dashboard query API (#49547)
This PR adds endpoints for public dashboards to retrieve data from the backend (trusted) query engine. It works by executing queries defined on the backend without any user input and does not support template variables. * Public dashboard query API * Create new API on service for building metric request * Flesh out testing, implement BuildPublicDashboardMetricRequest * Test for errors and missing panels * Refactor tests, add supporting code for multiple datasources * Handle queries from multiple datasources * Explicitly pass no user for querying public dashboard Co-authored-by: Jeff Levin <jeff@levinology.com>
This commit is contained in:
parent
07aa2bbbba
commit
0371884cdd
@ -613,6 +613,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
// Public API
|
// Public API
|
||||||
if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) {
|
if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) {
|
||||||
r.Get("/api/public/dashboards/:uid", routing.Wrap(hs.GetPublicDashboard))
|
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
|
// Frontend logs
|
||||||
|
@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"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)
|
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
|
// 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 {
|
func handleDashboardErr(defaultCode int, defaultMsg string, err error) response.Response {
|
||||||
var dashboardErr models.DashboardErr
|
var dashboardErr models.DashboardErr
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@ -12,11 +14,17 @@ import (
|
|||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"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/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"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) {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -73,6 +73,16 @@ type MetricRequest struct {
|
|||||||
HTTPRequest *http.Request `json:"-"`
|
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 {
|
func GetGravatarUrl(text string) string {
|
||||||
if setting.DisableGravatar {
|
if setting.DisableGravatar {
|
||||||
return setting.AppSubUrl + "/public/img/user_profile.png"
|
return setting.AppSubUrl + "/public/img/user_profile.png"
|
||||||
|
@ -8,14 +8,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"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"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
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/services/query"
|
||||||
"github.com/grafana/grafana/pkg/web/webtest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var queryDatasourceInput = `{
|
var queryDatasourceInput = `{
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -48,6 +49,17 @@ func NewJson(body []byte) (*Json, error) {
|
|||||||
return j, nil
|
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
|
// New returns a pointer to a new, empty `Json` object
|
||||||
func New() *Json {
|
func New() *Json {
|
||||||
return &Json{
|
return &Json{
|
||||||
|
@ -263,3 +263,12 @@ func TestPathWillOverwriteExisting(t *testing.T) {
|
|||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "bar", s)
|
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(`{`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -27,3 +27,23 @@ func GetQueriesFromDashboard(dashboard *simplejson.Json) map[int64][]*simplejson
|
|||||||
|
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
@ -36,6 +36,17 @@ const (
|
|||||||
"interval": "",
|
"interval": "",
|
||||||
"legendFormat": "",
|
"legendFormat": "",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "promds2"
|
||||||
|
},
|
||||||
|
"exemplar": true,
|
||||||
|
"expr": "query2",
|
||||||
|
"interval": "",
|
||||||
|
"legendFormat": "",
|
||||||
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Panel Title",
|
"title": "Panel Title",
|
||||||
@ -94,7 +105,7 @@ func TestGetQueriesFromDashboard(t *testing.T) {
|
|||||||
queries := GetQueriesFromDashboard(json)
|
queries := GetQueriesFromDashboard(json)
|
||||||
require.Len(t, queries, 1)
|
require.Len(t, queries, 1)
|
||||||
require.Contains(t, queries, int64(2))
|
require.Contains(t, queries, int64(2))
|
||||||
require.Len(t, queries[2], 1)
|
require.Len(t, queries[2], 2)
|
||||||
query, err := queries[2][0].MarshalJSON()
|
query, err := queries[2][0].MarshalJSON()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.JSONEq(t, `{
|
require.JSONEq(t, `{
|
||||||
@ -108,6 +119,19 @@ func TestGetQueriesFromDashboard(t *testing.T) {
|
|||||||
"legendFormat": "",
|
"legendFormat": "",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}`, string(query))
|
}`, 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) {
|
t.Run("can extract queries from old-style panels", func(t *testing.T) {
|
||||||
@ -130,3 +154,57 @@ func TestGetQueriesFromDashboard(t *testing.T) {
|
|||||||
}`, string(query))
|
}`, 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"
|
||||||
|
}`))})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -10,6 +10,11 @@ var (
|
|||||||
StatusCode: 404,
|
StatusCode: 404,
|
||||||
Status: "not-found",
|
Status: "not-found",
|
||||||
}
|
}
|
||||||
|
ErrPublicDashboardPanelNotFound = DashboardErr{
|
||||||
|
Reason: "Panel not found in dashboard",
|
||||||
|
StatusCode: 404,
|
||||||
|
Status: "not-found",
|
||||||
|
}
|
||||||
ErrPublicDashboardIdentifierNotSet = DashboardErr{
|
ErrPublicDashboardIdentifierNotSet = DashboardErr{
|
||||||
Reason: "No Uid for public dashboard specified",
|
Reason: "No Uid for public dashboard specified",
|
||||||
StatusCode: 400,
|
StatusCode: 400,
|
||||||
|
@ -3,12 +3,14 @@ package dashboards
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate mockery --name DashboardService --structname FakeDashboardService --inpackage --filename dashboard_service_mock.go
|
//go:generate mockery --name DashboardService --structname FakeDashboardService --inpackage --filename dashboard_service_mock.go
|
||||||
// DashboardService is a service for operating on dashboards.
|
// DashboardService is a service for operating on dashboards.
|
||||||
type DashboardService interface {
|
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)
|
BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error)
|
||||||
DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error
|
DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error
|
||||||
FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error)
|
FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error)
|
||||||
|
@ -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
|
package dashboards
|
||||||
|
|
||||||
import (
|
import (
|
||||||
context "context"
|
context "context"
|
||||||
|
|
||||||
models "github.com/grafana/grafana/pkg/models"
|
dtos "github.com/grafana/grafana/pkg/api/dtos"
|
||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
|
models "github.com/grafana/grafana/pkg/models"
|
||||||
|
|
||||||
testing "testing"
|
testing "testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -16,6 +18,27 @@ type FakeDashboardService struct {
|
|||||||
mock.Mock
|
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
|
// 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) {
|
func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) {
|
||||||
ret := _m.Called(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard)
|
ret := _m.Called(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard)
|
||||||
|
@ -2,7 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
)
|
)
|
||||||
@ -23,7 +25,7 @@ func (dr *DashboardServiceImpl) GetPublicDashboard(ctx context.Context, dashboar
|
|||||||
return nil, models.ErrPublicDashboardNotFound
|
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
|
return d, nil
|
||||||
}
|
}
|
||||||
@ -58,3 +60,35 @@ func (dr *DashboardServiceImpl) SavePublicDashboardConfig(ctx context.Context, d
|
|||||||
|
|
||||||
return pdc, nil
|
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
|
||||||
|
}
|
||||||
|
@ -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,
|
func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, title string, orgId int64,
|
||||||
folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard {
|
folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@ -151,6 +248,39 @@ func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore,
|
|||||||
"id": nil,
|
"id": nil,
|
||||||
"title": title,
|
"title": title,
|
||||||
"tags": tags,
|
"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)
|
dash, err := dashboardStore.SaveDashboard(cmd)
|
||||||
|
@ -83,6 +83,33 @@ func (s *Service) QueryData(ctx context.Context, user *models.SignedInUser, skip
|
|||||||
return s.handleQueryData(ctx, user, parsedReq)
|
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.
|
// 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) {
|
func (s *Service) handleExpressions(ctx context.Context, user *models.SignedInUser, parsedReq *parsedRequest) (*backend.QueryDataResponse, error) {
|
||||||
exprReq := expr.Request{
|
exprReq := expr.Request{
|
||||||
|
Loading…
Reference in New Issue
Block a user