mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Publicdasboards: Add annotations support (#56413)
adds annotations support for public dashboards
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user