mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Comments: support live comments in dashboards and annotations (#44980)
This commit is contained in:
parent
67c1a359d1
commit
28c30a34ad
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -42,5 +42,7 @@ export interface FeatureToggles {
|
||||
validatedQueries?: boolean;
|
||||
swaggerUi?: boolean;
|
||||
featureHighlights?: boolean;
|
||||
dashboardComments?: boolean;
|
||||
annotationComments?: boolean;
|
||||
migrationLocking?: boolean;
|
||||
}
|
||||
|
@ -51,6 +51,7 @@ export const getAvailableIcons = () =>
|
||||
'cog',
|
||||
'columns',
|
||||
'comment-alt',
|
||||
'comment-alt-message',
|
||||
'comment-alt-share',
|
||||
'comments-alt',
|
||||
'compass',
|
||||
|
@ -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
pkg/api/comments.go
Normal file
49
pkg/api/comments.go
Normal 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,
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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(
|
||||
|
13
pkg/services/comments/commentmodel/events.go
Normal file
13
pkg/services/comments/commentmodel/events.go
Normal file
@ -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"`
|
||||
}
|
105
pkg/services/comments/commentmodel/models.go
Normal file
105
pkg/services/comments/commentmodel/models.go
Normal file
@ -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"
|
||||
}
|
132
pkg/services/comments/commentmodel/permissions.go
Normal file
132
pkg/services/comments/commentmodel/permissions.go
Normal file
@ -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
pkg/services/comments/handlers.go
Normal file
164
pkg/services/comments/handlers.go
Normal 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
pkg/services/comments/service.go
Normal file
38
pkg/services/comments/service.go
Normal 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
pkg/services/comments/sql_storage.go
Normal file
116
pkg/services/comments/sql_storage.go
Normal 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
pkg/services/comments/sql_storage_test.go
Normal file
76
pkg/services/comments/sql_storage_test.go
Normal 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
pkg/services/comments/storage.go
Normal file
24
pkg/services/comments/storage.go
Normal 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)
|
||||
}
|
@ -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",
|
||||
|
@ -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
pkg/services/live/features/comment.go
Normal file
48
pkg/services/live/features/comment.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
46
pkg/services/sqlstore/migrations/comments_migrations.go
Normal file
46
pkg/services/sqlstore/migrations/comments_migrations.go
Normal file
@ -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) {
|
||||
|
@ -465,6 +465,7 @@ type InitTestDBOpt struct {
|
||||
|
||||
var featuresEnabledDuringTests = []string{
|
||||
featuremgmt.FlagDashboardPreviews,
|
||||
featuremgmt.FlagDashboardComments,
|
||||
}
|
||||
|
||||
// InitTestDBWithMigration initializes the test DB given custom migrations.
|
||||
|
@ -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),
|
||||
|
97
public/app/features/comments/Comment.tsx
Normal file
97
public/app/features/comments/Comment.tsx
Normal file
@ -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 (
|
||||
<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;
|
||||
}
|
||||
`,
|
||||
});
|
109
public/app/features/comments/CommentManager.tsx
Normal file
109
public/app/features/comments/CommentManager.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data';
|
||||
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime';
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
|
||||
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} />
|
||||
);
|
||||
}
|
||||
}
|
70
public/app/features/comments/CommentView.tsx
Normal file
70
public/app/features/comments/CommentView.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React, { FormEvent, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
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.target as HTMLInputElement;
|
||||
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;
|
||||
`,
|
||||
});
|
21
public/app/features/comments/types.ts
Normal file
21
public/app/features/comments/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
@ -15,9 +15,11 @@ import { DashboardModel } from '../../state';
|
||||
import { KioskMode } from 'app/types';
|
||||
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
|
||||
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
|
||||
import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { toggleKioskMode } from 'app/core/navigation/kiosk';
|
||||
import { getDashboardSrv } from '../../services/DashboardSrv';
|
||||
import config from 'app/core/config';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
updateTimeZoneForSession,
|
||||
@ -150,6 +152,26 @@ class DashNav extends PureComponent<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
if (dashboard.uid && config.featureToggles.dashboardComments) {
|
||||
buttons.push(
|
||||
<ModalsController key="button-dashboard-comments">
|
||||
{({ showModal, hideModal }) => (
|
||||
<DashNavButton
|
||||
tooltip="Show dashboard comments"
|
||||
icon="comment-alt-message"
|
||||
iconSize="lg"
|
||||
onClick={() => {
|
||||
showModal(DashboardCommentsModal, {
|
||||
dashboard,
|
||||
onDismiss: hideModal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ModalsController>
|
||||
);
|
||||
}
|
||||
|
||||
this.addCustomContent(customLeftActions, buttons);
|
||||
return buttons;
|
||||
}
|
||||
|
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { Modal, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
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;
|
||||
`,
|
||||
});
|
@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { HorizontalGroup, IconButton, Tag, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2, textUtil } from '@grafana/data';
|
||||
|
||||
import alertDef from 'app/features/alerting/state/alertDef';
|
||||
import { css } from '@emotion/css';
|
||||
import { CommentManager } from 'app/features/comments/CommentManager';
|
||||
import config from 'app/core/config';
|
||||
|
||||
interface AnnotationTooltipProps {
|
||||
annotation: AnnotationsDataFrameViewDTO;
|
||||
@ -12,13 +15,13 @@ interface AnnotationTooltipProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
|
||||
export const AnnotationTooltip = ({
|
||||
annotation,
|
||||
timeFormatter,
|
||||
editable,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
}: AnnotationTooltipProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const time = timeFormatter(annotation.time);
|
||||
const timeEnd = timeFormatter(annotation.timeEnd);
|
||||
@ -57,8 +60,10 @@ export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const areAnnotationCommentsEnabled = config.featureToggles.annotationComments;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.wrapper} style={areAnnotationCommentsEnabled ? { minWidth: '300px' } : {}}>
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify={'space-between'} align={'center'} spacing={'md'}>
|
||||
<div className={styles.meta}>
|
||||
@ -82,6 +87,11 @@ export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
|
||||
))}
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
{areAnnotationCommentsEnabled && (
|
||||
<div className={styles.commentWrapper}>
|
||||
<CommentManager objectType={'annotation'} objectId={annotation.id.toString()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -94,6 +104,13 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
wrapper: css`
|
||||
max-width: 400px;
|
||||
`,
|
||||
commentWrapper: css`
|
||||
margin-top: 10px;
|
||||
border-top: 2px solid #2d2b34;
|
||||
height: 30vh;
|
||||
overflow-y: scroll;
|
||||
padding: 0 3px;
|
||||
`,
|
||||
header: css`
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
|
Loading…
Reference in New Issue
Block a user