mirror of
https://github.com/grafana/grafana.git
synced 2024-11-23 01:16:31 -06:00
Publicdasboards: Add annotations support (#56413)
adds annotations support for public dashboards
This commit is contained in:
parent
cc6245df8e
commit
b2408dd7c5
@ -40,7 +40,12 @@ export interface AnnotationQuery {
|
||||
*/
|
||||
rawQuery?: string;
|
||||
showIn: number;
|
||||
target?: Record<string, unknown>;
|
||||
target?: {
|
||||
limit: number;
|
||||
matchAny: boolean;
|
||||
tags: Array<string>;
|
||||
type: string;
|
||||
};
|
||||
type: string;
|
||||
}
|
||||
|
||||
|
@ -84,6 +84,13 @@ seqs: [
|
||||
///////////////////////////////////////
|
||||
// Definitions (referenced above) are declared below
|
||||
|
||||
#AnnotationTarget: {
|
||||
limit: int64
|
||||
matchAny: bool
|
||||
tags: [...string]
|
||||
type: string
|
||||
}
|
||||
|
||||
// TODO docs
|
||||
// FROM: AnnotationQuery in grafana-data/src/types/annotations.ts
|
||||
#AnnotationQuery: {
|
||||
@ -106,7 +113,7 @@ seqs: [
|
||||
// Query for annotation data.
|
||||
rawQuery?: string @grafanamaturity(NeedsExpertReview)
|
||||
showIn: uint8 | *0 @grafanamaturity(NeedsExpertReview)
|
||||
target?: #Target @grafanamaturity(NeedsExpertReview) // TODO currently a generic in AnnotationQuery
|
||||
target?: #AnnotationTarget @grafanamaturity(NeedsExpertReview)
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
// FROM: packages/grafana-data/src/types/templateVars.ts
|
||||
|
@ -343,18 +343,21 @@ type AnnotationQuery struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
|
||||
// Query for annotation data.
|
||||
RawQuery *string `json:"rawQuery,omitempty"`
|
||||
ShowIn int `json:"showIn"`
|
||||
RawQuery *string `json:"rawQuery,omitempty"`
|
||||
ShowIn int `json:"showIn"`
|
||||
Target *AnnotationTarget `json:"target,omitempty"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Schema for panel targets is specified by datasource
|
||||
// plugins. We use a placeholder definition, which the Go
|
||||
// schema loader either left open/as-is with the Base
|
||||
// variant of the Model and Panel families, or filled
|
||||
// with types derived from plugins in the Instance variant.
|
||||
// When working directly from CUE, importers can extend this
|
||||
// type directly to achieve the same effect.
|
||||
Target *Target `json:"target,omitempty"`
|
||||
Type string `json:"type"`
|
||||
// AnnotationTarget is the Go representation of a dashboard.AnnotationTarget.
|
||||
//
|
||||
// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES.
|
||||
// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok.
|
||||
type AnnotationTarget struct {
|
||||
Limit int64 `json:"limit"`
|
||||
MatchAny bool `json:"matchAny"`
|
||||
Tags []string `json:"tags"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// 0 for no shared crosshair or tooltip (default).
|
||||
|
@ -13,6 +13,7 @@ var (
|
||||
ErrBaseTagLimitExceeded = errutil.NewBase(errutil.StatusBadRequest, "annotations.tag-limit-exceeded", errutil.WithPublicMessage("Tags length exceeds the maximum allowed."))
|
||||
)
|
||||
|
||||
//go:generate mockery --name Repository --structname FakeAnnotationsRepo --inpackage --filename annotations_repository_mock.go
|
||||
type Repository interface {
|
||||
Save(ctx context.Context, item *Item) error
|
||||
Update(ctx context.Context, item *Item) error
|
||||
|
111
pkg/services/annotations/annotations_repository_mock.go
Normal file
111
pkg/services/annotations/annotations_repository_mock.go
Normal file
@ -0,0 +1,111 @@
|
||||
// Code generated by mockery v2.12.1. DO NOT EDIT.
|
||||
|
||||
package annotations
|
||||
|
||||
import (
|
||||
context "context"
|
||||
testing "testing"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// FakeAnnotationsRepo is an autogenerated mock type for the Repository type
|
||||
type FakeAnnotationsRepo struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: ctx, params
|
||||
func (_m *FakeAnnotationsRepo) Delete(ctx context.Context, params *DeleteParams) error {
|
||||
ret := _m.Called(ctx, params)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *DeleteParams) error); ok {
|
||||
r0 = rf(ctx, params)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Find provides a mock function with given fields: ctx, query
|
||||
func (_m *FakeAnnotationsRepo) Find(ctx context.Context, query *ItemQuery) ([]*ItemDTO, error) {
|
||||
ret := _m.Called(ctx, query)
|
||||
|
||||
var r0 []*ItemDTO
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *ItemQuery) []*ItemDTO); ok {
|
||||
r0 = rf(ctx, query)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*ItemDTO)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *ItemQuery) error); ok {
|
||||
r1 = rf(ctx, query)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindTags provides a mock function with given fields: ctx, query
|
||||
func (_m *FakeAnnotationsRepo) FindTags(ctx context.Context, query *TagsQuery) (FindTagsResult, error) {
|
||||
ret := _m.Called(ctx, query)
|
||||
|
||||
var r0 FindTagsResult
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *TagsQuery) FindTagsResult); ok {
|
||||
r0 = rf(ctx, query)
|
||||
} else {
|
||||
r0 = ret.Get(0).(FindTagsResult)
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *TagsQuery) error); ok {
|
||||
r1 = rf(ctx, query)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Save provides a mock function with given fields: ctx, item
|
||||
func (_m *FakeAnnotationsRepo) Save(ctx context.Context, item *Item) error {
|
||||
ret := _m.Called(ctx, item)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *Item) error); ok {
|
||||
r0 = rf(ctx, item)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, item
|
||||
func (_m *FakeAnnotationsRepo) Update(ctx context.Context, item *Item) error {
|
||||
ret := _m.Called(ctx, item)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *Item) error); ok {
|
||||
r0 = rf(ctx, item)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// NewFakeAnnotationsRepo creates a new instance of FakeAnnotationsRepo. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
func NewFakeAnnotationsRepo(t testing.TB) *FakeAnnotationsRepo {
|
||||
mock := &FakeAnnotationsRepo{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
@ -63,6 +63,7 @@ func (api *Api) RegisterAPIEndpoints() {
|
||||
// public endpoints
|
||||
api.RouteRegister.Get("/api/public/dashboards/:accessToken", routing.Wrap(api.GetPublicDashboard))
|
||||
api.RouteRegister.Post("/api/public/dashboards/:accessToken/panels/:panelId/query", routing.Wrap(api.QueryPublicDashboard))
|
||||
api.RouteRegister.Get("/api/public/dashboards/:accessToken/annotations", routing.Wrap(api.GetAnnotations))
|
||||
|
||||
// List Public Dashboards
|
||||
api.RouteRegister.Get("/api/dashboards/public", middleware.ReqSignedIn, routing.Wrap(api.ListPublicDashboards))
|
||||
@ -187,6 +188,21 @@ func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response {
|
||||
return toJsonStreamingResponse(api.Features, resp)
|
||||
}
|
||||
|
||||
func (api *Api) GetAnnotations(c *models.ReqContext) response.Response {
|
||||
reqDTO := AnnotationsQueryDTO{
|
||||
From: c.QueryInt64("from"),
|
||||
To: c.QueryInt64("to"),
|
||||
}
|
||||
|
||||
annotations, err := api.PublicDashboardService.GetAnnotations(c.Req.Context(), reqDTO, web.Params(c.Req)[":accessToken"])
|
||||
|
||||
if err != nil {
|
||||
return api.handleError(c.Req.Context(), http.StatusInternalServerError, "error getting public dashboard annotations", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, annotations)
|
||||
}
|
||||
|
||||
// util to help us unpack dashboard and publicdashboard errors or use default http code and message
|
||||
// we should look to do some future refactoring of these errors as publicdashboard err is the same as a dashboarderr, just defined in a
|
||||
// different package.
|
||||
|
@ -22,6 +22,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
@ -49,6 +50,56 @@ type JsonErrResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func TestAPIGetAnnotations(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
ExpectedHttpResponse int
|
||||
Annotations []AnnotationEvent
|
||||
ServiceError error
|
||||
From string
|
||||
To string
|
||||
}{
|
||||
{
|
||||
Name: "will return success when there is no error and to and from are provided",
|
||||
ExpectedHttpResponse: http.StatusOK,
|
||||
Annotations: []AnnotationEvent{{Id: 1}},
|
||||
ServiceError: nil,
|
||||
From: "123",
|
||||
To: "123",
|
||||
},
|
||||
{
|
||||
Name: "will return 500 when service returns an error",
|
||||
ExpectedHttpResponse: http.StatusInternalServerError,
|
||||
Annotations: nil,
|
||||
ServiceError: errors.New("an error happened"),
|
||||
From: "123",
|
||||
To: "123",
|
||||
},
|
||||
}
|
||||
for _, test := range testCases {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.RBACEnabled = false
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
service.On("GetAnnotations", mock.Anything, mock.Anything, mock.AnythingOfType("string")).
|
||||
Return(test.Annotations, test.ServiceError).Once()
|
||||
testServer := setupTestServer(t, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), service, nil, anonymousUser)
|
||||
|
||||
path := fmt.Sprintf("/api/public/dashboards/abc123/annotations?from=%s&to=%s", test.From, test.To)
|
||||
response := callAPI(testServer, http.MethodGet, path, nil, t)
|
||||
|
||||
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
|
||||
|
||||
if test.ExpectedHttpResponse == http.StatusOK {
|
||||
var items []AnnotationEvent
|
||||
err := json.Unmarshal(response.Body.Bytes(), &items)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, items, test.Annotations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIFeatureFlag(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
@ -630,11 +681,13 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T)
|
||||
},
|
||||
}
|
||||
|
||||
annotationsService := annotationstest.NewFakeAnnotationsRepo()
|
||||
|
||||
// create public dashboard
|
||||
store := publicdashboardsStore.ProvideStore(db)
|
||||
cfg := setting.NewCfg()
|
||||
cfg.RBACEnabled = false
|
||||
service := publicdashboardsService.ProvideService(cfg, store, qds)
|
||||
service := publicdashboardsService.ProvideService(cfg, store, qds, annotationsService)
|
||||
pubdash, err := service.SavePublicDashboardConfig(context.Background(), &user.SignedInUser{}, savePubDashboardCmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/coremodel/dashboard"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
@ -77,6 +78,28 @@ type PublicDashboard struct {
|
||||
UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at"`
|
||||
}
|
||||
|
||||
// Alias the generated type
|
||||
type DashAnnotation = dashboard.AnnotationQuery
|
||||
|
||||
type AnnotationsDto struct {
|
||||
Annotations struct {
|
||||
List []DashAnnotation `json:"list"`
|
||||
}
|
||||
}
|
||||
|
||||
type AnnotationEvent struct {
|
||||
Id int64 `json:"id"`
|
||||
DashboardId int64 `json:"dashboardId"`
|
||||
PanelId int64 `json:"panelId"`
|
||||
Tags []string `json:"tags"`
|
||||
IsRegion bool `json:"isRegion"`
|
||||
Text string `json:"text"`
|
||||
Color string `json:"color"`
|
||||
Time int64 `json:"time"`
|
||||
TimeEnd int64 `json:"timeEnd"`
|
||||
Source dashboard.AnnotationQuery `json:"source"`
|
||||
}
|
||||
|
||||
func (pd PublicDashboard) TableName() string {
|
||||
return "dashboard_public"
|
||||
}
|
||||
@ -135,6 +158,11 @@ type PublicDashboardQueryDTO struct {
|
||||
MaxDataPoints int64
|
||||
}
|
||||
|
||||
type AnnotationsQueryDTO struct {
|
||||
From int64
|
||||
To int64
|
||||
}
|
||||
|
||||
//
|
||||
// COMMANDS
|
||||
//
|
||||
|
@ -62,6 +62,29 @@ func (_m *FakePublicDashboardService) BuildAnonymousUser(ctx context.Context, da
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetAnnotations provides a mock function with given fields: ctx, reqDTO, accessToken
|
||||
func (_m *FakePublicDashboardService) GetAnnotations(ctx context.Context, reqDTO publicdashboardsmodels.AnnotationsQueryDTO, accessToken string) ([]publicdashboardsmodels.AnnotationEvent, error) {
|
||||
ret := _m.Called(ctx, reqDTO, accessToken)
|
||||
|
||||
var r0 []publicdashboardsmodels.AnnotationEvent
|
||||
if rf, ok := ret.Get(0).(func(context.Context, publicdashboardsmodels.AnnotationsQueryDTO, string) []publicdashboardsmodels.AnnotationEvent); ok {
|
||||
r0 = rf(ctx, reqDTO, accessToken)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]publicdashboardsmodels.AnnotationEvent)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, publicdashboardsmodels.AnnotationsQueryDTO, string) error); ok {
|
||||
r1 = rf(ctx, reqDTO, accessToken)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetDashboard provides a mock function with given fields: ctx, dashboardUid
|
||||
func (_m *FakePublicDashboardService) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) {
|
||||
ret := _m.Called(ctx, dashboardUid)
|
||||
|
@ -16,6 +16,7 @@ import (
|
||||
type Service interface {
|
||||
AccessTokenExists(ctx context.Context, accessToken string) (bool, error)
|
||||
BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) *user.SignedInUser
|
||||
GetAnnotations(ctx context.Context, reqDTO AnnotationsQueryDTO, accessToken string) ([]AnnotationEvent, error)
|
||||
GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error)
|
||||
GetMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64, reqDTO PublicDashboardQueryDTO) (dtos.MetricRequest, error)
|
||||
GetPublicDashboard(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error)
|
||||
|
22
pkg/services/publicdashboards/service/annotations.go
Normal file
22
pkg/services/publicdashboards/service/annotations.go
Normal file
@ -0,0 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
||||
)
|
||||
|
||||
func UnmarshalDashboardAnnotations(sj *simplejson.Json) (*models.AnnotationsDto, error) {
|
||||
bytes, err := sj.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := &models.AnnotationsDto{}
|
||||
err = json.Unmarshal(bytes, dto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto, err
|
||||
}
|
@ -2,13 +2,15 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
. "github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
||||
@ -17,6 +19,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/query"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafanads"
|
||||
"github.com/grafana/grafana/pkg/tsdb/intervalv2"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
@ -29,6 +32,7 @@ type PublicDashboardServiceImpl struct {
|
||||
store publicdashboards.Store
|
||||
intervalCalculator intervalv2.Calculator
|
||||
QueryDataService *query.Service
|
||||
AnnotationsRepo annotations.Repository
|
||||
}
|
||||
|
||||
var LogPrefix = "publicdashboards.service"
|
||||
@ -43,6 +47,7 @@ func ProvideService(
|
||||
cfg *setting.Cfg,
|
||||
store publicdashboards.Store,
|
||||
qds *query.Service,
|
||||
anno annotations.Repository,
|
||||
) *PublicDashboardServiceImpl {
|
||||
return &PublicDashboardServiceImpl{
|
||||
log: log.New(LogPrefix),
|
||||
@ -50,6 +55,7 @@ func ProvideService(
|
||||
store: store,
|
||||
intervalCalculator: intervalv2.NewCalculator(),
|
||||
QueryDataService: qds,
|
||||
AnnotationsRepo: anno,
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,6 +258,81 @@ func (pd *PublicDashboardServiceImpl) GetMetricRequest(ctx context.Context, dash
|
||||
return metricReqDTO, nil
|
||||
}
|
||||
|
||||
func (pd *PublicDashboardServiceImpl) GetAnnotations(ctx context.Context, reqDTO AnnotationsQueryDTO, accessToken string) ([]AnnotationEvent, error) {
|
||||
_, dash, err := pd.GetPublicDashboard(ctx, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annoDto, err := UnmarshalDashboardAnnotations(dash.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
anonymousUser := pd.BuildAnonymousUser(ctx, dash)
|
||||
|
||||
uniqueEvents := make(map[int64]AnnotationEvent, 0)
|
||||
for _, anno := range annoDto.Annotations.List {
|
||||
// skip annotations that are not enabled or are not a grafana datasource
|
||||
if !anno.Enable || (*anno.Datasource.Uid != grafanads.DatasourceUID && *anno.Datasource.Uid != grafanads.DatasourceName) {
|
||||
continue
|
||||
}
|
||||
annoQuery := &annotations.ItemQuery{
|
||||
From: reqDTO.From,
|
||||
To: reqDTO.To,
|
||||
OrgId: dash.OrgId,
|
||||
DashboardId: dash.Id,
|
||||
DashboardUid: dash.Uid,
|
||||
Limit: anno.Target.Limit,
|
||||
MatchAny: anno.Target.MatchAny,
|
||||
SignedInUser: anonymousUser,
|
||||
}
|
||||
|
||||
if anno.Target.Type == "tags" {
|
||||
annoQuery.DashboardId = 0
|
||||
annoQuery.Tags = anno.Target.Tags
|
||||
}
|
||||
|
||||
annotationItems, err := pd.AnnotationsRepo.Find(ctx, annoQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range annotationItems {
|
||||
event := AnnotationEvent{
|
||||
Id: item.Id,
|
||||
DashboardId: item.DashboardId,
|
||||
Tags: item.Tags,
|
||||
IsRegion: item.TimeEnd > 0 && item.Time != item.TimeEnd,
|
||||
Text: item.Text,
|
||||
Color: *anno.IconColor,
|
||||
Time: item.Time,
|
||||
TimeEnd: item.TimeEnd,
|
||||
Source: anno,
|
||||
}
|
||||
|
||||
// We want dashboard annotations to reference the panel they're for. If no panelId is provided, they'll show up on all panels
|
||||
// which is only intended for tag and org annotations.
|
||||
if anno.Type == "dashboard" {
|
||||
event.PanelId = item.PanelId
|
||||
}
|
||||
|
||||
// We want events from tag queries to overwrite existing events
|
||||
_, has := uniqueEvents[event.Id]
|
||||
if !has || (has && anno.Target.Type == "tags") {
|
||||
uniqueEvents[event.Id] = event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var results []AnnotationEvent
|
||||
for _, result := range uniqueEvents {
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// buildMetricRequest merges public dashboard parameters with
|
||||
// dashboard and returns a metrics request to be sent to query backend
|
||||
func (pd *PublicDashboardServiceImpl) buildMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64, reqDTO PublicDashboardQueryDTO) (dtos.MetricRequest, error) {
|
||||
@ -282,17 +363,30 @@ func (pd *PublicDashboardServiceImpl) buildMetricRequest(ctx context.Context, da
|
||||
func (pd *PublicDashboardServiceImpl) BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) *user.SignedInUser {
|
||||
datasourceUids := queries.GetUniqueDashboardDatasourceUids(dashboard.Data)
|
||||
|
||||
// Create a temp user with read-only datasource permissions
|
||||
// Create a user with blank permissions
|
||||
anonymousUser := &user.SignedInUser{OrgID: dashboard.OrgId, Permissions: make(map[int64]map[string][]string)}
|
||||
permissions := make(map[string][]string)
|
||||
|
||||
// Scopes needed for Annotation queries
|
||||
annotationScopes := []string{accesscontrol.ScopeAnnotationsTypeDashboard}
|
||||
// Need to access all dashboards since tags annotations span across all dashboards
|
||||
dashboardScopes := []string{dashboards.ScopeDashboardsProvider.GetResourceAllScope()}
|
||||
|
||||
// Scopes needed for datasource queries
|
||||
queryScopes := make([]string, 0)
|
||||
readScopes := make([]string, 0)
|
||||
for _, uid := range datasourceUids {
|
||||
queryScopes = append(queryScopes, fmt.Sprintf("datasources:uid:%s", uid))
|
||||
readScopes = append(readScopes, fmt.Sprintf("datasources:uid:%s", uid))
|
||||
scope := datasources.ScopeProvider.GetResourceScopeUID(uid)
|
||||
queryScopes = append(queryScopes, scope)
|
||||
readScopes = append(readScopes, scope)
|
||||
}
|
||||
|
||||
// Apply all scopes to the actions we need the user to be able to perform
|
||||
permissions := make(map[string][]string)
|
||||
permissions[datasources.ActionQuery] = queryScopes
|
||||
permissions[datasources.ActionRead] = readScopes
|
||||
permissions[accesscontrol.ActionAnnotationsRead] = annotationScopes
|
||||
permissions[dashboards.ActionDashboardsRead] = dashboardScopes
|
||||
|
||||
anonymousUser.Permissions[dashboard.OrgId] = permissions
|
||||
|
||||
return anonymousUser
|
||||
|
@ -2,13 +2,19 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dashboard2 "github.com/grafana/grafana/pkg/coremodel/dashboard"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationsimpl"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards/internal"
|
||||
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -35,6 +41,289 @@ func TestLogPrefix(t *testing.T) {
|
||||
assert.Equal(t, LogPrefix, "publicdashboards.service")
|
||||
}
|
||||
|
||||
func TestGetAnnotations(t *testing.T) {
|
||||
t.Run("will build anonymous user with correct permissions to get annotations", func(t *testing.T) {
|
||||
sqlStore := sqlstore.InitTestDB(t)
|
||||
config := setting.NewCfg()
|
||||
tagService := tagimpl.ProvideService(sqlStore, sqlStore.Cfg)
|
||||
annotationsRepo := annotationsimpl.ProvideService(sqlStore, config, tagService)
|
||||
fakeStore := FakePublicDashboardStore{}
|
||||
service := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: &fakeStore,
|
||||
AnnotationsRepo: annotationsRepo,
|
||||
}
|
||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
||||
Return(&PublicDashboard{Uid: "uid1", IsEnabled: true}, models.NewDashboard("dash1"), nil)
|
||||
reqDTO := AnnotationsQueryDTO{
|
||||
From: 1,
|
||||
To: 2,
|
||||
}
|
||||
dash := models.NewDashboard("testDashboard")
|
||||
|
||||
items, _ := service.GetAnnotations(context.Background(), reqDTO, "abc123")
|
||||
anonUser := service.BuildAnonymousUser(context.Background(), dash)
|
||||
|
||||
assert.Equal(t, "dashboards:*", anonUser.Permissions[0]["dashboards:read"][0])
|
||||
assert.Len(t, items, 0)
|
||||
})
|
||||
|
||||
t.Run("Test events from tag queries overwrite built-in annotation queries and duplicate events are not returned", func(t *testing.T) {
|
||||
dash := models.NewDashboard("test")
|
||||
color := "red"
|
||||
name := "annoName"
|
||||
grafanaAnnotation := DashAnnotation{
|
||||
Datasource: CreateDatasource("grafana", "grafana"),
|
||||
Enable: true,
|
||||
Name: &name,
|
||||
IconColor: &color,
|
||||
Target: &dashboard2.AnnotationTarget{
|
||||
Limit: 100,
|
||||
MatchAny: false,
|
||||
Tags: nil,
|
||||
Type: "dashboard",
|
||||
},
|
||||
Type: "dashboard",
|
||||
}
|
||||
grafanaTagAnnotation := DashAnnotation{
|
||||
Datasource: CreateDatasource("grafana", "grafana"),
|
||||
Enable: true,
|
||||
Name: &name,
|
||||
IconColor: &color,
|
||||
Target: &dashboard2.AnnotationTarget{
|
||||
Limit: 100,
|
||||
MatchAny: false,
|
||||
Tags: []string{"tag1"},
|
||||
Type: "tags",
|
||||
},
|
||||
}
|
||||
annos := []DashAnnotation{grafanaAnnotation, grafanaTagAnnotation}
|
||||
dashboard := AddAnnotationsToDashboard(t, dash, annos)
|
||||
|
||||
annotationsRepo := annotations.FakeAnnotationsRepo{}
|
||||
fakeStore := FakePublicDashboardStore{}
|
||||
service := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: &fakeStore,
|
||||
AnnotationsRepo: &annotationsRepo,
|
||||
}
|
||||
pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.Uid}
|
||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, dashboard, nil)
|
||||
annotationsRepo.On("Find", mock.Anything, mock.Anything).Return([]*annotations.ItemDTO{
|
||||
{
|
||||
Id: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 1,
|
||||
Tags: []string{"tag1"},
|
||||
TimeEnd: 2,
|
||||
Time: 2,
|
||||
Text: "text",
|
||||
},
|
||||
}, nil).Maybe()
|
||||
|
||||
items, err := service.GetAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123")
|
||||
|
||||
expected := AnnotationEvent{
|
||||
Id: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 0,
|
||||
Tags: []string{"tag1"},
|
||||
IsRegion: false,
|
||||
Text: "text",
|
||||
Color: color,
|
||||
Time: 2,
|
||||
TimeEnd: 2,
|
||||
Source: grafanaTagAnnotation,
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, items, 1)
|
||||
assert.Equal(t, expected, items[0])
|
||||
})
|
||||
|
||||
t.Run("Test panelId set to zero when annotation event is for a tags query", func(t *testing.T) {
|
||||
dash := models.NewDashboard("test")
|
||||
color := "red"
|
||||
name := "annoName"
|
||||
grafanaAnnotation := DashAnnotation{
|
||||
Datasource: CreateDatasource("grafana", "grafana"),
|
||||
Enable: true,
|
||||
Name: &name,
|
||||
IconColor: &color,
|
||||
Target: &dashboard2.AnnotationTarget{
|
||||
Limit: 100,
|
||||
MatchAny: false,
|
||||
Tags: []string{"tag1"},
|
||||
Type: "tags",
|
||||
},
|
||||
}
|
||||
annos := []DashAnnotation{grafanaAnnotation}
|
||||
dashboard := AddAnnotationsToDashboard(t, dash, annos)
|
||||
|
||||
annotationsRepo := annotations.FakeAnnotationsRepo{}
|
||||
fakeStore := FakePublicDashboardStore{}
|
||||
service := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: &fakeStore,
|
||||
AnnotationsRepo: &annotationsRepo,
|
||||
}
|
||||
pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.Uid}
|
||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, dashboard, nil)
|
||||
annotationsRepo.On("Find", mock.Anything, mock.Anything).Return([]*annotations.ItemDTO{
|
||||
{
|
||||
Id: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 1,
|
||||
Tags: []string{},
|
||||
TimeEnd: 1,
|
||||
Time: 2,
|
||||
Text: "text",
|
||||
},
|
||||
}, nil).Maybe()
|
||||
|
||||
items, err := service.GetAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123")
|
||||
|
||||
expected := AnnotationEvent{
|
||||
Id: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 0,
|
||||
Tags: []string{},
|
||||
IsRegion: true,
|
||||
Text: "text",
|
||||
Color: color,
|
||||
Time: 2,
|
||||
TimeEnd: 1,
|
||||
Source: grafanaAnnotation,
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, items, 1)
|
||||
assert.Equal(t, expected, items[0])
|
||||
})
|
||||
|
||||
t.Run("Test can get grafana annotations and will skip annotation queries and disabled annotations", func(t *testing.T) {
|
||||
dash := models.NewDashboard("test")
|
||||
color := "red"
|
||||
name := "annoName"
|
||||
disabledGrafanaAnnotation := DashAnnotation{
|
||||
Datasource: CreateDatasource("grafana", "grafana"),
|
||||
Enable: false,
|
||||
Name: &name,
|
||||
IconColor: &color,
|
||||
}
|
||||
grafanaAnnotation := DashAnnotation{
|
||||
Datasource: CreateDatasource("grafana", "grafana"),
|
||||
Enable: true,
|
||||
Name: &name,
|
||||
IconColor: &color,
|
||||
Target: &dashboard2.AnnotationTarget{
|
||||
Limit: 100,
|
||||
MatchAny: true,
|
||||
Tags: nil,
|
||||
Type: "dashboard",
|
||||
},
|
||||
Type: "dashboard",
|
||||
}
|
||||
queryAnnotation := DashAnnotation{
|
||||
Datasource: CreateDatasource("prometheus", "abc123"),
|
||||
Enable: true,
|
||||
Name: &name,
|
||||
}
|
||||
annos := []DashAnnotation{grafanaAnnotation, queryAnnotation, disabledGrafanaAnnotation}
|
||||
dashboard := AddAnnotationsToDashboard(t, dash, annos)
|
||||
|
||||
annotationsRepo := annotations.FakeAnnotationsRepo{}
|
||||
fakeStore := FakePublicDashboardStore{}
|
||||
service := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: &fakeStore,
|
||||
AnnotationsRepo: &annotationsRepo,
|
||||
}
|
||||
pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.Uid}
|
||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, dashboard, nil)
|
||||
annotationsRepo.On("Find", mock.Anything, mock.Anything).Return([]*annotations.ItemDTO{
|
||||
{
|
||||
Id: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 1,
|
||||
Tags: []string{},
|
||||
TimeEnd: 1,
|
||||
Time: 2,
|
||||
Text: "text",
|
||||
},
|
||||
}, nil).Maybe()
|
||||
|
||||
items, err := service.GetAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123")
|
||||
|
||||
expected := AnnotationEvent{
|
||||
Id: 1,
|
||||
DashboardId: 1,
|
||||
PanelId: 1,
|
||||
Tags: []string{},
|
||||
IsRegion: true,
|
||||
Text: "text",
|
||||
Color: color,
|
||||
Time: 2,
|
||||
TimeEnd: 1,
|
||||
Source: grafanaAnnotation,
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, items, 1)
|
||||
assert.Equal(t, expected, items[0])
|
||||
})
|
||||
|
||||
t.Run("test will return nothing when dashboard has no annotations", func(t *testing.T) {
|
||||
annotationsRepo := annotations.FakeAnnotationsRepo{}
|
||||
fakeStore := FakePublicDashboardStore{}
|
||||
service := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: &fakeStore,
|
||||
AnnotationsRepo: &annotationsRepo,
|
||||
}
|
||||
dashboard := models.NewDashboard("dashWithNoAnnotations")
|
||||
pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dashboard.Uid}
|
||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, dashboard, nil)
|
||||
|
||||
items, err := service.GetAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, items)
|
||||
})
|
||||
|
||||
t.Run("test will error when annotations repo returns an error", func(t *testing.T) {
|
||||
annotationsRepo := annotations.FakeAnnotationsRepo{}
|
||||
fakeStore := FakePublicDashboardStore{}
|
||||
service := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: &fakeStore,
|
||||
AnnotationsRepo: &annotationsRepo,
|
||||
}
|
||||
dash := models.NewDashboard("test")
|
||||
color := "red"
|
||||
name := "annoName"
|
||||
grafanaAnnotation := DashAnnotation{
|
||||
Datasource: CreateDatasource("grafana", "grafana"),
|
||||
Enable: true,
|
||||
Name: &name,
|
||||
IconColor: &color,
|
||||
Target: &dashboard2.AnnotationTarget{
|
||||
Limit: 100,
|
||||
MatchAny: false,
|
||||
Tags: []string{"tag1"},
|
||||
Type: "tags",
|
||||
},
|
||||
}
|
||||
annos := []DashAnnotation{grafanaAnnotation}
|
||||
dash = AddAnnotationsToDashboard(t, dash, annos)
|
||||
pubdash := &PublicDashboard{Uid: "uid1", IsEnabled: true, OrgId: 1, DashboardUid: dash.Uid}
|
||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).Return(pubdash, dash, nil)
|
||||
annotationsRepo.On("Find", mock.Anything, mock.Anything).Return(nil, errors.New("failed")).Maybe()
|
||||
|
||||
items, err := service.GetAnnotations(context.Background(), AnnotationsQueryDTO{}, "abc123")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, items)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetPublicDashboard(t *testing.T) {
|
||||
type storeResp struct {
|
||||
pd *PublicDashboard
|
||||
@ -374,12 +663,20 @@ func TestBuildAnonymousUser(t *testing.T) {
|
||||
|
||||
t.Run("will add datasource read and query permissions to user for each datasource in dashboard", func(t *testing.T) {
|
||||
user := service.BuildAnonymousUser(context.Background(), dashboard)
|
||||
|
||||
require.Equal(t, dashboard.OrgId, user.OrgID)
|
||||
require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:query"][0])
|
||||
require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:query"][1])
|
||||
require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:read"][0])
|
||||
require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:read"][1])
|
||||
})
|
||||
t.Run("will add dashboard and annotation permissions needed for getting annotations", func(t *testing.T) {
|
||||
user := service.BuildAnonymousUser(context.Background(), dashboard)
|
||||
|
||||
require.Equal(t, dashboard.OrgId, user.OrgID)
|
||||
require.Equal(t, "annotations:type:dashboard", user.Permissions[user.OrgID]["annotations:read"][0])
|
||||
require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["dashboards:read"][0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMetricRequest(t *testing.T) {
|
||||
@ -863,3 +1160,33 @@ func TestDashboardEnabledChanged(t *testing.T) {
|
||||
assert.True(t, publicDashboardIsEnabledChanged(&PublicDashboard{IsEnabled: false}, &PublicDashboard{IsEnabled: true}))
|
||||
})
|
||||
}
|
||||
|
||||
func CreateDatasource(dsType string, uid string) struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
Uid *string `json:"uid,omitempty"`
|
||||
} {
|
||||
return struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
Uid *string `json:"uid,omitempty"`
|
||||
}{
|
||||
Type: &dsType,
|
||||
Uid: &uid,
|
||||
}
|
||||
}
|
||||
|
||||
func AddAnnotationsToDashboard(t *testing.T, dash *models.Dashboard, annotations []DashAnnotation) *models.Dashboard {
|
||||
type annotationsDto struct {
|
||||
List []DashAnnotation `json:"list"`
|
||||
}
|
||||
annos := annotationsDto{}
|
||||
annos.List = annotations
|
||||
annoJSON, err := json.Marshal(annos)
|
||||
require.NoError(t, err)
|
||||
|
||||
dashAnnos, err := simplejson.NewJson(annoJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
dash.Data.Set("annotations", dashAnnos)
|
||||
|
||||
return dash
|
||||
}
|
||||
|
@ -53,6 +53,7 @@ export function executeAnnotationQuery(
|
||||
scopedVars,
|
||||
...interval,
|
||||
app: CoreApp.Dashboard,
|
||||
publicDashboardAccessToken: options.dashboard.meta.publicDashboardAccessToken,
|
||||
|
||||
timezone: options.dashboard.timezone,
|
||||
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
KeyValue,
|
||||
standardTransformers,
|
||||
} from '@grafana/data';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
export const standardAnnotationSupport: AnnotationSupport = {
|
||||
/**
|
||||
@ -112,9 +113,22 @@ export const annotationEventNames: AnnotationFieldInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const publicDashboardEventNames: AnnotationFieldInfo[] = [
|
||||
{
|
||||
key: 'color',
|
||||
},
|
||||
{
|
||||
key: 'isRegion',
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
},
|
||||
];
|
||||
|
||||
// Given legacy infrastructure, alert events are passed though the same annotation
|
||||
// pipeline, but include fields that should not be exposed generally
|
||||
const alertEventAndAnnotationFields: AnnotationFieldInfo[] = [
|
||||
...(config.isPublicDashboardView ? publicDashboardEventNames : []),
|
||||
...annotationEventNames,
|
||||
{ key: 'userId' },
|
||||
{ key: 'login' },
|
||||
@ -185,7 +199,8 @@ export function getAnnotationsFromData(
|
||||
}
|
||||
|
||||
if (!hasTime || !hasText) {
|
||||
return []; // throw an error?
|
||||
console.error('Cannot process annotation fields. No time or text present.');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Add each value to the string
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DataQueryRequest, DataSourceInstanceSettings, DataSourceRef } from '@grafana/data';
|
||||
import { DataQueryRequest, DataSourceInstanceSettings, DataSourceRef, TimeRange } from '@grafana/data';
|
||||
import { BackendSrvRequest, BackendSrv, DataSourceWithBackend } from '@grafana/runtime';
|
||||
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { GRAFANA_DATASOURCE_NAME } from '../../alerting/unified/utils/datasource';
|
||||
|
||||
import { PublicDashboardDataSource, PUBLIC_DATASOURCE, DEFAULT_INTERVAL } from './PublicDashboardDataSource';
|
||||
|
||||
const mockDatasourceRequest = jest.fn();
|
||||
@ -12,6 +15,9 @@ const backendSrv = {
|
||||
fetch: (options: BackendSrvRequest) => {
|
||||
return of(mockDatasourceRequest(options));
|
||||
},
|
||||
get: (url: string, options?: Partial<BackendSrvRequest>) => {
|
||||
return mockDatasourceRequest(url, options);
|
||||
},
|
||||
} as unknown as BackendSrv;
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
@ -25,7 +31,50 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
describe('PublicDashboardDatasource', () => {
|
||||
test('Fetches results from the pubdash query endpoint', () => {
|
||||
test('will add annotation query type to annotations', () => {
|
||||
const ds = new PublicDashboardDataSource('public');
|
||||
const annotationQuery = {
|
||||
enable: true,
|
||||
name: 'someName',
|
||||
iconColor: 'red',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const annotation = ds?.annotations.prepareQuery(annotationQuery);
|
||||
|
||||
expect(annotation?.queryType).toEqual(GrafanaQueryType.Annotations);
|
||||
});
|
||||
|
||||
test('fetches results from the pubdash annotations endpoint when it is an annotation query', async () => {
|
||||
mockDatasourceRequest.mockReset();
|
||||
mockDatasourceRequest.mockReturnValue(Promise.resolve([]));
|
||||
|
||||
const ds = new PublicDashboardDataSource('public');
|
||||
const panelId = 1;
|
||||
const publicDashboardAccessToken = 'abc123';
|
||||
|
||||
await ds.query({
|
||||
maxDataPoints: 10,
|
||||
intervalMs: 5000,
|
||||
targets: [
|
||||
{
|
||||
refId: 'A',
|
||||
datasource: { uid: GRAFANA_DATASOURCE_NAME, type: 'sample' },
|
||||
queryType: GrafanaQueryType.Annotations,
|
||||
},
|
||||
],
|
||||
panelId,
|
||||
publicDashboardAccessToken,
|
||||
range: { from: new Date().toLocaleString(), to: new Date().toLocaleString() } as unknown as TimeRange,
|
||||
} as DataQueryRequest);
|
||||
|
||||
const mock = mockDatasourceRequest.mock;
|
||||
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(mock.lastCall[0]).toEqual(`/api/public/dashboards/${publicDashboardAccessToken}/annotations`);
|
||||
});
|
||||
|
||||
test('fetches results from the pubdash query endpoint when not annotation query', () => {
|
||||
mockDatasourceRequest.mockReset();
|
||||
mockDatasourceRequest.mockReturnValue(Promise.resolve({}));
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { catchError, Observable, of, switchMap } from 'rxjs';
|
||||
import { catchError, from, Observable, of, switchMap } from 'rxjs';
|
||||
|
||||
import {
|
||||
AnnotationQuery,
|
||||
DataQuery,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
@ -8,10 +9,13 @@ import {
|
||||
DataSourceJsonData,
|
||||
DataSourcePluginMeta,
|
||||
DataSourceRef,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { BackendDataSourceResponse, getBackendSrv, toDataQueryResponse } from '@grafana/runtime';
|
||||
|
||||
import { GrafanaQueryType } from '../../../plugins/datasource/grafana/types';
|
||||
import { MIXED_DATASOURCE_NAME } from '../../../plugins/datasource/mixed/MixedDataSource';
|
||||
import { GRAFANA_DATASOURCE_NAME } from '../../alerting/unified/utils/datasource';
|
||||
|
||||
export const PUBLIC_DATASOURCE = '-- Public --';
|
||||
export const DEFAULT_INTERVAL = '1min';
|
||||
@ -35,6 +39,12 @@ export class PublicDashboardDataSource extends DataSourceApi<DataQuery, DataSour
|
||||
});
|
||||
|
||||
this.interval = PublicDashboardDataSource.resolveInterval(datasource);
|
||||
|
||||
this.annotations = {
|
||||
prepareQuery(anno: AnnotationQuery): DataQuery | undefined {
|
||||
return { ...anno, queryType: GrafanaQueryType.Annotations, refId: 'anno' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -78,23 +88,51 @@ export class PublicDashboardDataSource extends DataSourceApi<DataQuery, DataSour
|
||||
return of({ data: [] });
|
||||
}
|
||||
|
||||
const body: any = { intervalMs, maxDataPoints };
|
||||
// Its an annotations query
|
||||
// Currently, annotations requests come in one at a time, so there will only be one target
|
||||
const target = request.targets[0];
|
||||
if (target.queryType === GrafanaQueryType.Annotations) {
|
||||
if (target?.datasource?.uid === GRAFANA_DATASOURCE_NAME) {
|
||||
return from(this.getAnnotations(request));
|
||||
}
|
||||
return of({ data: [] });
|
||||
}
|
||||
|
||||
return getBackendSrv()
|
||||
.fetch<BackendDataSourceResponse>({
|
||||
url: `/api/public/dashboards/${publicDashboardAccessToken}/panels/${panelId}/query`,
|
||||
method: 'POST',
|
||||
data: body,
|
||||
requestId,
|
||||
})
|
||||
.pipe(
|
||||
switchMap((raw) => {
|
||||
return of(toDataQueryResponse(raw, queries));
|
||||
}),
|
||||
catchError((err) => {
|
||||
return of(toDataQueryResponse(err));
|
||||
// Its a datasource query
|
||||
else {
|
||||
const body: any = { intervalMs, maxDataPoints };
|
||||
|
||||
return getBackendSrv()
|
||||
.fetch<BackendDataSourceResponse>({
|
||||
url: `/api/public/dashboards/${publicDashboardAccessToken}/panels/${panelId}/query`,
|
||||
method: 'POST',
|
||||
data: body,
|
||||
requestId,
|
||||
})
|
||||
);
|
||||
.pipe(
|
||||
switchMap((raw) => {
|
||||
return of(toDataQueryResponse(raw, queries));
|
||||
}),
|
||||
catchError((err) => {
|
||||
return of(toDataQueryResponse(err));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getAnnotations(request: DataQueryRequest<DataQuery>): Promise<DataQueryResponse> {
|
||||
const {
|
||||
publicDashboardAccessToken: accessToken,
|
||||
range: { to, from },
|
||||
} = request;
|
||||
|
||||
const params = {
|
||||
from: from.valueOf(),
|
||||
to: to.valueOf(),
|
||||
};
|
||||
const annotations = await getBackendSrv().get(`/api/public/dashboards/${accessToken}/annotations`, params);
|
||||
|
||||
return { data: [toDataFrame(annotations)] };
|
||||
}
|
||||
|
||||
testDatasource(): Promise<any> {
|
||||
|
@ -91,7 +91,7 @@ describe('AnnotationsWorker', () => {
|
||||
const options = getDefaultOptions();
|
||||
options.dashboard.meta.publicDashboardAccessToken = 'accessTokenString';
|
||||
|
||||
expect(worker.canWork(options)).toBe(false);
|
||||
expect(worker.canWork(options)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -6,6 +6,7 @@ import { AnnotationQuery, DataSourceApi } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
|
||||
import { AnnotationQueryFinished, AnnotationQueryStarted } from '../../../../types/events';
|
||||
import { PUBLIC_DATASOURCE, PublicDashboardDataSource } from '../../../dashboard/services/PublicDashboardDataSource';
|
||||
import { DashboardModel } from '../../../dashboard/state';
|
||||
|
||||
import { AnnotationsQueryRunner } from './AnnotationsQueryRunner';
|
||||
@ -29,8 +30,8 @@ export class AnnotationsWorker implements DashboardQueryRunnerWorker {
|
||||
|
||||
canWork({ dashboard }: DashboardQueryRunnerOptions): boolean {
|
||||
const annotations = dashboard.annotations.list.find(AnnotationsWorker.getAnnotationsToProcessFilter);
|
||||
// We shouldn't return annotations for public dashboards v1
|
||||
return Boolean(annotations) && !this.publicDashboardViewMode(dashboard);
|
||||
|
||||
return Boolean(annotations);
|
||||
}
|
||||
|
||||
work(options: DashboardQueryRunnerOptions): Observable<DashboardQueryRunnerWorkerResult> {
|
||||
@ -39,11 +40,22 @@ export class AnnotationsWorker implements DashboardQueryRunnerWorker {
|
||||
}
|
||||
|
||||
const { dashboard, range } = options;
|
||||
const annotations = dashboard.annotations.list.filter(AnnotationsWorker.getAnnotationsToProcessFilter);
|
||||
let annotations = dashboard.annotations.list.filter(AnnotationsWorker.getAnnotationsToProcessFilter);
|
||||
// We only want to create a single PublicDashboardDatasource. This will get all annotations in one request.
|
||||
if (dashboard.meta.publicDashboardAccessToken && annotations.length > 0) {
|
||||
annotations = [annotations[0]];
|
||||
}
|
||||
const observables = annotations.map((annotation) => {
|
||||
const datasourceObservable = from(getDataSourceSrv().get(annotation.datasource)).pipe(
|
||||
catchError(handleDatasourceSrvError) // because of the reduce all observables need to be completed, so an erroneous observable wont do
|
||||
);
|
||||
let datasourceObservable;
|
||||
if (dashboard.meta.publicDashboardAccessToken !== '') {
|
||||
const pubdashDatasource = new PublicDashboardDataSource(PUBLIC_DATASOURCE);
|
||||
datasourceObservable = of(pubdashDatasource).pipe(catchError(handleDatasourceSrvError));
|
||||
} else {
|
||||
datasourceObservable = from(getDataSourceSrv().get(annotation.datasource)).pipe(
|
||||
catchError(handleDatasourceSrvError) // because of the reduce all observables need to be completed, so an erroneous observable wont do
|
||||
);
|
||||
}
|
||||
|
||||
return datasourceObservable.pipe(
|
||||
mergeMap((datasource?: DataSourceApi) => {
|
||||
const runner = this.runners.find((r) => r.canRun(datasource));
|
||||
@ -65,7 +77,11 @@ export class AnnotationsWorker implements DashboardQueryRunnerWorker {
|
||||
annotation.snapshotData = cloneDeep(results);
|
||||
}
|
||||
// translate result
|
||||
return translateQueryResult(annotation, results);
|
||||
if (dashboard.meta.publicDashboardAccessToken) {
|
||||
return results;
|
||||
} else {
|
||||
return translateQueryResult(annotation, results);
|
||||
}
|
||||
}),
|
||||
finalize(() => {
|
||||
dashboard.events.publish(new AnnotationQueryFinished(annotation));
|
||||
|
@ -19,7 +19,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
function getDefaultOptions(): DashboardQueryRunnerOptions {
|
||||
const dashboard: any = { id: 'an id', uid: 'a uid' };
|
||||
const dashboard: any = { id: 'an id', uid: 'a uid', meta: { publicDashboardAccessToken: '' } };
|
||||
const range = getDefaultTimeRange();
|
||||
|
||||
return { dashboard, range };
|
||||
@ -49,6 +49,15 @@ describe('UnifiedAlertStatesWorker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canWork is called on a public dashboard view', () => {
|
||||
it('then it should return false', () => {
|
||||
const options = getDefaultOptions();
|
||||
options.dashboard.meta.publicDashboardAccessToken = 'abc123';
|
||||
|
||||
expect(worker.canWork(options)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canWork is called with no dashboard id', () => {
|
||||
it('then it should return false', () => {
|
||||
const dashboard: any = {};
|
||||
@ -114,6 +123,7 @@ describe('UnifiedAlertStatesWorker', () => {
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('then it should return the correct results', async () => {
|
||||
const getResults: PromRulesResponse = {
|
||||
status: 'success',
|
||||
|
@ -23,6 +23,11 @@ export class UnifiedAlertStatesWorker implements DashboardQueryRunnerWorker {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot fetch rules while on a public dashboard since it's unauthenticated
|
||||
if (dashboard.meta.publicDashboardAccessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (range.raw.to !== 'now') {
|
||||
return false;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user