Comments: support live comments in dashboards and annotations (#44980)

This commit is contained in:
Alexander Emelin
2022-02-22 10:47:42 +03:00
committed by GitHub
parent 67c1a359d1
commit 28c30a34ad
32 changed files with 1254 additions and 18 deletions
+5
View File
@@ -462,6 +462,11 @@ func (hs *HTTPServer) registerRoutes() {
// short urls
apiRoute.Post("/short-urls", routing.Wrap(hs.createShortURL))
apiRoute.Group("/comments", func(commentRoute routing.RouteRegister) {
commentRoute.Post("/get", routing.Wrap(hs.commentsGet))
commentRoute.Post("/create", routing.Wrap(hs.commentsCreate))
})
}, reqSignedIn)
// admin api
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"errors"
"net/http"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/comments"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
func (hs *HTTPServer) commentsGet(c *models.ReqContext) response.Response {
cmd := comments.GetCmd{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
items, err := hs.commentsService.Get(c.Req.Context(), c.OrgId, c.SignedInUser, cmd)
if err != nil {
if errors.Is(err, comments.ErrPermissionDenied) {
return response.Error(http.StatusForbidden, "permission denied", err)
}
return response.Error(http.StatusInternalServerError, "internal error", err)
}
return response.JSON(200, util.DynMap{
"comments": items,
})
}
func (hs *HTTPServer) commentsCreate(c *models.ReqContext) response.Response {
cmd := comments.CreateCmd{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
if c.SignedInUser.UserId == 0 && !c.SignedInUser.HasRole(models.ROLE_ADMIN) {
return response.Error(http.StatusForbidden, "admin role required", nil)
}
comment, err := hs.commentsService.Create(c.Req.Context(), c.OrgId, c.SignedInUser, cmd)
if err != nil {
if errors.Is(err, comments.ErrPermissionDenied) {
return response.Error(http.StatusForbidden, "permission denied", err)
}
return response.Error(http.StatusInternalServerError, "internal error", err)
}
return response.JSON(200, util.DynMap{
"comment": comment,
})
}
+1 -1
View File
@@ -93,7 +93,7 @@ func newTestLive(t *testing.T) *live.GrafanaLive {
nil,
&usagestats.UsageStatsMock{T: t},
nil,
features)
features, nil)
require.NoError(t, err)
return gLive
}
+4 -1
View File
@@ -30,6 +30,7 @@ import (
acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/comments"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
@@ -134,6 +135,7 @@ type HTTPServer struct {
dashboardProvisioningService dashboards.DashboardProvisioningService
folderService dashboards.FolderService
DatasourcePermissionsService DatasourcePermissionsService
commentsService *comments.Service
AlertNotificationService *alerting.AlertNotificationService
DashboardsnapshotsService *dashboardsnapshots.Service
}
@@ -166,7 +168,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
dashboardsnapshotsService *dashboardsnapshots.Service,
dashboardsnapshotsService *dashboardsnapshots.Service, commentsService *comments.Service,
) (*HTTPServer, error) {
web.Env = cfg.Env
m := web.New()
@@ -231,6 +233,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
dashboardProvisioningService: dashboardProvisioningService,
folderService: folderService,
DatasourcePermissionsService: datasourcePermissionsService,
commentsService: commentsService,
teamPermissionsService: permissionsServices.GetTeamService(),
AlertNotificationService: alertNotificationService,
DashboardsnapshotsService: dashboardsnapshotsService,
+2
View File
@@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/comments"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/dashboardimport"
dashboardimportservice "github.com/grafana/grafana/pkg/services/dashboardimport/service"
@@ -211,6 +212,7 @@ var wireBasicSet = wire.NewSet(
dashboardimportservice.ProvideService,
wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)),
plugindashboards.ProvideService,
comments.ProvideService,
)
var wireSet = wire.NewSet(
@@ -0,0 +1,13 @@
package commentmodel
type EventType string
const (
EventCommentCreated EventType = "commentCreated"
)
// Event represents comment event structure.
type Event struct {
Event EventType `json:"event"`
CommentCreated *CommentDto `json:"commentCreated"`
}
@@ -0,0 +1,105 @@
package commentmodel
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
)
const (
// ObjectTypeOrg is reserved for future use for per-org comments.
ObjectTypeOrg = "org"
// ObjectTypeDashboard used for dashboard-wide comments.
ObjectTypeDashboard = "dashboard"
// ObjectTypeAnnotation used for annotation comments.
ObjectTypeAnnotation = "annotation"
)
var RegisteredObjectTypes = map[string]struct{}{
ObjectTypeOrg: {},
ObjectTypeDashboard: {},
ObjectTypeAnnotation: {},
}
type CommentGroup struct {
Id int64
OrgId int64
ObjectType string
ObjectId string
Settings Settings
Created int64
Updated int64
}
func (i CommentGroup) TableName() string {
return "comment_group"
}
type Settings struct {
}
var (
_ driver.Valuer = Settings{}
_ sql.Scanner = &Settings{}
)
func (s Settings) Value() (driver.Value, error) {
d, err := json.Marshal(s)
if err != nil {
return nil, err
}
return string(d), nil
}
func (s *Settings) Scan(value interface{}) error {
switch v := value.(type) {
case string:
return json.Unmarshal([]byte(v), &s)
case []uint8:
return json.Unmarshal(v, &s)
default:
return fmt.Errorf("type assertion on scan failed: got %T", value)
}
}
type Comment struct {
Id int64
GroupId int64
UserId int64
Content string
Created int64
Updated int64
}
type CommentUser struct {
Id int64 `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
}
type CommentDto struct {
Id int64 `json:"id"`
UserId int64 `json:"userId"`
Content string `json:"content"`
Created int64 `json:"created"`
User *CommentUser `json:"user,omitempty"`
}
func (i Comment) ToDTO(user *CommentUser) *CommentDto {
return &CommentDto{
Id: i.Id,
UserId: i.UserId,
Content: i.Content,
Created: i.Created,
User: user,
}
}
func (i Comment) TableName() string {
return "comment"
}
@@ -0,0 +1,132 @@
package commentmodel
import (
"context"
"strconv"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/guardian"
)
type PermissionChecker struct {
sqlStore *sqlstore.SQLStore
features featuremgmt.FeatureToggles
}
func NewPermissionChecker(sqlStore *sqlstore.SQLStore, features featuremgmt.FeatureToggles) *PermissionChecker {
return &PermissionChecker{sqlStore: sqlStore, features: features}
}
func (c *PermissionChecker) getDashboardByUid(ctx context.Context, orgID int64, uid string) (*models.Dashboard, error) {
query := models.GetDashboardQuery{Uid: uid, OrgId: orgID}
if err := c.sqlStore.GetDashboard(ctx, &query); err != nil {
return nil, err
}
return query.Result, nil
}
func (c *PermissionChecker) getDashboardById(ctx context.Context, orgID int64, id int64) (*models.Dashboard, error) {
query := models.GetDashboardQuery{Id: id, OrgId: orgID}
if err := c.sqlStore.GetDashboard(ctx, &query); err != nil {
return nil, err
}
return query.Result, nil
}
func (c *PermissionChecker) CheckReadPermissions(ctx context.Context, orgId int64, signedInUser *models.SignedInUser, objectType string, objectID string) (bool, error) {
switch objectType {
case ObjectTypeOrg:
return false, nil
case ObjectTypeDashboard:
if !c.features.IsEnabled(featuremgmt.FlagDashboardComments) {
return false, nil
}
dash, err := c.getDashboardByUid(ctx, orgId, objectID)
if err != nil {
return false, err
}
guard := guardian.New(ctx, dash.Id, orgId, signedInUser)
if ok, err := guard.CanView(); err != nil || !ok {
return false, nil
}
case ObjectTypeAnnotation:
if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) {
return false, nil
}
repo := annotations.GetRepository()
annotationID, err := strconv.ParseInt(objectID, 10, 64)
if err != nil {
return false, nil
}
items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId})
if err != nil || len(items) != 1 {
return false, nil
}
dashboardID := items[0].DashboardId
if dashboardID == 0 {
return false, nil
}
dash, err := c.getDashboardById(ctx, orgId, dashboardID)
if err != nil {
return false, err
}
guard := guardian.New(ctx, dash.Id, orgId, signedInUser)
if ok, err := guard.CanView(); err != nil || !ok {
return false, nil
}
default:
return false, nil
}
return true, nil
}
func (c *PermissionChecker) CheckWritePermissions(ctx context.Context, orgId int64, signedInUser *models.SignedInUser, objectType string, objectID string) (bool, error) {
switch objectType {
case ObjectTypeOrg:
return false, nil
case ObjectTypeDashboard:
if !c.features.IsEnabled(featuremgmt.FlagDashboardComments) {
return false, nil
}
dash, err := c.getDashboardByUid(ctx, orgId, objectID)
if err != nil {
return false, err
}
guard := guardian.New(ctx, dash.Id, orgId, signedInUser)
if ok, err := guard.CanEdit(); err != nil || !ok {
return false, nil
}
case ObjectTypeAnnotation:
if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) {
return false, nil
}
repo := annotations.GetRepository()
annotationID, err := strconv.ParseInt(objectID, 10, 64)
if err != nil {
return false, nil
}
items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId})
if err != nil || len(items) != 1 {
return false, nil
}
dashboardID := items[0].DashboardId
if dashboardID == 0 {
return false, nil
}
dash, err := c.getDashboardById(ctx, orgId, dashboardID)
if err != nil {
return false, nil
}
guard := guardian.New(ctx, dash.Id, orgId, signedInUser)
if ok, err := guard.CanEdit(); err != nil || !ok {
return false, nil
}
default:
return false, nil
}
return true, nil
}
+164
View File
@@ -0,0 +1,164 @@
package comments
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
)
func commentsToDto(items []*commentmodel.Comment, userMap map[int64]*commentmodel.CommentUser) []*commentmodel.CommentDto {
result := make([]*commentmodel.CommentDto, 0, len(items))
for _, m := range items {
result = append(result, commentToDto(m, userMap))
}
return result
}
func commentToDto(comment *commentmodel.Comment, userMap map[int64]*commentmodel.CommentUser) *commentmodel.CommentDto {
var u *commentmodel.CommentUser
if comment.UserId > 0 {
var ok bool
u, ok = userMap[comment.UserId]
if !ok {
// TODO: handle this gracefully?
u = &commentmodel.CommentUser{
Id: comment.UserId,
}
}
}
return comment.ToDTO(u)
}
func searchUserToCommentUser(searchUser *models.UserSearchHitDTO) *commentmodel.CommentUser {
if searchUser == nil {
return nil
}
return &commentmodel.CommentUser{
Id: searchUser.Id,
Name: searchUser.Name,
Login: searchUser.Login,
Email: searchUser.Email,
AvatarUrl: dtos.GetGravatarUrl(searchUser.Email),
}
}
type UserIDFilter struct {
userIDs []int64
}
func NewIDFilter(userIDs []int64) models.Filter {
return &UserIDFilter{userIDs: userIDs}
}
func (a *UserIDFilter) WhereCondition() *models.WhereCondition {
return nil
}
func (a *UserIDFilter) JoinCondition() *models.JoinCondition {
return nil
}
func (a *UserIDFilter) InCondition() *models.InCondition {
return &models.InCondition{
Condition: "u.id",
Params: a.userIDs,
}
}
type GetCmd struct {
ObjectType string `json:"objectType"`
ObjectID string `json:"objectId"`
Limit uint `json:"limit"`
BeforeId int64 `json:"beforeId"`
}
type CreateCmd struct {
ObjectType string `json:"objectType"`
ObjectID string `json:"objectId"`
Content string `json:"content"`
}
var ErrPermissionDenied = errors.New("permission denied")
func (s *Service) Create(ctx context.Context, orgID int64, signedInUser *models.SignedInUser, cmd CreateCmd) (*commentmodel.CommentDto, error) {
ok, err := s.permissions.CheckWritePermissions(ctx, orgID, signedInUser, cmd.ObjectType, cmd.ObjectID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrPermissionDenied
}
userMap := make(map[int64]*commentmodel.CommentUser, 1)
if signedInUser.UserId > 0 {
userMap[signedInUser.UserId] = &commentmodel.CommentUser{
Id: signedInUser.UserId,
Name: signedInUser.Name,
Login: signedInUser.Login,
Email: signedInUser.Email,
AvatarUrl: dtos.GetGravatarUrl(signedInUser.Email),
}
}
m, err := s.storage.Create(ctx, orgID, cmd.ObjectType, cmd.ObjectID, signedInUser.UserId, cmd.Content)
if err != nil {
return nil, err
}
mDto := commentToDto(m, userMap)
e := commentmodel.Event{
Event: commentmodel.EventCommentCreated,
CommentCreated: mDto,
}
eventJSON, _ := json.Marshal(e)
_ = s.live.Publish(orgID, fmt.Sprintf("grafana/comment/%s/%s", cmd.ObjectType, cmd.ObjectID), eventJSON)
return mDto, nil
}
func (s *Service) Get(ctx context.Context, orgID int64, signedInUser *models.SignedInUser, cmd GetCmd) ([]*commentmodel.CommentDto, error) {
ok, err := s.permissions.CheckReadPermissions(ctx, orgID, signedInUser, cmd.ObjectType, cmd.ObjectID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrPermissionDenied
}
messages, err := s.storage.Get(ctx, orgID, cmd.ObjectType, cmd.ObjectID, GetFilter{
Limit: cmd.Limit,
BeforeID: cmd.BeforeId,
})
if err != nil {
return nil, err
}
userIds := make([]int64, 0, len(messages))
for _, m := range messages {
if m.UserId <= 0 {
continue
}
userIds = append(userIds, m.UserId)
}
// NOTE: probably replace with comment and user table join.
query := &models.SearchUsersQuery{Query: "", Filters: []models.Filter{NewIDFilter(userIds)}, Page: 0, Limit: len(userIds)}
if err := s.sqlStore.SearchUsers(ctx, query); err != nil {
return nil, err
}
userMap := make(map[int64]*commentmodel.CommentUser, len(query.Result.Users))
for _, v := range query.Result.Users {
userMap[v.Id] = searchUserToCommentUser(v)
}
result := commentsToDto(messages, userMap)
sort.Slice(result, func(i, j int) bool {
return result[i].Id < result[j].Id
})
return result, nil
}
+38
View File
@@ -0,0 +1,38 @@
package comments
import (
"context"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/live"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
type Service struct {
cfg *setting.Cfg
live *live.GrafanaLive
sqlStore *sqlstore.SQLStore
storage Storage
permissions *commentmodel.PermissionChecker
}
func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.GrafanaLive, features featuremgmt.FeatureToggles) *Service {
s := &Service{
cfg: cfg,
live: live,
sqlStore: store,
storage: &sqlStorage{
sql: store,
},
permissions: commentmodel.NewPermissionChecker(store, features),
}
return s
}
// Run Service.
func (s *Service) Run(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
+116
View File
@@ -0,0 +1,116 @@
package comments
import (
"context"
"time"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
type sqlStorage struct {
sql *sqlstore.SQLStore
}
func checkObjectType(contentType string) bool {
_, ok := commentmodel.RegisteredObjectTypes[contentType]
return ok
}
func checkObjectID(objectID string) bool {
return objectID != ""
}
func (s *sqlStorage) Create(ctx context.Context, orgID int64, objectType string, objectID string, userID int64, content string) (*commentmodel.Comment, error) {
if !checkObjectType(objectType) {
return nil, errUnknownObjectType
}
if !checkObjectID(objectID) {
return nil, errEmptyObjectID
}
if content == "" {
return nil, errEmptyContent
}
var result *commentmodel.Comment
return result, s.sql.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
group := commentmodel.CommentGroup{
OrgId: orgID,
ObjectType: objectType,
ObjectId: objectID,
}
has, err := dbSession.Get(&group)
if err != nil {
return err
}
nowUnix := time.Now().Unix()
groupID := group.Id
if !has {
group.Created = nowUnix
group.Updated = nowUnix
group.Settings = commentmodel.Settings{}
_, err = dbSession.Insert(&group)
if err != nil {
return err
}
groupID = group.Id
}
message := commentmodel.Comment{
GroupId: groupID,
UserId: userID,
Content: content,
Created: nowUnix,
Updated: nowUnix,
}
_, err = dbSession.Insert(&message)
if err != nil {
return err
}
result = &message
return nil
})
}
const maxLimit = 300
func (s *sqlStorage) Get(ctx context.Context, orgID int64, objectType string, objectID string, filter GetFilter) ([]*commentmodel.Comment, error) {
if !checkObjectType(objectType) {
return nil, errUnknownObjectType
}
if !checkObjectID(objectID) {
return nil, errEmptyObjectID
}
var result []*commentmodel.Comment
limit := 100
if filter.Limit > 0 {
limit = int(filter.Limit)
if limit > maxLimit {
limit = maxLimit
}
}
return result, s.sql.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
group := commentmodel.CommentGroup{
OrgId: orgID,
ObjectType: objectType,
ObjectId: objectID,
}
has, err := dbSession.Get(&group)
if err != nil {
return err
}
if !has {
return nil
}
clause := dbSession.Where("group_id=?", group.Id)
if filter.BeforeID > 0 {
clause.Where("id < ?", filter.BeforeID)
}
return clause.OrderBy("id desc").Limit(limit).Find(&result)
})
}
+76
View File
@@ -0,0 +1,76 @@
package comments
import (
"context"
"strconv"
"testing"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/stretchr/testify/require"
)
func createSqlStorage(t *testing.T) Storage {
t.Helper()
sqlStore := sqlstore.InitTestDB(t)
return &sqlStorage{
sql: sqlStore,
}
}
func TestSqlStorage(t *testing.T) {
s := createSqlStorage(t)
ctx := context.Background()
items, err := s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{})
require.NoError(t, err)
require.Len(t, items, 0)
numComments := 10
for i := 0; i < numComments; i++ {
comment, err := s.Create(ctx, 1, commentmodel.ObjectTypeOrg, "2", 1, "test"+strconv.Itoa(i))
require.NoError(t, err)
require.NotNil(t, comment)
require.True(t, comment.Id > 0)
}
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{})
require.NoError(t, err)
require.Len(t, items, 10)
require.Equal(t, "test9", items[0].Content)
require.Equal(t, "test0", items[9].Content)
require.Equal(t, int64(1), items[0].UserId)
require.NotZero(t, items[0].Created)
require.NotZero(t, items[0].Updated)
// Same object, but another content type.
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeDashboard, "2", GetFilter{})
require.NoError(t, err)
require.Len(t, items, 0)
// Now test filtering.
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{
Limit: 5,
})
require.NoError(t, err)
require.Len(t, items, 5)
require.Equal(t, "test9", items[0].Content)
require.Equal(t, "test5", items[4].Content)
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{
Limit: 5,
BeforeID: items[4].Id,
})
require.NoError(t, err)
require.Len(t, items, 5)
require.Equal(t, "test4", items[0].Content)
require.Equal(t, "test0", items[4].Content)
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{
Limit: 5,
BeforeID: items[4].Id,
})
require.NoError(t, err)
require.Len(t, items, 0)
}
+24
View File
@@ -0,0 +1,24 @@
package comments
import (
"context"
"errors"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
)
type GetFilter struct {
Limit uint
BeforeID int64
}
var (
errUnknownObjectType = errors.New("unknown object type")
errEmptyObjectID = errors.New("empty object id")
errEmptyContent = errors.New("empty comment content")
)
type Storage interface {
Get(ctx context.Context, orgID int64, objectType string, objectID string, filter GetFilter) ([]*commentmodel.Comment, error)
Create(ctx context.Context, orgID int64, objectType string, objectID string, userID int64, content string) (*commentmodel.Comment, error)
}
+10
View File
@@ -142,6 +142,16 @@ var (
Description: "Highlight Enterprise features",
State: FeatureStateStable,
},
{
Name: "dashboardComments",
Description: "Enable dashboard-wide comments",
State: FeatureStateAlpha,
},
{
Name: "annotationComments",
Description: "Enable annotation comments",
State: FeatureStateAlpha,
},
{
Name: "migrationLocking",
Description: "Lock database during migrations",
+8
View File
@@ -107,6 +107,14 @@ const (
// Highlight Enterprise features
FlagFeatureHighlights = "featureHighlights"
// FlagDashboardComments
// Enable dashboard-wide comments
FlagDashboardComments = "dashboardComments"
// FlagAnnotationComments
// Enable annotation comments
FlagAnnotationComments = "annotationComments"
// FlagMigrationLocking
// Lock database during migrations
FlagMigrationLocking = "migrationLocking"
+48
View File
@@ -0,0 +1,48 @@
package features
import (
"context"
"strings"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
"github.com/grafana/grafana-plugin-sdk-go/backend"
)
// CommentHandler manages all the `grafana/comment/*` channels.
type CommentHandler struct {
permissionChecker *commentmodel.PermissionChecker
}
func NewCommentHandler(permissionChecker *commentmodel.PermissionChecker) *CommentHandler {
return &CommentHandler{permissionChecker: permissionChecker}
}
// GetHandlerForPath called on init.
func (h *CommentHandler) GetHandlerForPath(_ string) (models.ChannelHandler, error) {
return h, nil // all chats share the same handler
}
// OnSubscribe handles subscription to comment group channel.
func (h *CommentHandler) OnSubscribe(ctx context.Context, user *models.SignedInUser, e models.SubscribeEvent) (models.SubscribeReply, backend.SubscribeStreamStatus, error) {
parts := strings.Split(e.Path, "/")
if len(parts) != 2 {
return models.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil
}
objectType := parts[0]
objectID := parts[1]
ok, err := h.permissionChecker.CheckReadPermissions(ctx, user.OrgId, user, objectType, objectID)
if err != nil {
return models.SubscribeReply{}, 0, err
}
if !ok {
return models.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil
}
return models.SubscribeReply{}, backend.SubscribeStreamStatusOK, nil
}
// OnPublish is not used for comments.
func (h *CommentHandler) OnPublish(_ context.Context, _ *models.SignedInUser, _ models.PublishEvent) (models.PublishReply, backend.PublishStreamStatus, error) {
return models.PublishReply{}, backend.PublishStreamStatusPermissionDenied, nil
}
+17 -11
View File
@@ -13,19 +13,10 @@ import (
"sync"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/query"
"github.com/centrifugal/centrifuge"
"github.com/go-redis/redis/v8"
"github.com/gobwas/glob"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/live"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/usagestats"
@@ -33,7 +24,9 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/plugincontext"
"github.com/grafana/grafana/pkg/services/comments/commentmodel"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/live/database"
"github.com/grafana/grafana/pkg/services/live/features"
"github.com/grafana/grafana/pkg/services/live/livecontext"
@@ -44,11 +37,19 @@ import (
"github.com/grafana/grafana/pkg/services/live/pushws"
"github.com/grafana/grafana/pkg/services/live/runstream"
"github.com/grafana/grafana/pkg/services/live/survey"
"github.com/grafana/grafana/pkg/services/query"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
"github.com/centrifugal/centrifuge"
"github.com/go-redis/redis/v8"
"github.com/gobwas/glob"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/live"
jsoniter "github.com/json-iterator/go"
"golang.org/x/sync/errgroup"
)
@@ -68,7 +69,8 @@ type CoreGrafanaScope struct {
func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, routeRegister routing.RouteRegister,
pluginStore plugins.Store, cacheService *localcache.CacheService,
dataSourceCache datasources.CacheService, sqlStore *sqlstore.SQLStore, secretsService secrets.Service,
usageStatsService usagestats.Service, queryDataService *query.Service, toggles featuremgmt.FeatureToggles) (*GrafanaLive, error) {
usageStatsService usagestats.Service, queryDataService *query.Service, toggles featuremgmt.FeatureToggles,
bus bus.Bus) (*GrafanaLive, error) {
g := &GrafanaLive{
Cfg: cfg,
Features: toggles,
@@ -80,6 +82,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
SQLStore: sqlStore,
SecretsService: secretsService,
queryDataService: queryDataService,
bus: bus,
channels: make(map[string]models.ChannelHandler),
GrafanaScope: CoreGrafanaScope{
Features: make(map[string]models.ChannelHandlerFactory),
@@ -238,6 +241,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
g.GrafanaScope.Dashboards = dash
g.GrafanaScope.Features["dashboard"] = dash
g.GrafanaScope.Features["broadcast"] = features.NewBroadcastRunner(g.storage)
g.GrafanaScope.Features["comment"] = features.NewCommentHandler(commentmodel.NewPermissionChecker(g.SQLStore, g.Features))
g.surveyCaller = survey.NewCaller(managedStreamRunner, node)
err = g.surveyCaller.SetupHandlers()
@@ -402,6 +406,7 @@ type GrafanaLive struct {
SecretsService secrets.Service
pluginStore plugins.Store
queryDataService *query.Service
bus bus.Bus
node *centrifuge.Node
surveyCaller *survey.Caller
@@ -933,6 +938,7 @@ func (g *GrafanaLive) handleDatasourceScope(ctx context.Context, user *models.Si
// Publish sends the data to the channel without checking permissions etc.
func (g *GrafanaLive) Publish(orgID int64, channel string, data []byte) error {
logger.Debug("publish into channel", "channel", channel, "orgId", orgID, "data", string(data))
_, err := g.node.Publish(orgchannel.PrependOrgID(orgID, channel), data)
return err
}
@@ -0,0 +1,46 @@
package migrations
import (
. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
func addCommentGroupMigrations(mg *Migrator) {
commentGroupTable := Table{
Name: "comment_group",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "org_id", Type: DB_BigInt, Nullable: false},
{Name: "object_type", Type: DB_NVarchar, Length: 10, Nullable: false},
{Name: "object_id", Type: DB_NVarchar, Length: 128, Nullable: false},
{Name: "settings", Type: DB_MediumText, Nullable: false},
{Name: "created", Type: DB_Int, Nullable: false},
{Name: "updated", Type: DB_Int, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"org_id", "object_type", "object_id"}, Type: UniqueIndex},
},
}
mg.AddMigration("create comment group table", NewAddTableMigration(commentGroupTable))
mg.AddMigration("add index comment_group.org_id_object_type_object_id", NewAddIndexMigration(commentGroupTable, commentGroupTable.Indices[0]))
}
func addCommentMigrations(mg *Migrator) {
commentTable := Table{
Name: "comment",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "group_id", Type: DB_BigInt, Nullable: false},
{Name: "user_id", Type: DB_BigInt, Nullable: false},
{Name: "content", Type: DB_MediumText, Nullable: false},
{Name: "created", Type: DB_Int, Nullable: false},
{Name: "updated", Type: DB_Int, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"group_id"}, Type: IndexType},
{Cols: []string{"created"}, Type: IndexType},
},
}
mg.AddMigration("create comment table", NewAddTableMigration(commentTable))
mg.AddMigration("add index comment.group_id", NewAddIndexMigration(commentTable, commentTable.Indices[0]))
mg.AddMigration("add index comment.created", NewAddIndexMigration(commentTable, commentTable.Indices[1]))
}
@@ -78,6 +78,13 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
accesscontrol.AddTeamMembershipMigrations(mg)
}
}
if mg.Cfg != nil && mg.Cfg.IsFeatureToggleEnabled != nil {
if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagDashboardComments) || mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAnnotationComments) {
addCommentGroupMigrations(mg)
addCommentMigrations(mg)
}
}
}
func addMigrationLogMigrations(mg *Migrator) {
+1
View File
@@ -465,6 +465,7 @@ type InitTestDBOpt struct {
var featuresEnabledDuringTests = []string{
featuremgmt.FlagDashboardPreviews,
featuremgmt.FlagDashboardComments,
}
// InitTestDBWithMigration initializes the test DB given custom migrations.
+4
View File
@@ -618,6 +618,10 @@ func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSigned
return err
}
func (ss *SQLStore) SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error {
return SearchUsers(ctx, query)
}
func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error {
query.Result = models.SearchUserQueryResult{
Users: make([]*models.UserSearchHitDTO, 0),