Publicdasboards: Add annotations support (#56413)

adds annotations support for public dashboards
This commit is contained in:
owensmallwood 2022-10-18 19:48:20 -06:00 committed by GitHub
parent cc6245df8e
commit b2408dd7c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 872 additions and 47 deletions

View File

@ -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;
}

View File

@ -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

View File

@ -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).

View File

@ -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

View 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
}

View File

@ -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.

View File

@ -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)

View File

@ -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
//

View File

@ -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)

View File

@ -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)

View 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
}

View File

@ -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

View File

@ -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
}

View File

@ -53,6 +53,7 @@ export function executeAnnotationQuery(
scopedVars,
...interval,
app: CoreApp.Dashboard,
publicDashboardAccessToken: options.dashboard.meta.publicDashboardAccessToken,
timezone: options.dashboard.timezone,

View File

@ -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

View File

@ -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({}));

View File

@ -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> {

View File

@ -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);
});
});

View File

@ -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));

View File

@ -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',

View File

@ -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;
}