add isPublic to dashboard (#48012)

adds toggle to make a dashboard public

* config struct for public dashboard config
* api endpoints for public dashboard configuration
* ui for toggling public dashboard on and off
* load public dashboard config on share modal

Co-authored-by: Owen Smallwood <owen.smallwood@grafana.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
pull/49125/head
Jeff Levin 3 years ago committed by GitHub
parent 156e14e296
commit c7f8c2cc73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      pkg/api/api.go
  2. 15
      pkg/api/common_test.go
  3. 1
      pkg/api/dashboard.go
  4. 56
      pkg/api/dashboard_public_config.go
  5. 134
      pkg/api/dashboard_public_config_test.go
  6. 1
      pkg/api/dtos/dashboard.go
  7. 11
      pkg/models/dashboards.go
  8. 11
      pkg/services/dashboards/dashboard.go
  9. 14
      pkg/services/dashboards/dashboard_provisioning_mock.go
  10. 12
      pkg/services/dashboards/dashboard_service_mock.go
  11. 41
      pkg/services/dashboards/database/database.go
  12. 46
      pkg/services/dashboards/database_mock.go
  13. 14
      pkg/services/dashboards/folder_service_mock.go
  14. 14
      pkg/services/dashboards/folder_store_mock.go
  15. 6
      pkg/services/dashboards/models.go
  16. 28
      pkg/services/dashboards/service/dashboard_service.go
  17. 4
      pkg/services/sqlstore/migrations/dashboard_mig.go
  18. 70
      public/api-merged.json
  19. 25
      public/api-spec.json
  20. 6
      public/app/features/dashboard/components/ShareModal/ShareModal.tsx
  21. 76
      public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx
  22. 69
      public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx
  23. 17
      public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx
  24. 21
      public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts
  25. 1
      public/app/types/dashboard.ts

@ -378,14 +378,20 @@ func (hs *HTTPServer) registerRoutes() {
}) })
}) })
if hs.ThumbService != nil { dashboardRoute.Group("/uid/:uid", func(dashUidRoute routing.RouteRegister) {
dashboardRoute.Get("/uid/:uid/img/:kind/:theme", hs.ThumbService.GetImage) if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) {
dashUidRoute.Get("/public-config", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetPublicDashboard))
dashUidRoute.Post("/public-config", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.SavePublicDashboard))
}
if hs.ThumbService != nil {
dashUidRoute.Get("/img/:kind/:theme", hs.ThumbService.GetImage)
if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) { if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) {
dashboardRoute.Post("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage) dashUidRoute.Post("/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage)
dashboardRoute.Put("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.UpdateThumbnailState) dashUidRoute.Put("/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.UpdateThumbnailState)
} }
} }
})
dashboardRoute.Post("/calculate-diff", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.CalculateDashboardDiff)) dashboardRoute.Post("/calculate-diff", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.CalculateDashboardDiff))
dashboardRoute.Post("/trim", routing.Wrap(hs.TrimDashboard)) dashboardRoute.Post("/trim", routing.Wrap(hs.TrimDashboard))

@ -40,6 +40,7 @@ import (
"github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers"
"github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/searchusers/filters"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
"github.com/grafana/grafana/pkg/web/webtest" "github.com/grafana/grafana/pkg/web/webtest"
@ -332,10 +333,19 @@ func setupHTTPServer(t *testing.T, useFakeAccessControl bool, enableAccessContro
func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessControl bool, cfg *setting.Cfg) accessControlScenarioContext { func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessControl bool, cfg *setting.Cfg) accessControlScenarioContext {
db := sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{}) db := sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{})
return setupHTTPServerWithCfgDb(t, useFakeAccessControl, enableAccessControl, cfg, db, db) return setupHTTPServerWithCfgDb(t, useFakeAccessControl, enableAccessControl, cfg, db, db, featuremgmt.WithFeatures())
} }
func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessControl bool, cfg *setting.Cfg, db *sqlstore.SQLStore, store sqlstore.Store) accessControlScenarioContext { func setupHTTPServerWithMockDb(t *testing.T, useFakeAccessControl, enableAccessControl bool, features *featuremgmt.FeatureManager) accessControlScenarioContext {
// Use a new conf
cfg := setting.NewCfg()
db := sqlstore.InitTestDB(t)
db.Cfg = setting.NewCfg()
return setupHTTPServerWithCfgDb(t, useFakeAccessControl, enableAccessControl, cfg, db, mockstore.NewSQLStoreMock(), features)
}
func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessControl bool, cfg *setting.Cfg, db *sqlstore.SQLStore, store sqlstore.Store, features *featuremgmt.FeatureManager) accessControlScenarioContext {
t.Helper() t.Helper()
if enableAccessControl { if enableAccessControl {
@ -345,7 +355,6 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo
cfg.RBACEnabled = false cfg.RBACEnabled = false
db.Cfg.RBACEnabled = false db.Cfg.RBACEnabled = false
} }
features := featuremgmt.WithFeatures()
var acmock *accesscontrolmock.Mock var acmock *accesscontrolmock.Mock

@ -146,6 +146,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
Url: dash.GetUrl(), Url: dash.GetUrl(),
FolderTitle: "General", FolderTitle: "General",
AnnotationsPermissions: annotationPermissions, AnnotationsPermissions: annotationPermissions,
IsPublic: dash.IsPublic,
} }
// lookup folder title // lookup folder title

@ -0,0 +1,56 @@
package api
import (
"errors"
"fmt"
"net/http"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/web"
)
// Sets sharing configuration for dashboard
func (hs *HTTPServer) GetPublicDashboard(c *models.ReqContext) response.Response {
pdc, err := hs.dashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgId, web.Params(c.Req)[":uid"])
if errors.Is(err, models.ErrDashboardNotFound) {
return response.Error(http.StatusNotFound, "dashboard not found", err)
}
if err != nil {
return response.Error(http.StatusInternalServerError, "error retrieving public dashboard config", err)
}
return response.JSON(http.StatusOK, pdc)
}
// Sets sharing configuration for dashboard
func (hs *HTTPServer) SavePublicDashboard(c *models.ReqContext) response.Response {
pdc := &models.PublicDashboardConfig{}
if err := web.Bind(c.Req, pdc); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
dto := dashboards.SavePublicDashboardConfigDTO{
OrgId: c.OrgId,
Uid: web.Params(c.Req)[":uid"],
PublicDashboardConfig: *pdc,
}
pdc, err := hs.dashboardService.SavePublicDashboardConfig(c.Req.Context(), &dto)
fmt.Println("err:", err)
if errors.Is(err, models.ErrDashboardNotFound) {
return response.Error(http.StatusNotFound, "dashboard not found", err)
}
if err != nil {
return response.Error(http.StatusInternalServerError, "error updating public dashboard config", err)
}
return response.JSON(http.StatusOK, pdc)
}

@ -0,0 +1,134 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApiRetrieveConfig(t *testing.T) {
pdc := &models.PublicDashboardConfig{IsPublic: true}
testCases := []struct {
name string
dashboardUid string
expectedHttpResponse int
publicDashboardConfigResult *models.PublicDashboardConfig
publicDashboardConfigError error
}{
{
name: "retrieves public dashboard config when dashboard is found",
dashboardUid: "1",
expectedHttpResponse: http.StatusOK,
publicDashboardConfigResult: pdc,
publicDashboardConfigError: nil,
},
{
name: "returns 404 when dashboard not found",
dashboardUid: "77777",
expectedHttpResponse: http.StatusNotFound,
publicDashboardConfigResult: nil,
publicDashboardConfigError: models.ErrDashboardNotFound,
},
{
name: "returns 500 when internal server error",
dashboardUid: "1",
expectedHttpResponse: http.StatusInternalServerError,
publicDashboardConfigResult: nil,
publicDashboardConfigError: errors.New("database broken"),
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards))
sc.hs.dashboardService = &dashboards.FakeDashboardService{
PublicDashboardConfigResult: test.publicDashboardConfigResult,
PublicDashboardConfigError: test.publicDashboardConfigError,
}
setInitCtxSignedInViewer(sc.initCtx)
response := callAPI(
sc.server,
http.MethodGet,
"/api/dashboards/uid/1/public-config",
nil,
t,
)
assert.Equal(t, test.expectedHttpResponse, response.Code)
if test.expectedHttpResponse == http.StatusOK {
var pdcResp models.PublicDashboardConfig
err := json.Unmarshal(response.Body.Bytes(), &pdcResp)
require.NoError(t, err)
assert.Equal(t, test.publicDashboardConfigResult, &pdcResp)
}
})
}
}
func TestApiPersistsValue(t *testing.T) {
testCases := []struct {
name string
dashboardUid string
expectedHttpResponse int
saveDashboardError error
}{
{
name: "returns 200 when update persists",
dashboardUid: "1",
expectedHttpResponse: http.StatusOK,
saveDashboardError: nil,
},
{
name: "returns 500 when not persisted",
expectedHttpResponse: http.StatusInternalServerError,
saveDashboardError: errors.New("backend failed to save"),
},
{
name: "returns 404 when dashboard not found",
expectedHttpResponse: http.StatusNotFound,
saveDashboardError: models.ErrDashboardNotFound,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards))
sc.hs.dashboardService = &dashboards.FakeDashboardService{
PublicDashboardConfigResult: &models.PublicDashboardConfig{IsPublic: true},
PublicDashboardConfigError: test.saveDashboardError,
}
setInitCtxSignedInViewer(sc.initCtx)
response := callAPI(
sc.server,
http.MethodPost,
"/api/dashboards/uid/1/public-config",
strings.NewReader(`{ "isPublic": true }`),
t,
)
assert.Equal(t, test.expectedHttpResponse, response.Code)
// check the result if it's a 200
if response.Code == http.StatusOK {
respJSON, _ := simplejson.NewJson(response.Body.Bytes())
val, _ := respJSON.Get("isPublic").Bool()
assert.Equal(t, true, val)
}
})
}
}

@ -33,6 +33,7 @@ type DashboardMeta struct {
Provisioned bool `json:"provisioned"` Provisioned bool `json:"provisioned"`
ProvisionedExternalId string `json:"provisionedExternalId"` ProvisionedExternalId string `json:"provisionedExternalId"`
AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"` AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"`
IsPublic bool `json:"isPublic"`
} }
type AnnotationPermission struct { type AnnotationPermission struct {
Dashboard AnnotationActions `json:"dashboard"` Dashboard AnnotationActions `json:"dashboard"`

@ -199,11 +199,16 @@ type Dashboard struct {
FolderId int64 FolderId int64
IsFolder bool IsFolder bool
HasAcl bool HasAcl bool
IsPublic bool
Title string Title string
Data *simplejson.Json Data *simplejson.Json
} }
type PublicDashboardConfig struct {
IsPublic bool `json:"isPublic"`
}
func (d *Dashboard) SetId(id int64) { func (d *Dashboard) SetId(id int64) {
d.Id = id d.Id = id
d.Data.Set("id", id) d.Data.Set("id", id)
@ -411,6 +416,12 @@ type DeleteOrphanedProvisionedDashboardsCommand struct {
ReaderNames []string ReaderNames []string
} }
type SavePublicDashboardConfigCommand struct {
Uid string
OrgId int64
PublicDashboardConfig PublicDashboardConfig
}
// //
// QUERIES // QUERIES
// //

@ -6,9 +6,18 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
) )
// Generating mocks is handled by vektra/mockery
// 1. install go mockery https://github.com/vektra/mockery#go-install
// 2. add your method to the relevant services
// 3. from the same directory as this file run `go generate` and it will update the mock
// If you don't see any output, this most likely means your OS can't find the mockery binary
// `which mockery` to confirm and follow one of the installation methods
// DashboardService is a service for operating on dashboards. // DashboardService is a service for operating on dashboards.
type DashboardService interface { type DashboardService interface {
SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error)
GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error)
SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*models.PublicDashboardConfig, error)
ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error)
DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error
MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error
@ -45,6 +54,8 @@ type Store interface {
GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error)
SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error)
SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error)
SavePublicDashboardConfig(cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboardConfig, error)
GetPublicDashboardConfig(orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error)
UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error
DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error
// SaveAlerts saves dashboard alerts. // SaveAlerts saves dashboard alerts.

@ -1,4 +1,4 @@
// Code generated by mockery v2.10.0. DO NOT EDIT. // Code generated by mockery v2.12.1. DO NOT EDIT.
package dashboards package dashboards
@ -7,6 +7,8 @@ import (
models "github.com/grafana/grafana/pkg/models" models "github.com/grafana/grafana/pkg/models"
mock "github.com/stretchr/testify/mock" mock "github.com/stretchr/testify/mock"
testing "testing"
) )
// FakeDashboardProvisioning is an autogenerated mock type for the DashboardProvisioningService type // FakeDashboardProvisioning is an autogenerated mock type for the DashboardProvisioningService type
@ -170,3 +172,13 @@ func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, d
return r0 return r0
} }
// NewFakeDashboardProvisioning creates a new instance of FakeDashboardProvisioning. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewFakeDashboardProvisioning(t testing.TB) *FakeDashboardProvisioning {
mock := &FakeDashboardProvisioning{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -10,10 +10,12 @@ type FakeDashboardService struct {
DashboardService DashboardService
SaveDashboardResult *models.Dashboard SaveDashboardResult *models.Dashboard
SaveDashboardError error
SavedDashboards []*SaveDashboardDTO SavedDashboards []*SaveDashboardDTO
ProvisionedDashData *models.DashboardProvisioning ProvisionedDashData *models.DashboardProvisioning
PublicDashboardConfigResult *models.PublicDashboardConfig
PublicDashboardConfigError error
SaveDashboardError error
GetDashboardFn func(ctx context.Context, cmd *models.GetDashboardQuery) error GetDashboardFn func(ctx context.Context, cmd *models.GetDashboardQuery) error
} }
@ -27,6 +29,14 @@ func (s *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashb
return s.SaveDashboardResult, s.SaveDashboardError return s.SaveDashboardResult, s.SaveDashboardError
} }
func (s *FakeDashboardService) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) {
return s.PublicDashboardConfigResult, s.PublicDashboardConfigError
}
func (s *FakeDashboardService) SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*models.PublicDashboardConfig, error) {
return s.PublicDashboardConfigResult, s.PublicDashboardConfigError
}
func (s *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) { func (s *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) {
return s.SaveDashboard(ctx, dto, true) return s.SaveDashboard(ctx, dto, true)
} }

@ -187,6 +187,47 @@ func (d *DashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models
return cmd.Result, err return cmd.Result, err
} }
// retrieves public dashboard configuration
func (d *DashboardStore) GetPublicDashboardConfig(orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) {
var result []*models.Dashboard
err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
return sess.Where("org_id = ? AND uid= ?", orgId, dashboardUid).Find(&result)
})
if len(result) == 0 {
return nil, models.ErrDashboardNotFound
}
pdc := &models.PublicDashboardConfig{
IsPublic: result[0].IsPublic,
}
return pdc, err
}
// stores public dashboard configuration
func (d *DashboardStore) SavePublicDashboardConfig(cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboardConfig, error) {
err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
affectedRowCount, err := sess.Table("dashboard").Where("org_id = ? AND uid = ?", cmd.OrgId, cmd.Uid).Update(map[string]interface{}{"is_public": cmd.PublicDashboardConfig.IsPublic})
if err != nil {
return err
}
if affectedRowCount == 0 {
return models.ErrDashboardNotFound
}
return nil
})
if err != nil {
return nil, err
}
return &cmd.PublicDashboardConfig, nil
}
func (d *DashboardStore) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error { func (d *DashboardStore) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error {
return d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { return d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
// delete existing items // delete existing items

@ -210,6 +210,29 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dash
return r0, r1 return r0, r1
} }
// GetPublicDashboardConfig provides a mock function with given fields: dashboardUid
func (_m *FakeDashboardStore) GetPublicDashboardConfig(orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) {
ret := _m.Called(dashboardUid)
var r0 *models.PublicDashboardConfig
if rf, ok := ret.Get(0).(func(string) *models.PublicDashboardConfig); ok {
r0 = rf(dashboardUid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.PublicDashboardConfig)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(dashboardUid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SaveAlerts provides a mock function with given fields: ctx, dashID, alerts // SaveAlerts provides a mock function with given fields: ctx, dashID, alerts
func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error {
ret := _m.Called(ctx, dashID, alerts) ret := _m.Called(ctx, dashID, alerts)
@ -270,6 +293,29 @@ func (_m *FakeDashboardStore) SaveProvisionedDashboard(cmd models.SaveDashboardC
return r0, r1 return r0, r1
} }
// SavePublicDashboardConfig provides a mock function with given fields: cmd
func (_m *FakeDashboardStore) SavePublicDashboardConfig(cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboardConfig, error) {
ret := _m.Called(cmd)
var r0 *models.PublicDashboardConfig
if rf, ok := ret.Get(0).(func(models.SavePublicDashboardConfigCommand) *models.PublicDashboardConfig); ok {
r0 = rf(cmd)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.PublicDashboardConfig)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(models.SavePublicDashboardConfigCommand) error); ok {
r1 = rf(cmd)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UnprovisionDashboard provides a mock function with given fields: ctx, id // UnprovisionDashboard provides a mock function with given fields: ctx, id
func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error { func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)

@ -1,4 +1,4 @@
// Code generated by mockery v2.10.0. DO NOT EDIT. // Code generated by mockery v2.12.1. DO NOT EDIT.
package dashboards package dashboards
@ -7,6 +7,8 @@ import (
models "github.com/grafana/grafana/pkg/models" models "github.com/grafana/grafana/pkg/models"
mock "github.com/stretchr/testify/mock" mock "github.com/stretchr/testify/mock"
testing "testing"
) )
// FakeFolderService is an autogenerated mock type for the FolderService type // FakeFolderService is an autogenerated mock type for the FolderService type
@ -179,3 +181,13 @@ func (_m *FakeFolderService) UpdateFolder(ctx context.Context, user *models.Sign
return r0 return r0
} }
// NewFakeFolderService creates a new instance of FakeFolderService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewFakeFolderService(t testing.TB) *FakeFolderService {
mock := &FakeFolderService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -1,4 +1,4 @@
// Code generated by mockery v2.10.0. DO NOT EDIT. // Code generated by mockery v2.12.1. DO NOT EDIT.
package dashboards package dashboards
@ -7,6 +7,8 @@ import (
models "github.com/grafana/grafana/pkg/models" models "github.com/grafana/grafana/pkg/models"
mock "github.com/stretchr/testify/mock" mock "github.com/stretchr/testify/mock"
testing "testing"
) )
// FakeFolderStore is an autogenerated mock type for the FolderStore type // FakeFolderStore is an autogenerated mock type for the FolderStore type
@ -82,3 +84,13 @@ func (_m *FakeFolderStore) GetFolderByUID(ctx context.Context, orgID int64, uid
return r0, r1 return r0, r1
} }
// NewFakeFolderStore creates a new instance of FakeFolderStore. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewFakeFolderStore(t testing.TB) *FakeFolderStore {
mock := &FakeFolderStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -14,3 +14,9 @@ type SaveDashboardDTO struct {
Overwrite bool Overwrite bool
Dashboard *models.Dashboard Dashboard *models.Dashboard
} }
type SavePublicDashboardConfigDTO struct {
Uid string
OrgId int64
PublicDashboardConfig models.PublicDashboardConfig
}

@ -7,7 +7,6 @@ import (
"time" "time"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol"
@ -342,6 +341,33 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *m.SaveDa
return dash, nil return dash, nil
} }
// GetPublicDashboardConfig is a helper method to retrieve the public dashboard configuration for a given dashboard from the database
func (dr *DashboardServiceImpl) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) {
pdc, err := dr.dashboardStore.GetPublicDashboardConfig(orgId, dashboardUid)
if err != nil {
return nil, err
}
return pdc, nil
}
// SavePublicDashboardConfig is a helper method to persist the sharing config
// to the database. It handles validations for sharing config and persistence
func (dr *DashboardServiceImpl) SavePublicDashboardConfig(ctx context.Context, dto *m.SavePublicDashboardConfigDTO) (*models.PublicDashboardConfig, error) {
cmd := models.SavePublicDashboardConfigCommand{
Uid: dto.Uid,
OrgId: dto.OrgId,
PublicDashboardConfig: dto.PublicDashboardConfig,
}
pdc, err := dr.dashboardStore.SavePublicDashboardConfig(cmd)
if err != nil {
return nil, err
}
return pdc, nil
}
// DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for // DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for
// operations by the user where we want to make sure user does not delete provisioned dashboard. // operations by the user where we want to make sure user does not delete provisioned dashboard.
func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error {

@ -230,4 +230,8 @@ func addDashboardMigration(mg *Migrator) {
Cols: []string{"is_folder"}, Cols: []string{"is_folder"},
Type: IndexType, Type: IndexType,
})) }))
mg.AddMigration("Add isPublic for dashboard", NewAddColumnMigration(dashboardV2, &Column{
Name: "is_public", Type: DB_Bool, Nullable: false, Default: "0",
}))
} }

@ -3992,60 +3992,11 @@
} }
} }
}, },
<<<<<<< HEAD
"/dashboards/uid/{uid}/restore": {
"post": {
"tags": ["dashboard_versions"],
"summary": "Restore a dashboard to a given dashboard version using UID.",
"operationId": "restoreDashboardVersionByUID",
"parameters": [
{
"type": "string",
"x-go-name": "UID",
"name": "uid",
"in": "path",
"required": true
},
{
"name": "Body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/RestoreDashboardVersionCommand"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/postDashboardResponse"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"403": {
"$ref": "#/responses/forbiddenError"
},
"404": {
"$ref": "#/responses/notFoundError"
},
"500": {
"$ref": "#/responses/internalServerError"
}
}
}
},
"/dashboards/uid/{uid}/versions": {
"get": {
"tags": ["dashboard_versions"],
"summary": "Gets all existing versions for the dashboard using UID.",
"operationId": "getDashboardVersionsByUID",
=======
"/dashboards/uid/{uid}/versions/{DashboardVersionID}": { "/dashboards/uid/{uid}/versions/{DashboardVersionID}": {
"get": { "get": {
"tags": ["dashboard_versions"], "tags": ["dashboard_versions"],
"summary": "Get a specific dashboard version using UID.", "summary": "Get a specific dashboard version using UID.",
"operationId": "getDashboardVersionByUID", "operationId": "getDashboardVersionByUID",
>>>>>>> main
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
@ -4057,35 +4008,14 @@
{ {
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
<<<<<<< HEAD
"default": 0,
"x-go-name": "Limit",
"description": "Maximum number of results to return",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"default": 0,
"x-go-name": "Start",
"description": "Version to start from when returning queries",
"name": "start",
"in": "query"
=======
"name": "DashboardVersionID", "name": "DashboardVersionID",
"in": "path", "in": "path",
"required": true "required": true
>>>>>>> main
} }
], ],
"responses": { "responses": {
"200": { "200": {
<<<<<<< HEAD
"$ref": "#/responses/dashboardVersionsResponse"
=======
"$ref": "#/responses/dashboardVersionResponse" "$ref": "#/responses/dashboardVersionResponse"
>>>>>>> main
}, },
"401": { "401": {
"$ref": "#/responses/unauthorisedError" "$ref": "#/responses/unauthorisedError"

@ -3099,7 +3099,9 @@
"get": { "get": {
"tags": ["dashboard_versions"], "tags": ["dashboard_versions"],
"summary": "Gets all existing versions for the dashboard using UID.", "summary": "Gets all existing versions for the dashboard using UID.",
"operationId": "getDashboardVersionsByUID", "operationId": "getDashboardVersionsByUID"
}
},
"/dashboards/uid/{uid}/versions/{DashboardVersionID}": { "/dashboards/uid/{uid}/versions/{DashboardVersionID}": {
"get": { "get": {
"tags": ["dashboard_versions"], "tags": ["dashboard_versions"],
@ -3116,35 +3118,14 @@
{ {
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
<<<<<<< HEAD
"default": 0,
"x-go-name": "Limit",
"description": "Maximum number of results to return",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"default": 0,
"x-go-name": "Start",
"description": "Version to start from when returning queries",
"name": "start",
"in": "query"
=======
"name": "DashboardVersionID", "name": "DashboardVersionID",
"in": "path", "in": "path",
"required": true "required": true
>>>>>>> main
} }
], ],
"responses": { "responses": {
"200": { "200": {
<<<<<<< HEAD
"$ref": "#/responses/dashboardVersionsResponse"
=======
"$ref": "#/responses/dashboardVersionResponse" "$ref": "#/responses/dashboardVersionResponse"
>>>>>>> main
}, },
"401": { "401": {
"$ref": "#/responses/unauthorisedError" "$ref": "#/responses/unauthorisedError"

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui';
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
@ -9,6 +10,7 @@ import { ShareEmbed } from './ShareEmbed';
import { ShareExport } from './ShareExport'; import { ShareExport } from './ShareExport';
import { ShareLibraryPanel } from './ShareLibraryPanel'; import { ShareLibraryPanel } from './ShareLibraryPanel';
import { ShareLink } from './ShareLink'; import { ShareLink } from './ShareLink';
import { SharePublicDashboard } from './SharePublicDashboard';
import { ShareSnapshot } from './ShareSnapshot'; import { ShareSnapshot } from './ShareSnapshot';
import { ShareModalTabModel } from './types'; import { ShareModalTabModel } from './types';
@ -52,6 +54,10 @@ function getTabs(props: Props) {
tabs.push(...customDashboardTabs); tabs.push(...customDashboardTabs);
} }
if (Boolean(config.featureToggles['publicDashboards'])) {
tabs.push({ label: 'Public Dashboard', value: 'share', component: SharePublicDashboard });
}
return tabs; return tabs;
} }

@ -0,0 +1,76 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import config from 'app/core/config';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { ShareModal } from './ShareModal';
jest.mock('app/core/core', () => {
return {
contextSrv: {
hasPermission: () => true,
},
appEvents: {
subscribe: () => {
return {
unsubscribe: () => {},
};
},
emit: () => {},
},
};
});
describe('SharePublic', () => {
let originalBootData: any;
beforeAll(() => {
originalBootData = config.bootData;
config.appUrl = 'http://dashboards.grafana.com/';
config.bootData = {
user: {
orgId: 1,
},
} as any;
});
afterAll(() => {
config.bootData = originalBootData;
});
it('does not render share panel when public dashboards feature is disabled', () => {
const mockDashboard = new DashboardModel({
uid: 'mockDashboardUid',
});
const mockPanel = new PanelModel({
id: 'mockPanelId',
});
render(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />);
expect(screen.getByRole('tablist')).toHaveTextContent('Link');
expect(screen.getByRole('tablist')).not.toHaveTextContent('Public Dashboard');
});
it('renders share panel when public dashboards feature is enabled', async () => {
config.featureToggles.publicDashboards = true;
const mockDashboard = new DashboardModel({
uid: 'mockDashboardUid',
});
const mockPanel = new PanelModel({
id: 'mockPanelId',
});
render(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />);
await waitFor(() => screen.getByText('Link'));
expect(screen.getByRole('tablist')).toHaveTextContent('Link');
expect(screen.getByRole('tablist')).toHaveTextContent('Public Dashboard');
fireEvent.click(screen.getByText('Public Dashboard'));
await waitFor(() => screen.getByText('Enabled'));
});
});

@ -0,0 +1,69 @@
import React, { useState, useEffect } from 'react';
import { Button, Field, Switch } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
import {
dashboardCanBePublic,
getPublicDashboardConfig,
savePublicDashboardConfig,
PublicDashboardConfig,
} from './SharePublicDashboardUtils';
import { ShareModalTabProps } from './types';
interface Props extends ShareModalTabProps {}
// 1. write test for dashboardCanBePublic
// 2. figure out how to disable the switch
export const SharePublicDashboard = (props: Props) => {
const [publicDashboardConfig, setPublicDashboardConfig] = useState<PublicDashboardConfig>({ isPublic: false });
const dashboardUid = props.dashboard.uid;
useEffect(() => {
getPublicDashboardConfig(dashboardUid)
.then((pdc: PublicDashboardConfig) => {
setPublicDashboardConfig(pdc);
})
.catch(() => {
dispatch(notifyApp(createErrorNotification('Failed to retrieve public dashboard config')));
});
}, [dashboardUid]);
const onSavePublicConfig = () => {
// verify dashboard can be public
if (!dashboardCanBePublic(props.dashboard)) {
dispatch(notifyApp(createErrorNotification('This dashboard cannot be made public')));
return;
}
try {
savePublicDashboardConfig(props.dashboard.uid, publicDashboardConfig);
dispatch(notifyApp(createSuccessNotification('Dashboard sharing configuration saved')));
} catch (err) {
console.error('Error while making dashboard public', err);
dispatch(notifyApp(createErrorNotification('Error making dashboard public')));
}
};
return (
<>
<p className="share-modal-info-text">Public Dashboard Configuration</p>
<Field label="Enabled" description="Configures whether current dashboard can be available publicly">
<Switch
id="share-current-time-range"
disabled={!dashboardCanBePublic(props.dashboard)}
value={publicDashboardConfig?.isPublic}
onChange={() =>
setPublicDashboardConfig((state) => {
return { ...state, isPublic: !state.isPublic };
})
}
/>
</Field>
<Button onClick={onSavePublicConfig}>Save Sharing Configuration</Button>
</>
);
};

@ -0,0 +1,17 @@
import { DashboardModel } from 'app/features/dashboard/state';
import { dashboardCanBePublic } from './SharePublicDashboardUtils';
describe('dashboardCanBePublic', () => {
it('can be public with no template variables', () => {
//@ts-ignore
const dashboard: DashboardModel = { templating: { list: [] } };
expect(dashboardCanBePublic(dashboard)).toBe(true);
});
it('cannot be public with template variables', () => {
//@ts-ignore
const dashboard: DashboardModel = { templating: { list: [{}] } };
expect(dashboardCanBePublic(dashboard)).toBe(false);
});
});

@ -0,0 +1,21 @@
import { getBackendSrv } from '@grafana/runtime';
import { DashboardModel } from 'app/features/dashboard/state';
export interface PublicDashboardConfig {
isPublic: boolean;
}
export const dashboardCanBePublic = (dashboard: DashboardModel): boolean => {
return dashboard?.templating?.list.length === 0;
};
export const getPublicDashboardConfig = async (dashboardUid: string) => {
const url = `/api/dashboards/uid/${dashboardUid}/public-config`;
return getBackendSrv().get(url);
};
export const savePublicDashboardConfig = async (dashboardUid: string, conf: PublicDashboardConfig) => {
const payload = { isPublic: conf.isPublic };
const url = `/api/dashboards/uid/${dashboardUid}/public-config`;
return getBackendSrv().post(url, payload);
};

@ -38,6 +38,7 @@ export interface DashboardMeta {
fromFile?: boolean; fromFile?: boolean;
hasUnsavedFolderChange?: boolean; hasUnsavedFolderChange?: boolean;
annotationsPermissions?: AnnotationsPermissions; annotationsPermissions?: AnnotationsPermissions;
isPublic?: boolean;
} }
export interface AnnotationActions { export interface AnnotationActions {

Loading…
Cancel
Save