mirror of https://github.com/grafana/grafana
Chore: remove comments feature (#64644)
parent
18e3e0ca8d
commit
d5a9a0cea0
@ -1,50 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"errors" |
||||
"net/http" |
||||
|
||||
"github.com/grafana/grafana/pkg/api/response" |
||||
"github.com/grafana/grafana/pkg/services/comments" |
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" |
||||
"github.com/grafana/grafana/pkg/services/org" |
||||
"github.com/grafana/grafana/pkg/util" |
||||
"github.com/grafana/grafana/pkg/web" |
||||
) |
||||
|
||||
func (hs *HTTPServer) commentsGet(c *contextmodel.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(http.StatusOK, util.DynMap{ |
||||
"comments": items, |
||||
}) |
||||
} |
||||
|
||||
func (hs *HTTPServer) commentsCreate(c *contextmodel.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(org.RoleAdmin) { |
||||
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(http.StatusOK, util.DynMap{ |
||||
"comment": comment, |
||||
}) |
||||
} |
@ -1,13 +0,0 @@ |
||||
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"` |
||||
} |
@ -1,105 +0,0 @@ |
||||
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" |
||||
} |
@ -1,157 +0,0 @@ |
||||
package commentmodel |
||||
|
||||
import ( |
||||
"context" |
||||
"strconv" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
"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/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/guardian" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
) |
||||
|
||||
type PermissionChecker struct { |
||||
sqlStore db.DB |
||||
features featuremgmt.FeatureToggles |
||||
accessControl accesscontrol.AccessControl |
||||
dashboardService dashboards.DashboardService |
||||
annotationsRepo annotations.Repository |
||||
} |
||||
|
||||
func NewPermissionChecker(sqlStore db.DB, features featuremgmt.FeatureToggles, |
||||
accessControl accesscontrol.AccessControl, dashboardService dashboards.DashboardService, |
||||
annotationsRepo annotations.Repository, |
||||
) *PermissionChecker { |
||||
return &PermissionChecker{sqlStore: sqlStore, features: features, accessControl: accessControl, annotationsRepo: annotationsRepo} |
||||
} |
||||
|
||||
func (c *PermissionChecker) getDashboardByUid(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { |
||||
query := dashboards.GetDashboardQuery{UID: uid, OrgID: orgID} |
||||
queryResult, err := c.dashboardService.GetDashboard(ctx, &query) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return queryResult, nil |
||||
} |
||||
|
||||
func (c *PermissionChecker) getDashboardById(ctx context.Context, orgID int64, id int64) (*dashboards.Dashboard, error) { |
||||
query := dashboards.GetDashboardQuery{ID: id, OrgID: orgID} |
||||
queryResult, err := c.dashboardService.GetDashboard(ctx, &query) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return queryResult, nil |
||||
} |
||||
|
||||
func (c *PermissionChecker) CheckReadPermissions(ctx context.Context, orgId int64, signedInUser *user.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, err := guardian.NewByDashboard(ctx, dash, orgId, signedInUser) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
if ok, err := guard.CanView(); err != nil || !ok { |
||||
return false, nil |
||||
} |
||||
case ObjectTypeAnnotation: |
||||
if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) { |
||||
return false, nil |
||||
} |
||||
annotationID, err := strconv.ParseInt(objectID, 10, 64) |
||||
if err != nil { |
||||
return false, nil |
||||
} |
||||
items, err := c.annotationsRepo.Find(ctx, &annotations.ItemQuery{AnnotationID: annotationID, OrgID: orgId, SignedInUser: signedInUser}) |
||||
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, err := guardian.NewByDashboard(ctx, dash, orgId, signedInUser) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
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 *user.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, err := guardian.NewByDashboard(ctx, dash, orgId, signedInUser) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
if ok, err := guard.CanEdit(); err != nil || !ok { |
||||
return false, nil |
||||
} |
||||
case ObjectTypeAnnotation: |
||||
if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) { |
||||
return false, nil |
||||
} |
||||
if !c.accessControl.IsDisabled() { |
||||
evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsWrite, accesscontrol.ScopeAnnotationsTypeDashboard) |
||||
if canEdit, err := c.accessControl.Evaluate(ctx, signedInUser, evaluator); err != nil || !canEdit { |
||||
return canEdit, err |
||||
} |
||||
} |
||||
annotationID, err := strconv.ParseInt(objectID, 10, 64) |
||||
if err != nil { |
||||
return false, nil |
||||
} |
||||
items, err := c.annotationsRepo.Find(ctx, &annotations.ItemQuery{AnnotationID: annotationID, OrgID: orgId, SignedInUser: signedInUser}) |
||||
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, err := guardian.NewByDashboard(ctx, dash, orgId, signedInUser) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
if ok, err := guard.CanEdit(); err != nil || !ok { |
||||
return false, nil |
||||
} |
||||
default: |
||||
return false, nil |
||||
} |
||||
return true, nil |
||||
} |
@ -1,171 +0,0 @@ |
||||
package comments |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"sort" |
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos" |
||||
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
) |
||||
|
||||
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 *user.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) user.Filter { |
||||
return &UserIDFilter{userIDs: userIDs} |
||||
} |
||||
|
||||
func (a *UserIDFilter) WhereCondition() *user.WhereCondition { |
||||
return nil |
||||
} |
||||
|
||||
func (a *UserIDFilter) JoinCondition() *user.JoinCondition { |
||||
return nil |
||||
} |
||||
|
||||
func (a *UserIDFilter) InCondition() *user.InCondition { |
||||
return &user.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 *user.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 *user.SignedInUser, cmd GetCmd) ([]*commentmodel.CommentDto, error) { |
||||
var res *user.SearchUserQueryResult |
||||
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 := &user.SearchUsersQuery{ |
||||
Query: "", |
||||
Page: 0, |
||||
Limit: len(userIds), |
||||
SignedInUser: signedInUser, |
||||
Filters: []user.Filter{NewIDFilter(userIds)}, |
||||
} |
||||
if res, err = s.userService.Search(ctx, query); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
userMap := make(map[int64]*commentmodel.CommentUser, len(res.Users)) |
||||
for _, v := range res.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 |
||||
} |
@ -1,46 +0,0 @@ |
||||
package comments |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
"github.com/grafana/grafana/pkg/services/accesscontrol" |
||||
"github.com/grafana/grafana/pkg/services/annotations" |
||||
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||
"github.com/grafana/grafana/pkg/services/dashboards" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/live" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
) |
||||
|
||||
type Service struct { |
||||
cfg *setting.Cfg |
||||
live *live.GrafanaLive |
||||
sqlStore db.DB |
||||
storage Storage |
||||
permissions *commentmodel.PermissionChecker |
||||
userService user.Service |
||||
} |
||||
|
||||
func ProvideService(cfg *setting.Cfg, store db.DB, live *live.GrafanaLive, |
||||
features featuremgmt.FeatureToggles, accessControl accesscontrol.AccessControl, |
||||
dashboardService dashboards.DashboardService, userService user.Service, annotationsRepo annotations.Repository) *Service { |
||||
s := &Service{ |
||||
cfg: cfg, |
||||
live: live, |
||||
sqlStore: store, |
||||
storage: &sqlStorage{ |
||||
sql: store, |
||||
}, |
||||
permissions: commentmodel.NewPermissionChecker(store, features, accessControl, dashboardService, annotationsRepo), |
||||
userService: userService, |
||||
} |
||||
return s |
||||
} |
||||
|
||||
// Run Service.
|
||||
func (s *Service) Run(ctx context.Context) error { |
||||
<-ctx.Done() |
||||
return ctx.Err() |
||||
} |
@ -1,117 +0,0 @@ |
||||
package comments |
||||
|
||||
import ( |
||||
"context" |
||||
"time" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||
) |
||||
|
||||
type sqlStorage struct { |
||||
sql db.DB |
||||
} |
||||
|
||||
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 *db.Session) error { |
||||
var group commentmodel.CommentGroup |
||||
has, err := dbSession.NoAutoCondition().Where( |
||||
"org_id=? AND object_type=? AND object_id=?", |
||||
orgID, objectType, objectID, |
||||
).Get(&group) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
nowUnix := time.Now().Unix() |
||||
|
||||
groupID := group.Id |
||||
if !has { |
||||
group.OrgId = orgID |
||||
group.ObjectType = objectType |
||||
group.ObjectId = objectID |
||||
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 *db.Session) error { |
||||
var group commentmodel.CommentGroup |
||||
has, err := dbSession.NoAutoCondition().Where( |
||||
"org_id=? AND object_type=? AND object_id=?", |
||||
orgID, objectType, objectID, |
||||
).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) |
||||
}) |
||||
} |
@ -1,76 +0,0 @@ |
||||
package comments |
||||
|
||||
import ( |
||||
"context" |
||||
"strconv" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db" |
||||
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||
) |
||||
|
||||
func createSqlStorage(t *testing.T) Storage { |
||||
t.Helper() |
||||
sqlStore := db.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) |
||||
} |
@ -1,24 +0,0 @@ |
||||
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) |
||||
} |
@ -1,49 +0,0 @@ |
||||
package features |
||||
|
||||
import ( |
||||
"context" |
||||
"strings" |
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||
"github.com/grafana/grafana/pkg/services/live/model" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
) |
||||
|
||||
// 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) (model.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 *user.SignedInUser, e model.SubscribeEvent) (model.SubscribeReply, backend.SubscribeStreamStatus, error) { |
||||
parts := strings.Split(e.Path, "/") |
||||
if len(parts) != 2 { |
||||
return model.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 model.SubscribeReply{}, 0, err |
||||
} |
||||
if !ok { |
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil |
||||
} |
||||
return model.SubscribeReply{}, backend.SubscribeStreamStatusOK, nil |
||||
} |
||||
|
||||
// OnPublish is not used for comments.
|
||||
func (h *CommentHandler) OnPublish(_ context.Context, _ *user.SignedInUser, _ model.PublishEvent) (model.PublishReply, backend.PublishStreamStatus, error) { |
||||
return model.PublishReply{}, backend.PublishStreamStatusPermissionDenied, nil |
||||
} |
@ -1,46 +0,0 @@ |
||||
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])) |
||||
} |
@ -1,98 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import DangerouslySetHtmlContent from 'dangerously-set-html-content'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
|
||||
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 ( |
||||
<div className={styles.comment}> |
||||
<div className={styles.avatarContainer}> |
||||
<img src={avatarUrl} alt="User avatar" className={styles.avatar} /> |
||||
</div> |
||||
<div> |
||||
<div> |
||||
<span style={{ color: senderColor }}>{senderName}</span> |
||||
|
||||
<span style={{ color: timeColor }}>{timeFormatted}</span> |
||||
</div> |
||||
<div> |
||||
<DangerouslySetHtmlContent html={markdownContent} className={styles.content} /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
comment: css` |
||||
margin-bottom: 10px; |
||||
padding-top: 3px; |
||||
padding-bottom: 3px; |
||||
word-break: break-word; |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: top; |
||||
|
||||
:hover { |
||||
background-color: #1e1f24; |
||||
} |
||||
|
||||
blockquote { |
||||
padding: 0 0 0 10px; |
||||
margin: 0 0 10px; |
||||
} |
||||
`,
|
||||
avatarContainer: css` |
||||
align-self: left; |
||||
margin-top: 6px; |
||||
margin-right: 10px; |
||||
`,
|
||||
avatar: css` |
||||
width: 30px; |
||||
height: 30px; |
||||
`,
|
||||
content: css` |
||||
display: block; |
||||
overflow: hidden; |
||||
|
||||
p { |
||||
margin: 0; |
||||
padding: 0; |
||||
} |
||||
|
||||
blockquote p { |
||||
font-size: 14px; |
||||
padding-top: 4px; |
||||
} |
||||
|
||||
a { |
||||
color: #43c57e; |
||||
} |
||||
|
||||
a:hover { |
||||
text-decoration: underline; |
||||
} |
||||
`,
|
||||
}); |
@ -1,110 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { Unsubscribable } from 'rxjs'; |
||||
|
||||
import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data'; |
||||
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; |
||||
|
||||
import { CommentView } from './CommentView'; |
||||
import { Message, MessagePacket } from './types'; |
||||
|
||||
export interface Props { |
||||
objectType: string; |
||||
objectId: string; |
||||
} |
||||
|
||||
export interface State { |
||||
messages: Message[]; |
||||
value: string; |
||||
} |
||||
|
||||
export class CommentManager extends PureComponent<Props, State> { |
||||
subscription?: Unsubscribable; |
||||
packetCounter = 0; |
||||
|
||||
constructor(props: Props) { |
||||
super(props); |
||||
|
||||
this.state = { |
||||
messages: [], |
||||
value: '', |
||||
}; |
||||
} |
||||
|
||||
async componentDidMount() { |
||||
const resp = await getBackendSrv().post('/api/comments/get', { |
||||
objectType: this.props.objectType, |
||||
objectId: this.props.objectId, |
||||
}); |
||||
this.packetCounter++; |
||||
this.setState({ |
||||
messages: resp.comments, |
||||
}); |
||||
this.updateSubscription(); |
||||
} |
||||
|
||||
getLiveChannel = () => { |
||||
const live = getGrafanaLiveSrv(); |
||||
if (!live) { |
||||
console.error('Grafana live not running, enable "live" feature toggle'); |
||||
return undefined; |
||||
} |
||||
|
||||
const address = this.getLiveAddress(); |
||||
if (!address) { |
||||
return undefined; |
||||
} |
||||
|
||||
return live.getStream<MessagePacket>(address); |
||||
}; |
||||
|
||||
getLiveAddress = () => { |
||||
return { |
||||
scope: LiveChannelScope.Grafana, |
||||
namespace: 'comment', |
||||
path: `${this.props.objectType}/${this.props.objectId}`, |
||||
}; |
||||
}; |
||||
|
||||
updateSubscription = () => { |
||||
if (this.subscription) { |
||||
this.subscription.unsubscribe(); |
||||
this.subscription = undefined; |
||||
} |
||||
|
||||
const channel = this.getLiveChannel(); |
||||
if (channel) { |
||||
this.subscription = channel.subscribe({ |
||||
next: (msg) => { |
||||
if (isLiveChannelMessageEvent(msg)) { |
||||
const { commentCreated } = msg.message; |
||||
if (commentCreated) { |
||||
this.setState((prevState) => ({ |
||||
messages: [...prevState.messages, commentCreated], |
||||
})); |
||||
this.packetCounter++; |
||||
} |
||||
} |
||||
}, |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
addComment = async (comment: string): Promise<boolean> => { |
||||
const response = await getBackendSrv().post('/api/comments/create', { |
||||
objectType: this.props.objectType, |
||||
objectId: this.props.objectId, |
||||
content: comment, |
||||
}); |
||||
|
||||
// TODO: set up error handling
|
||||
console.log(response); |
||||
|
||||
return true; |
||||
}; |
||||
|
||||
render() { |
||||
return ( |
||||
<CommentView comments={this.state.messages} packetCounter={this.packetCounter} addComment={this.addComment} /> |
||||
); |
||||
} |
||||
} |
@ -1,71 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { FormEvent, useLayoutEffect, useRef, useState } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { CustomScrollbar, TextArea, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { Comment } from './Comment'; |
||||
import { Message } from './types'; |
||||
|
||||
type Props = { |
||||
comments: Message[]; |
||||
packetCounter: number; |
||||
addComment: (comment: string) => Promise<boolean>; |
||||
}; |
||||
|
||||
export const CommentView = ({ comments, packetCounter, addComment }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const [comment, setComment] = useState(''); |
||||
const [scrollTop, setScrollTop] = useState(0); |
||||
const commentViewContainer = useRef<HTMLDivElement>(null); |
||||
|
||||
useLayoutEffect(() => { |
||||
if (commentViewContainer.current) { |
||||
setScrollTop(commentViewContainer.current.offsetHeight); |
||||
} else { |
||||
setScrollTop(0); |
||||
} |
||||
}, [packetCounter]); |
||||
|
||||
const onUpdateComment = (event: FormEvent<HTMLTextAreaElement>) => { |
||||
const element = event.currentTarget; |
||||
setComment(element.value); |
||||
}; |
||||
|
||||
const onKeyPress = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => { |
||||
if (event?.key === 'Enter' && !event?.shiftKey) { |
||||
event.preventDefault(); |
||||
|
||||
if (comment.length > 0) { |
||||
const result = await addComment(comment); |
||||
if (result) { |
||||
setComment(''); |
||||
} |
||||
} |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<CustomScrollbar scrollTop={scrollTop}> |
||||
<div ref={commentViewContainer} className={styles.commentViewContainer}> |
||||
{comments.map((msg) => ( |
||||
<Comment key={msg.id} message={msg} /> |
||||
))} |
||||
<TextArea |
||||
placeholder="Write a comment" |
||||
value={comment} |
||||
onChange={onUpdateComment} |
||||
onKeyPress={onKeyPress} |
||||
autoFocus={true} |
||||
/> |
||||
</div> |
||||
</CustomScrollbar> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
commentViewContainer: css` |
||||
margin: 5px; |
||||
`,
|
||||
}); |
@ -1,21 +0,0 @@ |
||||
export interface MessagePacket { |
||||
event: string; |
||||
commentCreated: Message; |
||||
} |
||||
|
||||
export interface Message { |
||||
id: number; |
||||
content: string; |
||||
created: number; |
||||
userId: number; |
||||
user: User; |
||||
} |
||||
|
||||
// TODO: Interface may exist elsewhere
|
||||
export interface User { |
||||
id: number; |
||||
name: string; |
||||
login: string; |
||||
email: string; |
||||
avatarUrl: string; |
||||
} |
@ -1,30 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Modal, useStyles2 } from '@grafana/ui'; |
||||
import { CommentManager } from 'app/features/comments/CommentManager'; |
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel'; |
||||
|
||||
type Props = { |
||||
dashboard: DashboardModel; |
||||
onDismiss: () => void; |
||||
}; |
||||
|
||||
export const DashboardCommentsModal = ({ dashboard, onDismiss }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<Modal isOpen={true} title="Dashboard comments" icon="save" onDismiss={onDismiss} className={styles.modal}> |
||||
<CommentManager objectType={'dashboard'} objectId={dashboard.uid} /> |
||||
</Modal> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
modal: css` |
||||
width: 500px; |
||||
height: 60vh; |
||||
`,
|
||||
}); |
Loading…
Reference in new issue