diff --git a/pkg/api/api.go b/pkg/api/api.go index c6918288743..94f3b0b45ce 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -378,14 +378,20 @@ func (hs *HTTPServer) registerRoutes() { }) }) - if hs.ThumbService != nil { - dashboardRoute.Get("/uid/:uid/img/:kind/:theme", hs.ThumbService.GetImage) - - if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) { - dashboardRoute.Post("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage) - dashboardRoute.Put("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.UpdateThumbnailState) + dashboardRoute.Group("/uid/:uid", func(dashUidRoute routing.RouteRegister) { + 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) { + dashUidRoute.Post("/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage) + 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("/trim", routing.Wrap(hs.TrimDashboard)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 8af7c6a2830..bfd3d3836c3 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -40,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "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/web" "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 { 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() if enableAccessControl { @@ -345,7 +355,6 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo cfg.RBACEnabled = false db.Cfg.RBACEnabled = false } - features := featuremgmt.WithFeatures() var acmock *accesscontrolmock.Mock diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index e5bdbc1f86c..afc9f03da2e 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -146,6 +146,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { Url: dash.GetUrl(), FolderTitle: "General", AnnotationsPermissions: annotationPermissions, + IsPublic: dash.IsPublic, } // lookup folder title diff --git a/pkg/api/dashboard_public_config.go b/pkg/api/dashboard_public_config.go new file mode 100644 index 00000000000..5aa785d3d2a --- /dev/null +++ b/pkg/api/dashboard_public_config.go @@ -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) +} diff --git a/pkg/api/dashboard_public_config_test.go b/pkg/api/dashboard_public_config_test.go new file mode 100644 index 00000000000..e642935b83f --- /dev/null +++ b/pkg/api/dashboard_public_config_test.go @@ -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) + } + }) + } +} diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 2e25ca4e132..2f1f7911c22 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -33,6 +33,7 @@ type DashboardMeta struct { Provisioned bool `json:"provisioned"` ProvisionedExternalId string `json:"provisionedExternalId"` AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"` + IsPublic bool `json:"isPublic"` } type AnnotationPermission struct { Dashboard AnnotationActions `json:"dashboard"` diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index b784dcf66eb..58dda45f24a 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -199,11 +199,16 @@ type Dashboard struct { FolderId int64 IsFolder bool HasAcl bool + IsPublic bool Title string Data *simplejson.Json } +type PublicDashboardConfig struct { + IsPublic bool `json:"isPublic"` +} + func (d *Dashboard) SetId(id int64) { d.Id = id d.Data.Set("id", id) @@ -411,6 +416,12 @@ type DeleteOrphanedProvisionedDashboardsCommand struct { ReaderNames []string } +type SavePublicDashboardConfigCommand struct { + Uid string + OrgId int64 + PublicDashboardConfig PublicDashboardConfig +} + // // QUERIES // diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 24c07ddcdd2..eb0d691b504 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -6,9 +6,18 @@ import ( "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. type DashboardService interface { 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) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) 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) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*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 DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error // SaveAlerts saves dashboard alerts. diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go index 92da6bb4ef5..ddfee0257f6 100644 --- a/pkg/services/dashboards/dashboard_provisioning_mock.go +++ b/pkg/services/dashboards/dashboard_provisioning_mock.go @@ -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 @@ -7,6 +7,8 @@ import ( models "github.com/grafana/grafana/pkg/models" mock "github.com/stretchr/testify/mock" + + testing "testing" ) // FakeDashboardProvisioning is an autogenerated mock type for the DashboardProvisioningService type @@ -170,3 +172,13 @@ func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, d 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 +} diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 1975a16bba5..bca2586ec30 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -10,11 +10,13 @@ type FakeDashboardService struct { DashboardService SaveDashboardResult *models.Dashboard - SaveDashboardError error SavedDashboards []*SaveDashboardDTO ProvisionedDashData *models.DashboardProvisioning - GetDashboardFn func(ctx context.Context, cmd *models.GetDashboardQuery) error + PublicDashboardConfigResult *models.PublicDashboardConfig + PublicDashboardConfigError error + SaveDashboardError error + GetDashboardFn func(ctx context.Context, cmd *models.GetDashboardQuery) error } func (s *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) { @@ -27,6 +29,14 @@ func (s *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashb 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) { return s.SaveDashboard(ctx, dto, true) } diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 456ae79b2d6..2e82ed30e67 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -187,6 +187,47 @@ func (d *DashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models 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 { return d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { // delete existing items diff --git a/pkg/services/dashboards/database_mock.go b/pkg/services/dashboards/database_mock.go index 7402570ed14..63367b0abe5 100644 --- a/pkg/services/dashboards/database_mock.go +++ b/pkg/services/dashboards/database_mock.go @@ -210,6 +210,29 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dash 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 func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { ret := _m.Called(ctx, dashID, alerts) @@ -270,6 +293,29 @@ func (_m *FakeDashboardStore) SaveProvisionedDashboard(cmd models.SaveDashboardC 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 func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error { ret := _m.Called(ctx, id) diff --git a/pkg/services/dashboards/folder_service_mock.go b/pkg/services/dashboards/folder_service_mock.go index 3fcc72601b2..17f830cf8a9 100644 --- a/pkg/services/dashboards/folder_service_mock.go +++ b/pkg/services/dashboards/folder_service_mock.go @@ -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 @@ -7,6 +7,8 @@ import ( models "github.com/grafana/grafana/pkg/models" mock "github.com/stretchr/testify/mock" + + testing "testing" ) // 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 } + +// 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 +} diff --git a/pkg/services/dashboards/folder_store_mock.go b/pkg/services/dashboards/folder_store_mock.go index 69cf2186c0b..d44e60c4732 100644 --- a/pkg/services/dashboards/folder_store_mock.go +++ b/pkg/services/dashboards/folder_store_mock.go @@ -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 @@ -7,6 +7,8 @@ import ( models "github.com/grafana/grafana/pkg/models" mock "github.com/stretchr/testify/mock" + + testing "testing" ) // 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 } + +// 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 +} diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index eea9e9c9b44..ef8cd5fafe1 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -14,3 +14,9 @@ type SaveDashboardDTO struct { Overwrite bool Dashboard *models.Dashboard } + +type SavePublicDashboardConfigDTO struct { + Uid string + OrgId int64 + PublicDashboardConfig models.PublicDashboardConfig +} diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 7dd281a9f80..e1ff812a73a 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -7,7 +7,6 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -342,6 +341,33 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *m.SaveDa 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 // 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 { diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 94a4dc358e5..6b4a9f003f7 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -230,4 +230,8 @@ func addDashboardMigration(mg *Migrator) { Cols: []string{"is_folder"}, Type: IndexType, })) + + mg.AddMigration("Add isPublic for dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "is_public", Type: DB_Bool, Nullable: false, Default: "0", + })) } diff --git a/public/api-merged.json b/public/api-merged.json index 4e42f61d725..8fdfe554c47 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -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}": { "get": { "tags": ["dashboard_versions"], "summary": "Get a specific dashboard version using UID.", "operationId": "getDashboardVersionByUID", ->>>>>>> main "parameters": [ { "type": "string", @@ -4057,35 +4008,14 @@ { "type": "integer", "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", "in": "path", "required": true ->>>>>>> main } ], "responses": { "200": { -<<<<<<< HEAD - "$ref": "#/responses/dashboardVersionsResponse" -======= "$ref": "#/responses/dashboardVersionResponse" ->>>>>>> main }, "401": { "$ref": "#/responses/unauthorisedError" diff --git a/public/api-spec.json b/public/api-spec.json index a363952961e..b86745df32e 100644 --- a/public/api-spec.json +++ b/public/api-spec.json @@ -3099,7 +3099,9 @@ "get": { "tags": ["dashboard_versions"], "summary": "Gets all existing versions for the dashboard using UID.", - "operationId": "getDashboardVersionsByUID", + "operationId": "getDashboardVersionsByUID" + } + }, "/dashboards/uid/{uid}/versions/{DashboardVersionID}": { "get": { "tags": ["dashboard_versions"], @@ -3116,35 +3118,14 @@ { "type": "integer", "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", "in": "path", "required": true ->>>>>>> main } ], "responses": { "200": { -<<<<<<< HEAD - "$ref": "#/responses/dashboardVersionsResponse" -======= "$ref": "#/responses/dashboardVersionResponse" ->>>>>>> main }, "401": { "$ref": "#/responses/unauthorisedError" diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index bc4945f416a..49cf5da65de 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; +import { config } from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; @@ -9,6 +10,7 @@ import { ShareEmbed } from './ShareEmbed'; import { ShareExport } from './ShareExport'; import { ShareLibraryPanel } from './ShareLibraryPanel'; import { ShareLink } from './ShareLink'; +import { SharePublicDashboard } from './SharePublicDashboard'; import { ShareSnapshot } from './ShareSnapshot'; import { ShareModalTabModel } from './types'; @@ -52,6 +54,10 @@ function getTabs(props: Props) { tabs.push(...customDashboardTabs); } + if (Boolean(config.featureToggles['publicDashboards'])) { + tabs.push({ label: 'Public Dashboard', value: 'share', component: SharePublicDashboard }); + } + return tabs; } diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx new file mode 100644 index 00000000000..79a37a0769c --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx @@ -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( {}} />); + + 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( {}} />); + + 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')); + }); +}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx new file mode 100644 index 00000000000..843fb0c760d --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx @@ -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({ 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 ( + <> +

Public Dashboard Configuration

+ + + setPublicDashboardConfig((state) => { + return { ...state, isPublic: !state.isPublic }; + }) + } + /> + + + + ); +}; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx new file mode 100644 index 00000000000..58b1d4e3cc2 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx @@ -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); + }); +}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts new file mode 100644 index 00000000000..6213ddfae75 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts @@ -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); +}; diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 68ace717636..953fd6e68ff 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -38,6 +38,7 @@ export interface DashboardMeta { fromFile?: boolean; hasUnsavedFolderChange?: boolean; annotationsPermissions?: AnnotationsPermissions; + isPublic?: boolean; } export interface AnnotationActions {