diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 495abdd9d94..fee3c1ec2b4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -99,6 +99,7 @@ go.sum @grafana/backend-platform /public/app/core/components/TimePicker @grafana/grafana-bi-squad /public/app/core/components/Layers @grafana/grafana-edge-squad /public/app/features/canvas/ @grafana/grafana-edge-squad +/public/app/features/comments/ @grafana/grafana-edge-squad /public/app/features/dimensions/ @grafana/grafana-edge-squad /public/app/features/geo/ @grafana/grafana-edge-squad /public/app/features/live/ @grafana/grafana-edge-squad diff --git a/packages/grafana-data/src/text/markdown.ts b/packages/grafana-data/src/text/markdown.ts index a87d0f0bb54..5335d47145d 100644 --- a/packages/grafana-data/src/text/markdown.ts +++ b/packages/grafana-data/src/text/markdown.ts @@ -5,6 +5,7 @@ let hasInitialized = false; export interface RenderMarkdownOptions { noSanitize?: boolean; + breaks?: boolean; } const markdownOptions = { @@ -13,6 +14,7 @@ const markdownOptions = { smartLists: true, smartypants: false, xhtml: false, + breaks: false, }; export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): string { @@ -21,7 +23,15 @@ export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): s hasInitialized = true; } - const html = marked(str || ''); + let opts = undefined; + if (options?.breaks) { + opts = { + ...markdownOptions, + breaks: true, + }; + } + const html = marked(str || '', opts); + if (options?.noSanitize) { return html; } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 95bb511c0e9..ef0242c0389 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -42,5 +42,7 @@ export interface FeatureToggles { validatedQueries?: boolean; swaggerUi?: boolean; featureHighlights?: boolean; + dashboardComments?: boolean; + annotationComments?: boolean; migrationLocking?: boolean; } diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index 0b7e64c937a..c06ea1239f0 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -51,6 +51,7 @@ export const getAvailableIcons = () => 'cog', 'columns', 'comment-alt', + 'comment-alt-message', 'comment-alt-share', 'comments-alt', 'compass', diff --git a/pkg/api/api.go b/pkg/api/api.go index 535cca95ab7..02aeb599393 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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 diff --git a/pkg/api/comments.go b/pkg/api/comments.go new file mode 100644 index 00000000000..d84d4a8cd8f --- /dev/null +++ b/pkg/api/comments.go @@ -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, + }) +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 01f87d54b0a..36fa2f632bc 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -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 } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 729bf865099..2b4308078a7 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -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, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 8f57ceb3a29..586c86db521 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -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( diff --git a/pkg/services/comments/commentmodel/events.go b/pkg/services/comments/commentmodel/events.go new file mode 100644 index 00000000000..5e047c24757 --- /dev/null +++ b/pkg/services/comments/commentmodel/events.go @@ -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"` +} diff --git a/pkg/services/comments/commentmodel/models.go b/pkg/services/comments/commentmodel/models.go new file mode 100644 index 00000000000..64526d124f2 --- /dev/null +++ b/pkg/services/comments/commentmodel/models.go @@ -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" +} diff --git a/pkg/services/comments/commentmodel/permissions.go b/pkg/services/comments/commentmodel/permissions.go new file mode 100644 index 00000000000..c35f927538c --- /dev/null +++ b/pkg/services/comments/commentmodel/permissions.go @@ -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 +} diff --git a/pkg/services/comments/handlers.go b/pkg/services/comments/handlers.go new file mode 100644 index 00000000000..8773f19b4ff --- /dev/null +++ b/pkg/services/comments/handlers.go @@ -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 +} diff --git a/pkg/services/comments/service.go b/pkg/services/comments/service.go new file mode 100644 index 00000000000..c2797fdaa87 --- /dev/null +++ b/pkg/services/comments/service.go @@ -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() +} diff --git a/pkg/services/comments/sql_storage.go b/pkg/services/comments/sql_storage.go new file mode 100644 index 00000000000..6a95c225912 --- /dev/null +++ b/pkg/services/comments/sql_storage.go @@ -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) + }) +} diff --git a/pkg/services/comments/sql_storage_test.go b/pkg/services/comments/sql_storage_test.go new file mode 100644 index 00000000000..ddfcec06434 --- /dev/null +++ b/pkg/services/comments/sql_storage_test.go @@ -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) +} diff --git a/pkg/services/comments/storage.go b/pkg/services/comments/storage.go new file mode 100644 index 00000000000..dd9216cb64b --- /dev/null +++ b/pkg/services/comments/storage.go @@ -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) +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d95d6b385c1..8aba04eda6a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7c54af1412e..70f7b58c3eb 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/pkg/services/live/features/comment.go b/pkg/services/live/features/comment.go new file mode 100644 index 00000000000..98b30867830 --- /dev/null +++ b/pkg/services/live/features/comment.go @@ -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 +} diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index f61b36ffc75..270def38838 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -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 } diff --git a/pkg/services/sqlstore/migrations/comments_migrations.go b/pkg/services/sqlstore/migrations/comments_migrations.go new file mode 100644 index 00000000000..2c92b8d756e --- /dev/null +++ b/pkg/services/sqlstore/migrations/comments_migrations.go @@ -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])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index e3e66a46cc4..d65076973ea 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -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) { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index b83ee8cbba1..b376f6eb0e2 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -465,6 +465,7 @@ type InitTestDBOpt struct { var featuresEnabledDuringTests = []string{ featuremgmt.FlagDashboardPreviews, + featuremgmt.FlagDashboardComments, } // InitTestDBWithMigration initializes the test DB given custom migrations. diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 47da1eafb4a..394648217eb 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -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), diff --git a/public/app/features/comments/Comment.tsx b/public/app/features/comments/Comment.tsx new file mode 100644 index 00000000000..7f78a65e4b3 --- /dev/null +++ b/public/app/features/comments/Comment.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; +import { css } from '@emotion/css'; +import { useStyles2 } from '@grafana/ui'; +import DangerouslySetHtmlContent from 'dangerously-set-html-content'; + +import { Message } from './types'; + +type Props = { + message: Message; +}; + +export const Comment = ({ message }: Props) => { + const styles = useStyles2(getStyles); + + let senderColor = '#34BA18'; + let senderName = 'System'; + let avatarUrl = '/public/img/grafana_icon.svg'; + if (message.userId > 0) { + senderColor = '#19a2e7'; + senderName = message.user.login; + avatarUrl = message.user.avatarUrl; + } + const timeColor = '#898989'; + const timeFormatted = new Date(message.created * 1000).toLocaleTimeString(); + const markdownContent = renderMarkdown(message.content, { breaks: true }); + + return ( +