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:
co-authored by
Jeff Levin
parent
07aa2bbbba
commit
0371884cdd
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = `{
|
||||
|
||||
Reference in New Issue
Block a user