From 52ed651958a19a88f60d9c0ee8041e45a0f2b213 Mon Sep 17 00:00:00 2001 From: Jeff Levin Date: Thu, 2 Jun 2022 18:27:23 -0800 Subject: [PATCH] public dashboards: insert default public dashboard config into database on save (#49131) This PR adds endpoints for saving and retrieving a public dashboard configuration and and api endpoint to retrieve the public dashboard. All of this is highly experimental and APIs will change. Notably, we will be removing isPublic from the dashboard model and moving it over to the public dashboard table in the next release. Further context can be found here: https://github.com/grafana/grafana/pull/49131#issuecomment-1145456952 --- pkg/api/api.go | 9 +- pkg/api/dashboard_public.go | 61 +++++ pkg/api/dashboard_public_config.go | 53 ---- pkg/api/dashboard_public_config_test.go | 134 ---------- pkg/api/dashboard_public_test.go | 227 +++++++++++++++++ pkg/models/dashboards.go | 10 - pkg/models/dashboards_public.go | 43 ++++ pkg/services/dashboards/dashboard.go | 2 + .../dashboards/dashboard_service_mock.go | 25 +- pkg/services/dashboards/database/database.go | 41 --- .../database/database_dashboard_public.go | 157 ++++++++++++ .../database_dashboard_public_test.go | 233 ++++++++++++++++++ pkg/services/dashboards/models.go | 4 +- .../dashboards/service/dashboard_public.go | 60 +++++ .../service/dashboard_public_test.go | 162 ++++++++++++ .../dashboards/service/dashboard_service.go | 27 -- pkg/services/dashboards/store_mock.go | 34 ++- .../migrations/dashboard_public_config_mig.go | 13 +- .../ShareModal/SharePublicDashboard.test.tsx | 17 ++ .../ShareModal/SharePublicDashboard.tsx | 52 ++-- .../SharePublicDashboardUtils.test.tsx | 19 +- .../ShareModal/SharePublicDashboardUtils.ts | 38 ++- 22 files changed, 1103 insertions(+), 318 deletions(-) create mode 100644 pkg/api/dashboard_public.go delete mode 100644 pkg/api/dashboard_public_config.go delete mode 100644 pkg/api/dashboard_public_config_test.go create mode 100644 pkg/api/dashboard_public_test.go create mode 100644 pkg/models/dashboards_public.go create mode 100644 pkg/services/dashboards/database/database_dashboard_public.go create mode 100644 pkg/services/dashboards/database/database_dashboard_public_test.go create mode 100644 pkg/services/dashboards/service/dashboard_public.go create mode 100644 pkg/services/dashboards/service/dashboard_public_test.go diff --git a/pkg/api/api.go b/pkg/api/api.go index 1161e5389c6..ef5cb1e583b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -392,8 +392,8 @@ func (hs *HTTPServer) registerRoutes() { 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)) + dashUidRoute.Get("/public-config", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetPublicDashboardConfig)) + dashUidRoute.Post("/public-config", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.SavePublicDashboardConfig)) } if hs.ThumbService != nil { @@ -608,6 +608,11 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) r.Delete("/api/snapshots/:key", reqEditorRole, routing.Wrap(hs.DeleteDashboardSnapshot)) + // Public API + if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { + r.Get("/api/public/dashboards/:uid", routing.Wrap(hs.GetPublicDashboard)) + } + // Frontend logs sourceMapStore := frontendlogging.NewSourceMapStore(hs.Cfg, hs.pluginStaticRouteResolver, frontendlogging.ReadSourceMapFromFS) r.Post("/log", middleware.RateLimit(hs.Cfg.Sentry.EndpointRPS, hs.Cfg.Sentry.EndpointBurst, time.Now), diff --git a/pkg/api/dashboard_public.go b/pkg/api/dashboard_public.go new file mode 100644 index 00000000000..fbb71cc9c52 --- /dev/null +++ b/pkg/api/dashboard_public.go @@ -0,0 +1,61 @@ +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/dashboards" + "github.com/grafana/grafana/pkg/web" +) + +// gets public dashboard +func (hs *HTTPServer) GetPublicDashboard(c *models.ReqContext) response.Response { + dash, err := hs.dashboardService.GetPublicDashboard(c.Req.Context(), web.Params(c.Req)[":uid"]) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err) + } + return response.JSON(http.StatusOK, dash) +} + +// gets public dashboard configuration for dashboard +func (hs *HTTPServer) GetPublicDashboardConfig(c *models.ReqContext) response.Response { + pdc, err := hs.dashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgId, web.Params(c.Req)[":uid"]) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard config", err) + } + return response.JSON(http.StatusOK, pdc) +} + +// sets public dashboard configuration for dashboard +func (hs *HTTPServer) SavePublicDashboardConfig(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, + DashboardUid: web.Params(c.Req)[":uid"], + PublicDashboardConfig: pdc, + } + + pdc, err := hs.dashboardService.SavePublicDashboardConfig(c.Req.Context(), &dto) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to save public dashboard configuration", err) + } + + return response.JSON(http.StatusOK, pdc) +} + +// util to help us unpack a dashboard err or use default http code and message +func handleDashboardErr(defaultCode int, defaultMsg string, err error) response.Response { + var dashboardErr models.DashboardErr + + if ok := errors.As(err, &dashboardErr); ok { + return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), dashboardErr) + } + + return response.Error(defaultCode, defaultMsg, err) +} diff --git a/pkg/api/dashboard_public_config.go b/pkg/api/dashboard_public_config.go deleted file mode 100644 index 7dc903998d1..00000000000 --- a/pkg/api/dashboard_public_config.go +++ /dev/null @@ -1,53 +0,0 @@ -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/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) - - 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 deleted file mode 100644 index f487b3ea252..00000000000 --- a/pkg/api/dashboard_public_config_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "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" -) - -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)) - dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). - Return(test.publicDashboardConfigResult, test.publicDashboardConfigError) - sc.hs.dashboardService = dashSvc - - 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)) - dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("SavePublicDashboardConfig", mock.Anything, mock.AnythingOfType("*dashboards.SavePublicDashboardConfigDTO")). - Return(&models.PublicDashboardConfig{IsPublic: true}, test.saveDashboardError) - sc.hs.dashboardService = dashSvc - - 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/dashboard_public_test.go b/pkg/api/dashboard_public_test.go new file mode 100644 index 00000000000..cbd7d30899c --- /dev/null +++ b/pkg/api/dashboard_public_test.go @@ -0,0 +1,227 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +func TestAPIGetPublicDashboard(t *testing.T) { + t.Run("It should 404 if featureflag is not enabled", func(t *testing.T) { + sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures()) + dashSvc := dashboards.NewFakeDashboardService(t) + dashSvc.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). + Return(&models.Dashboard{}, nil).Maybe() + sc.hs.dashboardService = dashSvc + + setInitCtxSignedInViewer(sc.initCtx) + response := callAPI( + sc.server, + http.MethodGet, + "/api/public/dashboards", + nil, + t, + ) + assert.Equal(t, http.StatusNotFound, response.Code) + response = callAPI( + sc.server, + http.MethodGet, + "/api/public/dashboards/asdf", + nil, + t, + ) + assert.Equal(t, http.StatusNotFound, response.Code) + }) + + testCases := []struct { + name string + uid string + expectedHttpResponse int + publicDashboardResult *models.Dashboard + publicDashboardErr error + }{ + { + name: "It gets a public dashboard", + uid: "pubdash-abcd1234", + expectedHttpResponse: http.StatusOK, + publicDashboardResult: &models.Dashboard{ + Uid: "dashboard-abcd1234", + }, + publicDashboardErr: nil, + }, + { + name: "It should return 404 if isPublicDashboard is false", + uid: "pubdash-abcd1234", + expectedHttpResponse: http.StatusNotFound, + publicDashboardResult: nil, + publicDashboardErr: models.ErrPublicDashboardNotFound, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) + dashSvc := dashboards.NewFakeDashboardService(t) + dashSvc.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). + Return(test.publicDashboardResult, test.publicDashboardErr) + sc.hs.dashboardService = dashSvc + + setInitCtxSignedInViewer(sc.initCtx) + response := callAPI( + sc.server, + http.MethodGet, + fmt.Sprintf("/api/public/dashboards/%v", test.uid), + nil, + t, + ) + + assert.Equal(t, test.expectedHttpResponse, response.Code) + + if test.publicDashboardErr == nil { + var dashResp models.Dashboard + err := json.Unmarshal(response.Body.Bytes(), &dashResp) + require.NoError(t, err) + assert.Equal(t, test.publicDashboardResult.Uid, dashResp.Uid) + } else { + var errResp struct { + Error string `json:"error"` + } + err := json.Unmarshal(response.Body.Bytes(), &errResp) + require.NoError(t, err) + assert.Equal(t, test.publicDashboardErr.Error(), errResp.Error) + } + }) + } +} + +func TestAPIGetPublicDashboardConfig(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)) + dashSvc := dashboards.NewFakeDashboardService(t) + dashSvc.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). + Return(test.publicDashboardConfigResult, test.publicDashboardConfigError) + sc.hs.dashboardService = dashSvc + + 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 response.Code == http.StatusOK { + var pdcResp models.PublicDashboardConfig + err := json.Unmarshal(response.Body.Bytes(), &pdcResp) + require.NoError(t, err) + assert.Equal(t, test.publicDashboardConfigResult, &pdcResp) + } + }) + } +} + +func TestApiSavePublicDashboardConfig(t *testing.T) { + testCases := []struct { + name string + dashboardUid string + publicDashboardConfig *models.PublicDashboardConfig + expectedHttpResponse int + saveDashboardError error + }{ + { + name: "returns 200 when update persists", + dashboardUid: "1", + publicDashboardConfig: &models.PublicDashboardConfig{IsPublic: true}, + expectedHttpResponse: http.StatusOK, + saveDashboardError: nil, + }, + { + name: "returns 500 when not persisted", + expectedHttpResponse: http.StatusInternalServerError, + publicDashboardConfig: &models.PublicDashboardConfig{}, + saveDashboardError: errors.New("backend failed to save"), + }, + { + name: "returns 404 when dashboard not found", + expectedHttpResponse: http.StatusNotFound, + publicDashboardConfig: &models.PublicDashboardConfig{}, + saveDashboardError: models.ErrDashboardNotFound, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) + + dashSvc := dashboards.NewFakeDashboardService(t) + dashSvc.On("SavePublicDashboardConfig", mock.Anything, mock.AnythingOfType("*dashboards.SavePublicDashboardConfigDTO")). + Return(&models.PublicDashboardConfig{IsPublic: true}, test.saveDashboardError) + sc.hs.dashboardService = dashSvc + + 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 { + val, err := json.Marshal(test.publicDashboardConfig) + require.NoError(t, err) + assert.Equal(t, string(val), response.Body.String()) + } + }) + } +} diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index af229e6ddaa..1a68389b262 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -206,10 +206,6 @@ type Dashboard struct { Data *simplejson.Json } -type PublicDashboardConfig struct { - IsPublic bool `json:"isPublic"` -} - func (d *Dashboard) SetId(id int64) { d.Id = id d.Data.Set("id", id) @@ -417,12 +413,6 @@ type DeleteOrphanedProvisionedDashboardsCommand struct { ReaderNames []string } -type SavePublicDashboardConfigCommand struct { - Uid string - OrgId int64 - PublicDashboardConfig PublicDashboardConfig -} - // // QUERIES // diff --git a/pkg/models/dashboards_public.go b/pkg/models/dashboards_public.go new file mode 100644 index 00000000000..8804174f809 --- /dev/null +++ b/pkg/models/dashboards_public.go @@ -0,0 +1,43 @@ +package models + +var ( + ErrPublicDashboardFailedGenerateUniqueUid = DashboardErr{ + Reason: "Failed to generate unique dashboard id", + StatusCode: 500, + } + ErrPublicDashboardNotFound = DashboardErr{ + Reason: "Public dashboard not found", + StatusCode: 404, + Status: "not-found", + } + ErrPublicDashboardIdentifierNotSet = DashboardErr{ + Reason: "No Uid for public dashboard specified", + StatusCode: 400, + } +) + +type PublicDashboardConfig struct { + IsPublic bool `json:"isPublic"` + PublicDashboard PublicDashboard `json:"publicDashboard"` +} + +type PublicDashboard struct { + Uid string `json:"uid" xorm:"uid"` + DashboardUid string `json:"dashboardUid" xorm:"dashboard_uid"` + OrgId int64 `json:"orgId" xorm:"org_id"` + TimeSettings string `json:"timeSettings" xorm:"time_settings"` +} + +func (pd PublicDashboard) TableName() string { + return "dashboard_public_config" +} + +// +// COMMANDS +// + +type SavePublicDashboardConfigCommand struct { + DashboardUid string + OrgId int64 + PublicDashboardConfig PublicDashboardConfig +} diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 031e7c39d52..db64ff76d8f 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -17,6 +17,7 @@ type DashboardService interface { GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error GetDashboardUIDById(ctx context.Context, query *models.GetDashboardRefByIdQuery) error + GetPublicDashboard(ctx context.Context, publicDashboardUid string) (*models.Dashboard, error) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error @@ -63,6 +64,7 @@ type Store interface { GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) GetPublicDashboardConfig(orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) + GetPublicDashboard(uid string) (*models.PublicDashboard, *models.Dashboard, error) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error // SaveAlerts saves dashboard alerts. diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 4e22249eb12..08a0de76b44 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.12.2. DO NOT EDIT. +// Code generated by mockery v2.12.1. DO NOT EDIT. package dashboards @@ -146,6 +146,29 @@ func (_m *FakeDashboardService) GetDashboards(ctx context.Context, query *models return r0 } +// GetPublicDashboard provides a mock function with given fields: ctx, publicDashboardUid +func (_m *FakeDashboardService) GetPublicDashboard(ctx context.Context, publicDashboardUid string) (*models.Dashboard, error) { + ret := _m.Called(ctx, publicDashboardUid) + + var r0 *models.Dashboard + if rf, ok := ret.Get(0).(func(context.Context, string) *models.Dashboard); ok { + r0 = rf(ctx, publicDashboardUid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Dashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, publicDashboardUid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPublicDashboardConfig provides a mock function with given fields: ctx, orgId, dashboardUid func (_m *FakeDashboardService) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) { ret := _m.Called(ctx, orgId, dashboardUid) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index de18a96b1f2..70bba04ed0c 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -192,47 +192,6 @@ 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/database_dashboard_public.go b/pkg/services/dashboards/database/database_dashboard_public.go new file mode 100644 index 00000000000..392d9ce3de1 --- /dev/null +++ b/pkg/services/dashboards/database/database_dashboard_public.go @@ -0,0 +1,157 @@ +package database + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errutil" +) + +// retrieves public dashboard configuration +func (d *DashboardStore) GetPublicDashboard(uid string) (*models.PublicDashboard, *models.Dashboard, error) { + if uid == "" { + return nil, nil, models.ErrPublicDashboardIdentifierNotSet + } + + // get public dashboard + pdRes := &models.PublicDashboard{Uid: uid} + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + has, err := sess.Get(pdRes) + if err != nil { + return err + } + if !has { + return models.ErrPublicDashboardNotFound + } + return nil + }) + + if err != nil { + return nil, nil, err + } + + // find dashboard + dashRes := &models.Dashboard{OrgId: pdRes.OrgId, Uid: pdRes.DashboardUid} + err = d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + has, err := sess.Get(dashRes) + if err != nil { + return err + } + if !has { + return models.ErrPublicDashboardNotFound + } + return nil + }) + + if err != nil { + return nil, nil, err + } + + return pdRes, dashRes, err +} + +// generates a new unique uid to retrieve a public dashboard +func generateNewPublicDashboardUid(sess *sqlstore.DBSession) (string, error) { + for i := 0; i < 3; i++ { + uid := util.GenerateShortUID() + + exists, err := sess.Get(&models.PublicDashboard{Uid: uid}) + if err != nil { + return "", err + } + + if !exists { + return uid, nil + } + } + + return "", models.ErrPublicDashboardFailedGenerateUniqueUid +} + +// retrieves public dashboard configuration +func (d *DashboardStore) GetPublicDashboardConfig(orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) { + if dashboardUid == "" { + return nil, models.ErrDashboardIdentifierNotSet + } + + // get dashboard and publicDashboard + dashRes := &models.Dashboard{OrgId: orgId, Uid: dashboardUid} + pdRes := &models.PublicDashboard{OrgId: orgId, DashboardUid: dashboardUid} + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + // dashboard + has, err := sess.Get(dashRes) + if err != nil { + return err + } + if !has { + return models.ErrDashboardNotFound + } + + // publicDashboard + _, err = sess.Get(pdRes) + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return nil, err + } + + pdc := &models.PublicDashboardConfig{ + IsPublic: dashRes.IsPublic, + PublicDashboard: *pdRes, + } + + return pdc, err +} + +// persists public dashboard configuration +func (d *DashboardStore) SavePublicDashboardConfig(cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboardConfig, error) { + if len(cmd.PublicDashboardConfig.PublicDashboard.DashboardUid) == 0 { + return nil, models.ErrDashboardIdentifierNotSet + } + + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + // update isPublic on dashboard entry + affectedRowCount, err := sess.Table("dashboard").Where("org_id = ? AND uid = ?", cmd.OrgId, cmd.DashboardUid).Update(map[string]interface{}{"is_public": cmd.PublicDashboardConfig.IsPublic}) + if err != nil { + return err + } + + if affectedRowCount == 0 { + return models.ErrDashboardNotFound + } + + // update dashboard_public_config + // if we have a uid, public dashboard config exists. delete it otherwise generate a uid + if cmd.PublicDashboardConfig.PublicDashboard.Uid != "" { + if _, err = sess.Exec("DELETE FROM dashboard_public_config WHERE uid=?", cmd.PublicDashboardConfig.PublicDashboard.Uid); err != nil { + return err + } + } else { + uid, err := generateNewPublicDashboardUid(sess) + if err != nil { + return errutil.Wrapf(err, "Failed to generate UID for public dashboard") + } + cmd.PublicDashboardConfig.PublicDashboard.Uid = uid + } + + _, err = sess.Insert(&cmd.PublicDashboardConfig.PublicDashboard) + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return nil, err + } + + return &cmd.PublicDashboardConfig, nil +} diff --git a/pkg/services/dashboards/database/database_dashboard_public_test.go b/pkg/services/dashboards/database/database_dashboard_public_test.go new file mode 100644 index 00000000000..56f63256774 --- /dev/null +++ b/pkg/services/dashboards/database/database_dashboard_public_test.go @@ -0,0 +1,233 @@ +//go:build integration +// +build integration + +package database + +import ( + "testing" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// GetPublicDashboard +func TestGetPublicDashboard(t *testing.T) { + var sqlStore *sqlstore.SQLStore + var dashboardStore *DashboardStore + var savedDashboard *models.Dashboard + + setup := func() { + sqlStore = sqlstore.InitTestDB(t) + dashboardStore = ProvideDashboardStore(sqlStore) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) + } + + t.Run("returns PublicDashboard and Dashboard", func(t *testing.T) { + setup() + pdc, err := dashboardStore.SavePublicDashboardConfig(models.SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboardConfig: models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + Uid: "abc1234", + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + }, + }, + }) + require.NoError(t, err) + + pd, d, err := dashboardStore.GetPublicDashboard("abc1234") + require.NoError(t, err) + assert.Equal(t, pd, &pdc.PublicDashboard) + assert.Equal(t, d.Uid, pdc.PublicDashboard.DashboardUid) + }) + + t.Run("returns ErrPublicDashboardNotFound with empty uid", func(t *testing.T) { + setup() + _, _, err := dashboardStore.GetPublicDashboard("") + require.Error(t, models.ErrPublicDashboardIdentifierNotSet, err) + }) + + t.Run("returns ErrPublicDashboardNotFound when PublicDashboard not found", func(t *testing.T) { + setup() + _, _, err := dashboardStore.GetPublicDashboard("zzzzzz") + require.Error(t, models.ErrPublicDashboardNotFound, err) + }) + + t.Run("returns ErrDashboardNotFound when Dashboard not found", func(t *testing.T) { + setup() + _, err := dashboardStore.SavePublicDashboardConfig(models.SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboardConfig: models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + Uid: "abc1234", + DashboardUid: "nevergonnafindme", + OrgId: savedDashboard.OrgId, + }, + }, + }) + require.NoError(t, err) + _, _, err = dashboardStore.GetPublicDashboard("abc1234") + require.Error(t, models.ErrDashboardNotFound, err) + }) + +} + +// GetPublicDashboardConfig +func TestGetPublicDashboardConfig(t *testing.T) { + var sqlStore *sqlstore.SQLStore + var dashboardStore *DashboardStore + var savedDashboard *models.Dashboard + + setup := func() { + sqlStore = sqlstore.InitTestDB(t) + dashboardStore = ProvideDashboardStore(sqlStore) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) + } + + t.Run("returns isPublic and set dashboardUid and orgId", func(t *testing.T) { + setup() + pdc, err := dashboardStore.GetPublicDashboardConfig(savedDashboard.OrgId, savedDashboard.Uid) + require.NoError(t, err) + assert.Equal(t, &models.PublicDashboardConfig{IsPublic: false, PublicDashboard: models.PublicDashboard{DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId}}, pdc) + }) + + t.Run("returns dashboard errDashboardIdentifierNotSet", func(t *testing.T) { + setup() + _, err := dashboardStore.GetPublicDashboardConfig(savedDashboard.OrgId, "") + require.Error(t, models.ErrDashboardIdentifierNotSet, err) + }) + + t.Run("returns isPublic along with public dashboard when exists", func(t *testing.T) { + setup() + // insert test public dashboard + resp, err := dashboardStore.SavePublicDashboardConfig(models.SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboardConfig: models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + Uid: "pubdash-uid", + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + TimeSettings: "{from: now, to: then}", + }, + }, + }) + require.NoError(t, err) + + pdc, err := dashboardStore.GetPublicDashboardConfig(savedDashboard.OrgId, savedDashboard.Uid) + require.NoError(t, err) + assert.Equal(t, resp, pdc) + }) +} + +// SavePublicDashboardConfig +func TestSavePublicDashboardConfig(t *testing.T) { + var sqlStore *sqlstore.SQLStore + var dashboardStore *DashboardStore + var savedDashboard *models.Dashboard + var savedDashboard2 *models.Dashboard + + setup := func() { + sqlStore = sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) + dashboardStore = ProvideDashboardStore(sqlStore) + savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) + savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, true) + } + + t.Run("saves new public dashboard", func(t *testing.T) { + setup() + resp, err := dashboardStore.SavePublicDashboardConfig(models.SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboardConfig: models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + Uid: "pubdash-uid", + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + }, + }, + }) + require.NoError(t, err) + + pdc, err := dashboardStore.GetPublicDashboardConfig(savedDashboard.OrgId, savedDashboard.Uid) + require.NoError(t, err) + + //verify saved response and queried response are the same + assert.Equal(t, resp, pdc) + + // verify we have a valid uid + assert.True(t, util.IsValidShortUID(pdc.PublicDashboard.Uid)) + + // verify we didn't update all dashboards + pdc2, err := dashboardStore.GetPublicDashboardConfig(savedDashboard2.OrgId, savedDashboard2.Uid) + assert.False(t, pdc2.IsPublic) + }) + + t.Run("returns ErrDashboardIdentifierNotSet", func(t *testing.T) { + setup() + _, err := dashboardStore.SavePublicDashboardConfig(models.SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboardConfig: models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + DashboardUid: "", + OrgId: savedDashboard.OrgId, + }, + }, + }) + require.Error(t, models.ErrDashboardIdentifierNotSet, err) + }) + + t.Run("overwrites existing public dashboard", func(t *testing.T) { + setup() + + pdUid := util.GenerateShortUID() + + // insert initial record + _, err := dashboardStore.SavePublicDashboardConfig(models.SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboardConfig: models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + Uid: pdUid, + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + }, + }, + }) + require.NoError(t, err) + + // update initial record + resp, err := dashboardStore.SavePublicDashboardConfig(models.SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboardConfig: models.PublicDashboardConfig{ + IsPublic: false, + PublicDashboard: models.PublicDashboard{ + Uid: pdUid, + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + TimeSettings: "{}", + }, + }, + }) + require.NoError(t, err) + + pdc, err := dashboardStore.GetPublicDashboardConfig(savedDashboard.OrgId, savedDashboard.Uid) + require.NoError(t, err) + assert.Equal(t, resp, pdc) + }) +} diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 662f9ad7cba..431647e3294 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -16,9 +16,9 @@ type SaveDashboardDTO struct { } type SavePublicDashboardConfigDTO struct { - Uid string + DashboardUid string OrgId int64 - PublicDashboardConfig models.PublicDashboardConfig + PublicDashboardConfig *models.PublicDashboardConfig } type DashboardSearchProjection struct { diff --git a/pkg/services/dashboards/service/dashboard_public.go b/pkg/services/dashboards/service/dashboard_public.go new file mode 100644 index 00000000000..d282fd70cc5 --- /dev/null +++ b/pkg/services/dashboards/service/dashboard_public.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" +) + +// Gets public dashboard via generated Uid +func (dr *DashboardServiceImpl) GetPublicDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) { + pdc, d, err := dr.dashboardStore.GetPublicDashboard(dashboardUid) + + if err != nil { + return nil, err + } + + if pdc == nil || d == nil { + return nil, models.ErrPublicDashboardNotFound + } + + if !d.IsPublic { + return nil, models.ErrPublicDashboardNotFound + } + + // FIXME insert logic to substitute pdc.TimeSettings into d + + return d, 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 *dashboards.SavePublicDashboardConfigDTO) (*models.PublicDashboardConfig, error) { + cmd := models.SavePublicDashboardConfigCommand{ + DashboardUid: dto.DashboardUid, + OrgId: dto.OrgId, + PublicDashboardConfig: *dto.PublicDashboardConfig, + } + + // Eventually we want this to propagate to array of public dashboards + cmd.PublicDashboardConfig.PublicDashboard.OrgId = dto.OrgId + cmd.PublicDashboardConfig.PublicDashboard.DashboardUid = dto.DashboardUid + + pdc, err := dr.dashboardStore.SavePublicDashboardConfig(cmd) + if err != nil { + return nil, err + } + + return pdc, nil +} diff --git a/pkg/services/dashboards/service/dashboard_public_test.go b/pkg/services/dashboards/service/dashboard_public_test.go new file mode 100644 index 00000000000..a75890bf1f1 --- /dev/null +++ b/pkg/services/dashboards/service/dashboard_public_test.go @@ -0,0 +1,162 @@ +package service + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetPublicDashboard(t *testing.T) { + type storeResp struct { + pd *models.PublicDashboard + d *models.Dashboard + err error + } + + testCases := []struct { + name string + uid string + storeResp *storeResp + errResp error + dashResp *models.Dashboard + }{ + { + name: "returns a dashboard", + uid: "abc123", + storeResp: &storeResp{pd: &models.PublicDashboard{}, d: &models.Dashboard{IsPublic: true}, err: nil}, + errResp: nil, + dashResp: &models.Dashboard{IsPublic: true}, + }, + { + name: "returns ErrPublicDashboardNotFound when isPublic is false", + uid: "abc123", + storeResp: &storeResp{pd: &models.PublicDashboard{}, d: &models.Dashboard{IsPublic: false}, err: nil}, + errResp: models.ErrPublicDashboardNotFound, + dashResp: nil, + }, + { + name: "returns ErrPublicDashboardNotFound if PublicDashboard missing", + uid: "abc123", + storeResp: &storeResp{pd: nil, d: nil, err: nil}, + errResp: models.ErrPublicDashboardNotFound, + dashResp: nil, + }, + { + name: "returns ErrPublicDashboardNotFound if Dashboard missing", + uid: "abc123", + storeResp: &storeResp{pd: nil, d: nil, err: nil}, + errResp: models.ErrPublicDashboardNotFound, + dashResp: nil, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + service := &DashboardServiceImpl{ + log: log.New("test.logger"), + dashboardStore: &fakeStore, + } + fakeStore.On("GetPublicDashboard", mock.Anything). + Return(test.storeResp.pd, test.storeResp.d, test.storeResp.err) + + dashboard, err := service.GetPublicDashboard(context.Background(), test.uid) + if test.errResp != nil { + assert.Error(t, test.errResp, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, test.dashResp, dashboard) + }) + } +} + +func TestSavePublicDashboard(t *testing.T) { + t.Run("gets PublicDashboard.orgId and PublicDashboard.DashboardUid set from SavePublicDashboardConfigDTO", func(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t) + dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) + + service := &DashboardServiceImpl{ + log: log.New("test.logger"), + dashboardStore: dashboardStore, + } + + dto := &dashboards.SavePublicDashboardConfigDTO{ + DashboardUid: dashboard.Uid, + OrgId: dashboard.OrgId, + PublicDashboardConfig: &models.PublicDashboardConfig{ + IsPublic: true, + PublicDashboard: models.PublicDashboard{ + DashboardUid: "NOTTHESAME", + OrgId: 9999999, + }, + }, + } + + pdc, err := service.SavePublicDashboardConfig(context.Background(), dto) + require.NoError(t, err) + + assert.Equal(t, dashboard.Uid, pdc.PublicDashboard.DashboardUid) + assert.Equal(t, dashboard.OrgId, pdc.PublicDashboard.OrgId) + }) + + t.Run("PLACEHOLDER - dashboard with template variables cannot be saved", func(t *testing.T) { + //sqlStore := sqlstore.InitTestDB(t) + //dashboardStore := database.ProvideDashboardStore(sqlStore) + //dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) + + //service := &DashboardServiceImpl{ + //log: log.New("test.logger"), + //dashboardStore: dashboardStore, + //} + + //dto := &dashboards.SavePublicDashboardConfigDTO{ + //DashboardUid: dashboard.Uid, + //OrgId: dashboard.OrgId, + //PublicDashboardConfig: &models.PublicDashboardConfig{ + //IsPublic: true, + //PublicDashboard: models.PublicDashboard{ + //DashboardUid: "NOTTHESAME", + //OrgId: 9999999, + //}, + //}, + //} + + //pdc, err := service.SavePublicDashboardConfig(context.Background(), dto) + //require.NoError(t, err) + + //assert.Equal(t, dashboard.Uid, pdc.PublicDashboard.DashboardUid) + //assert.Equal(t, dashboard.OrgId, pdc.PublicDashboard.OrgId) + }) +} + +func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, title string, orgId int64, + folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { + t.Helper() + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + dash, err := dashboardStore.SaveDashboard(cmd) + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.Id) + dash.Data.Set("uid", dash.Uid) + return dash +} diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 5f117c65ad2..0ef591af11b 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -344,33 +344,6 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar 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 *dashboards.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/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 20b34459917..c7e061b6d97 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.12.2. DO NOT EDIT. +// Code generated by mockery v2.12.1. DO NOT EDIT. package dashboards @@ -289,6 +289,38 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dash return r0, r1 } +// GetPublicDashboard provides a mock function with given fields: uid +func (_m *FakeDashboardStore) GetPublicDashboard(uid string) (*models.PublicDashboard, *models.Dashboard, error) { + ret := _m.Called(uid) + + var r0 *models.PublicDashboard + if rf, ok := ret.Get(0).(func(string) *models.PublicDashboard); ok { + r0 = rf(uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.PublicDashboard) + } + } + + var r1 *models.Dashboard + if rf, ok := ret.Get(1).(func(string) *models.Dashboard); ok { + r1 = rf(uid) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*models.Dashboard) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(uid) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // GetPublicDashboardConfig provides a mock function with given fields: orgId, dashboardUid func (_m *FakeDashboardStore) GetPublicDashboardConfig(orgId int64, dashboardUid string) (*models.PublicDashboardConfig, error) { ret := _m.Called(orgId, dashboardUid) diff --git a/pkg/services/sqlstore/migrations/dashboard_public_config_mig.go b/pkg/services/sqlstore/migrations/dashboard_public_config_mig.go index 97c86e5bab0..a53c80e6b6d 100644 --- a/pkg/services/sqlstore/migrations/dashboard_public_config_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_public_config_mig.go @@ -8,19 +8,26 @@ func addPublicDashboardMigration(mg *Migrator) { var dashboardPublicCfgV1 = Table{ Name: "dashboard_public_config", Columns: []*Column{ - {Name: "uid", Type: DB_BigInt, IsPrimaryKey: true}, + {Name: "uid", Type: DB_NVarchar, Length: 40, IsPrimaryKey: true}, {Name: "dashboard_uid", Type: DB_NVarchar, Length: 40, Nullable: false}, {Name: "org_id", Type: DB_BigInt, Nullable: false}, + {Name: "time_settings", Type: DB_Text, Nullable: false}, {Name: "refresh_rate", Type: DB_Int, Nullable: false, Default: "30"}, {Name: "template_variables", Type: DB_MediumText, Nullable: true}, - {Name: "time_variables", Type: DB_Text, Nullable: false}, }, Indices: []*Index{ {Cols: []string{"uid"}, Type: UniqueIndex}, {Cols: []string{"org_id", "dashboard_uid"}}, }, } - mg.AddMigration("create dashboard public config v1", NewAddTableMigration(dashboardPublicCfgV1)) + + // table has no dependencies and was created with incorrect pkey type. + // drop then recreate with correct values + addDropAllIndicesMigrations(mg, "v1", dashboardPublicCfgV1) + mg.AddMigration("Drop old dashboard public config table", NewDropTableMigration("dashboard_public_config")) + + // recreate table with proper primary key type + mg.AddMigration("recreate dashboard public config v1", NewAddTableMigration(dashboardPublicCfgV1)) addTableIndicesMigrations(mg, "v1", dashboardPublicCfgV1) } diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx index 79a37a0769c..dd460df3d24 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx @@ -1,10 +1,27 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; +import { BackendSrv } from '@grafana/runtime'; import config from 'app/core/config'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { ShareModal } from './ShareModal'; +import { PublicDashboardConfig } from './SharePublicDashboardUtils'; + +// Mock api request +const publicDashboardconfigResp: PublicDashboardConfig = { + isPublic: true, + publicDashboard: { uid: '', dashboardUid: '' }, +}; + +const backendSrv = { + get: () => publicDashboardconfigResp, +} as unknown as BackendSrv; + +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as unknown as object), + getBackendSrv: () => backendSrv, +})); jest.mock('app/core/core', () => { return { diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx index 843fb0c760d..6363dce4035 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx @@ -1,12 +1,13 @@ import React, { useState, useEffect } from 'react'; -import { Button, Field, Switch } from '@grafana/ui'; +import { Button, Field, Switch, Alert } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; -import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { VariableModel } from 'app/features/variables/types'; import { dispatch } from 'app/store/store'; import { - dashboardCanBePublic, + dashboardHasTemplateVariables, getPublicDashboardConfig, savePublicDashboardConfig, PublicDashboardConfig, @@ -15,46 +16,45 @@ 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; + const [publicDashboardConfig, setPublicDashboardConfig] = useState({ + isPublic: false, + publicDashboard: { uid: '', dashboardUid }, + }); + + const [dashboardVariables, setDashboardVariables] = useState([]); useEffect(() => { - getPublicDashboardConfig(dashboardUid) - .then((pdc: PublicDashboardConfig) => { - setPublicDashboardConfig(pdc); - }) - .catch(() => { - dispatch(notifyApp(createErrorNotification('Failed to retrieve public dashboard config'))); - }); - }, [dashboardUid]); + setDashboardVariables(props.dashboard.getVariables()); + getPublicDashboardConfig(dashboardUid, setPublicDashboardConfig).catch(); + }, [props, dashboardUid]); const onSavePublicConfig = () => { - // verify dashboard can be public - if (!dashboardCanBePublic(props.dashboard)) { - dispatch(notifyApp(createErrorNotification('This dashboard cannot be made public'))); + if (dashboardHasTemplateVariables(dashboardVariables)) { + dispatch( + notifyApp(createErrorNotification('This dashboard cannot be made public because it has template variables')) + ); 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'))); - } + savePublicDashboardConfig(props.dashboard.uid, publicDashboardConfig, setPublicDashboardConfig).catch(); }; return ( <> + {dashboardHasTemplateVariables(dashboardVariables) && ( + + This dashboard cannot be made public because it has template variables + + )} +

Public Dashboard Configuration

+ setPublicDashboardConfig((state) => { diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx index 58b1d4e3cc2..a42188b122d 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx @@ -1,17 +1,16 @@ -import { DashboardModel } from 'app/features/dashboard/state'; +import { VariableModel } from 'app/features/variables/types'; -import { dashboardCanBePublic } from './SharePublicDashboardUtils'; +import { dashboardHasTemplateVariables } from './SharePublicDashboardUtils'; -describe('dashboardCanBePublic', () => { - it('can be public with no template variables', () => { - //@ts-ignore - const dashboard: DashboardModel = { templating: { list: [] } }; - expect(dashboardCanBePublic(dashboard)).toBe(true); +describe('dashboardHasTemplateVariables', () => { + it('false', () => { + let variables: VariableModel[] = []; + expect(dashboardHasTemplateVariables(variables)).toBe(false); }); - it('cannot be public with template variables', () => { + it('true', () => { //@ts-ignore - const dashboard: DashboardModel = { templating: { list: [{}] } }; - expect(dashboardCanBePublic(dashboard)).toBe(false); + let variables: VariableModel[] = ['a']; + expect(dashboardHasTemplateVariables(variables)).toBe(true); }); }); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts index 6213ddfae75..bd6a0dc60eb 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts @@ -1,21 +1,43 @@ import { getBackendSrv } from '@grafana/runtime'; -import { DashboardModel } from 'app/features/dashboard/state'; +import { notifyApp } from 'app/core/actions'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { VariableModel } from 'app/features/variables/types'; +import { dispatch } from 'app/store/store'; +import { DashboardDataDTO, DashboardMeta } from 'app/types/dashboard'; export interface PublicDashboardConfig { isPublic: boolean; + publicDashboard: { + uid: string; + dashboardUid: string; + timeSettings?: object; + }; +} +export interface DashboardResponse { + dashboard: DashboardDataDTO; + meta: DashboardMeta; } -export const dashboardCanBePublic = (dashboard: DashboardModel): boolean => { - return dashboard?.templating?.list.length === 0; +export const dashboardHasTemplateVariables = (variables: VariableModel[]): boolean => { + return variables.length > 0; }; -export const getPublicDashboardConfig = async (dashboardUid: string) => { +export const getPublicDashboardConfig = async ( + dashboardUid: string, + setPublicDashboardConfig: React.Dispatch> +) => { const url = `/api/dashboards/uid/${dashboardUid}/public-config`; - return getBackendSrv().get(url); + const pdResp: PublicDashboardConfig = await getBackendSrv().get(url); + setPublicDashboardConfig(pdResp); }; -export const savePublicDashboardConfig = async (dashboardUid: string, conf: PublicDashboardConfig) => { - const payload = { isPublic: conf.isPublic }; +export const savePublicDashboardConfig = async ( + dashboardUid: string, + publicDashboardConfig: PublicDashboardConfig, + setPublicDashboardConfig: Function +) => { const url = `/api/dashboards/uid/${dashboardUid}/public-config`; - return getBackendSrv().post(url, payload); + const pdResp: PublicDashboardConfig = await getBackendSrv().post(url, publicDashboardConfig); + dispatch(notifyApp(createSuccessNotification('Dashboard sharing configuration saved'))); + setPublicDashboardConfig(pdResp); };