Snapshots: Add snapshot enable config (#61587)

* Add config to remove Snapshot functionality (frontend is hidden and validation in the backend)
* Add test cases
* Remove unused mock on the test
* Moving Snapshot config from globar variables to settings.Cfg
* Removing warnings on code
pull/62211/head
lean.dev 3 years ago committed by GitHub
parent 928e2c9c9e
commit 7d8ec6199d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      conf/defaults.ini
  2. 3
      conf/sample.ini
  3. 1
      packages/grafana-data/src/types/config.ts
  4. 1
      packages/grafana-runtime/src/config.ts
  5. 2
      pkg/api/api.go
  6. 86
      pkg/api/dashboard_snapshot.go
  7. 107
      pkg/api/dashboard_snapshot_test.go
  8. 1
      pkg/api/frontendsettings.go
  9. 7
      pkg/services/dashboardsnapshots/database/database.go
  10. 6
      pkg/services/dashboardsnapshots/database/database_test.go
  11. 2
      pkg/services/dashboardsnapshots/service/service_test.go
  12. 16
      pkg/services/navtree/navtreeimpl/navtree.go
  13. 22
      pkg/setting/setting.go
  14. 33
      public/app/features/dashboard/components/ShareModal/ShareModal.tsx

@ -366,6 +366,9 @@ data_keys_cache_cleanup_interval = 1m
#################################### Snapshots ########################### #################################### Snapshots ###########################
[snapshots] [snapshots]
# set to false to remove snapshot functionality
enabled = true
# snapshot sharing options # snapshot sharing options
external_enabled = true external_enabled = true
external_snapshot_url = https://snapshots.raintank.io external_snapshot_url = https://snapshots.raintank.io

@ -372,6 +372,9 @@
#################################### Snapshots ########################### #################################### Snapshots ###########################
[snapshots] [snapshots]
# set to false to remove snapshot functionality
;enabled = true
# snapshot sharing options # snapshot sharing options
;external_enabled = true ;external_enabled = true
;external_snapshot_url = https://snapshots.raintank.io ;external_snapshot_url = https://snapshots.raintank.io

@ -152,6 +152,7 @@ export interface BootData {
*/ */
export interface GrafanaConfig { export interface GrafanaConfig {
isPublicDashboardView: boolean; isPublicDashboardView: boolean;
snapshotEnabled: boolean;
datasources: { [str: string]: DataSourceInstanceSettings }; datasources: { [str: string]: DataSourceInstanceSettings };
panels: { [key: string]: PanelPluginMeta }; panels: { [key: string]: PanelPluginMeta };
auth: AuthSettings; auth: AuthSettings;

@ -27,6 +27,7 @@ export interface AzureSettings {
export class GrafanaBootConfig implements GrafanaConfig { export class GrafanaBootConfig implements GrafanaConfig {
isPublicDashboardView: boolean; isPublicDashboardView: boolean;
snapshotEnabled = true;
datasources: { [str: string]: DataSourceInstanceSettings } = {}; datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {}; panels: { [key: string]: PanelPluginMeta } = {};
auth: AuthSettings = {}; auth: AuthSettings = {};

@ -701,7 +701,7 @@ func (hs *HTTPServer) registerRoutes() {
// Snapshots // Snapshots
r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.CreateDashboardSnapshot) r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.CreateDashboardSnapshot)
r.Get("/api/snapshot/shared-options/", reqSignedIn, GetSharingOptions) r.Get("/api/snapshot/shared-options/", reqSignedIn, hs.GetSharingOptions)
r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot)) r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot))
r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey))
r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot)) r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot))

@ -33,11 +33,12 @@ var client = &http.Client{
// Responses: // Responses:
// 200: getSharingOptionsResponse // 200: getSharingOptionsResponse
// 401: unauthorisedError // 401: unauthorisedError
func GetSharingOptions(c *models.ReqContext) { func (hs *HTTPServer) GetSharingOptions(c *models.ReqContext) {
c.JSON(http.StatusOK, util.DynMap{ c.JSON(http.StatusOK, util.DynMap{
"externalSnapshotURL": setting.ExternalSnapshotUrl, "snapshotEnabled": hs.Cfg.SnapshotEnabled,
"externalSnapshotName": setting.ExternalSnapshotName, "externalSnapshotURL": hs.Cfg.ExternalSnapshotUrl,
"externalEnabled": setting.ExternalEnabled, "externalSnapshotName": hs.Cfg.ExternalSnapshotName,
"externalEnabled": hs.Cfg.ExternalEnabled,
}) })
} }
@ -48,7 +49,7 @@ type CreateExternalSnapshotResponse struct {
DeleteUrl string `json:"deleteUrl"` DeleteUrl string `json:"deleteUrl"`
} }
func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand) (*CreateExternalSnapshotResponse, error) { func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) {
var createSnapshotResponse CreateExternalSnapshotResponse var createSnapshotResponse CreateExternalSnapshotResponse
message := map[string]interface{}{ message := map[string]interface{}{
"name": cmd.Name, "name": cmd.Name,
@ -63,28 +64,28 @@ func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnaps
return nil, err return nil, err
} }
response, err := client.Post(setting.ExternalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer func() {
if err := response.Body.Close(); err != nil { if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err) plog.Warn("Failed to close response body", "err", err)
} }
}() }()
if response.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("create external snapshot response status code %d", response.StatusCode) return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode)
} }
if err := json.NewDecoder(response.Body).Decode(&createSnapshotResponse); err != nil { if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil {
return nil, err return nil, err
} }
return &createSnapshotResponse, nil return &createSnapshotResponse, nil
} }
func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) { func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) {
dashUID := cmd.Dashboard.Get("uid").MustString("") dashUID := cmd.Dashboard.Get("uid").MustString("")
if ok := util.IsValidShortUID(dashUID); !ok { if ok := util.IsValidShortUID(dashUID); !ok {
return "", fmt.Errorf("invalid dashboard UID") return "", fmt.Errorf("invalid dashboard UID")
@ -105,6 +106,11 @@ func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDas
// 403: forbiddenError // 403: forbiddenError
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Response { func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{} cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{}
if err := web.Bind(c.Req, &cmd); err != nil { if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err) return response.Error(http.StatusBadRequest, "bad request data", err)
@ -117,28 +123,28 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
cmd.ExternalURL = "" cmd.ExternalURL = ""
cmd.OrgID = c.OrgID cmd.OrgID = c.OrgID
cmd.UserID = c.UserID cmd.UserID = c.UserID
originalDashboardURL, err := createOriginalDashboardURL(hs.Cfg.AppURL, &cmd) originalDashboardURL, err := createOriginalDashboardURL(&cmd)
if err != nil { if err != nil {
return response.Error(http.StatusInternalServerError, "Invalid app URL", err) return response.Error(http.StatusInternalServerError, "Invalid app URL", err)
} }
if cmd.External { if cmd.External {
if !setting.ExternalEnabled { if !hs.Cfg.ExternalEnabled {
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
return nil return nil
} }
response, err := createExternalDashboardSnapshot(cmd) resp, err := createExternalDashboardSnapshot(cmd, hs.Cfg.ExternalSnapshotUrl)
if err != nil { if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err) c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
return nil return nil
} }
snapshotUrl = response.Url snapshotUrl = resp.Url
cmd.Key = response.Key cmd.Key = resp.Key
cmd.DeleteKey = response.DeleteKey cmd.DeleteKey = resp.DeleteKey
cmd.ExternalURL = response.Url cmd.ExternalURL = resp.Url
cmd.ExternalDeleteURL = response.DeleteUrl cmd.ExternalDeleteURL = resp.DeleteUrl
cmd.Dashboard = simplejson.New() cmd.Dashboard = simplejson.New()
metrics.MApiDashboardSnapshotExternal.Inc() metrics.MApiDashboardSnapshotExternal.Inc()
@ -195,6 +201,11 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
// 404: notFoundError // 404: notFoundError
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Response { func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
key := web.Params(c.Req)[":key"] key := web.Params(c.Req)[":key"]
if len(key) == 0 { if len(key) == 0 {
return response.Error(http.StatusBadRequest, "Empty snapshot key", nil) return response.Error(http.StatusBadRequest, "Empty snapshot key", nil)
@ -230,26 +241,26 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon
} }
func deleteExternalDashboardSnapshot(externalUrl string) error { func deleteExternalDashboardSnapshot(externalUrl string) error {
response, err := client.Get(externalUrl) resp, err := client.Get(externalUrl)
if err != nil { if err != nil {
return err return err
} }
defer func() { defer func() {
if err := response.Body.Close(); err != nil { if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err) plog.Warn("Failed to close response body", "err", err)
} }
}() }()
if response.StatusCode == 200 { if resp.StatusCode == 200 {
return nil return nil
} }
// Gracefully ignore "snapshot not found" errors as they could have already // Gracefully ignore "snapshot not found" errors as they could have already
// been removed either via the cleanup script or by request. // been removed either via the cleanup script or by request.
if response.StatusCode == 500 { if resp.StatusCode == 500 {
var respJson map[string]interface{} var respJson map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&respJson); err != nil { if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil {
return err return err
} }
@ -258,7 +269,7 @@ func deleteExternalDashboardSnapshot(externalUrl string) error {
} }
} }
return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", response.StatusCode) return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode)
} }
// swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey // swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey
@ -274,6 +285,11 @@ func deleteExternalDashboardSnapshot(externalUrl string) error {
// 404: notFoundError // 404: notFoundError
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) response.Response { func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
key := web.Params(c.Req)[":deleteKey"] key := web.Params(c.Req)[":deleteKey"]
if len(key) == 0 { if len(key) == 0 {
return response.Error(404, "Snapshot not found", nil) return response.Error(404, "Snapshot not found", nil)
@ -314,6 +330,11 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r
// 404: notFoundError // 404: notFoundError
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Response { func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
key := web.Params(c.Req)[":key"] key := web.Params(c.Req)[":key"]
if len(key) == 0 { if len(key) == 0 {
return response.Error(http.StatusNotFound, "Snapshot not found", nil) return response.Error(http.StatusNotFound, "Snapshot not found", nil)
@ -343,12 +364,12 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
dashboardID := queryResult.Dashboard.Get("id").MustInt64() dashboardID := queryResult.Dashboard.Get("id").MustInt64()
if dashboardID != 0 { if dashboardID != 0 {
guardian, err := guardian.New(c.Req.Context(), dashboardID, c.OrgID, c.SignedInUser) g, err := guardian.New(c.Req.Context(), dashboardID, c.OrgID, c.SignedInUser)
if err != nil { if err != nil {
return response.Err(err) return response.Err(err)
} }
canEdit, err := guardian.CanEdit() canEdit, err := g.CanEdit()
// check for permissions only if the dashboard is found // check for permissions only if the dashboard is found
if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) {
return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err) return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err)
@ -379,6 +400,11 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
// 200: searchDashboardSnapshotsResponse // 200: searchDashboardSnapshotsResponse
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Response { func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
query := c.Query("query") query := c.Query("query")
limit := c.QueryInt("limit") limit := c.QueryInt("limit")
@ -398,9 +424,9 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re
return response.Error(500, "Search failed", err) return response.Error(500, "Search failed", err)
} }
dtos := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQueryResult)) dto := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQueryResult))
for i, snapshot := range searchQueryResult { for i, snapshot := range searchQueryResult {
dtos[i] = &dashboardsnapshots.DashboardSnapshotDTO{ dto[i] = &dashboardsnapshots.DashboardSnapshotDTO{
ID: snapshot.ID, ID: snapshot.ID,
Name: snapshot.Name, Name: snapshot.Name,
Key: snapshot.Key, Key: snapshot.Key,
@ -414,7 +440,7 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re
} }
} }
return response.JSON(http.StatusOK, dtos) return response.JSON(http.StatusOK, dto)
} }
// swagger:parameters createDashboardSnapshot // swagger:parameters createDashboardSnapshot

@ -21,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/team/teamtest"
"github.com/grafana/grafana/pkg/setting"
) )
func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
@ -64,7 +65,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
t.Run("When user has editor role and is not in the ACL", func(t *testing.T) { t.Run("When user has editor role and is not in the ACL", func(t *testing.T) {
loggedInUserScenarioWithRole(t, "Should not be able to delete snapshot when calling DELETE on", loggedInUserScenarioWithRole(t, "Should not be able to delete snapshot when calling DELETE on",
"DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, "")} d := setUpSnapshotTest(t, 0, "")
hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
teamSvc := &teamtest.FakeService{} teamSvc := &teamtest.FakeService{}
@ -95,7 +97,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
rw.WriteHeader(200) rw.WriteHeader(200)
externalRequest = req externalRequest = req
}) })
hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, ts.URL)} d := setUpSnapshotTest(t, 0, ts.URL)
hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec() sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
@ -138,7 +141,9 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
} }
dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResultACL, nil) dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResultACL, nil)
guardian.InitLegacyGuardian(sc.sqlStore, dashSvc, teamSvc) guardian.InitLegacyGuardian(sc.sqlStore, dashSvc, teamSvc)
hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, ts.URL), DashboardService: dashSvc} d := setUpSnapshotTest(t, 0, ts.URL)
hs := buildHttpServer(d, true)
hs.DashboardService = dashSvc
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
@ -159,7 +164,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
d := setUpSnapshotTest(t, testUserID, "") d := setUpSnapshotTest(t, testUserID, "")
dashSvc := dashboards.NewFakeDashboardService(t) dashSvc := dashboards.NewFakeDashboardService(t)
hs := &HTTPServer{dashboardsnapshotsService: d, DashboardService: dashSvc} hs := buildHttpServer(d, true)
hs.DashboardService = dashSvc
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
@ -183,7 +189,9 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
}) })
dashSvc := dashboards.NewFakeDashboardService(t) dashSvc := dashboards.NewFakeDashboardService(t)
hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL), DashboardService: dashSvc} d := setUpSnapshotTest(t, testUserID, ts.URL)
hs := buildHttpServer(d, true)
hs.DashboardService = dashSvc
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
@ -204,7 +212,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
rw.WriteHeader(500) rw.WriteHeader(500)
_, writeErr = rw.Write([]byte(`{"message":"Unexpected"}`)) _, writeErr = rw.Write([]byte(`{"message":"Unexpected"}`))
}) })
hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL)} d := setUpSnapshotTest(t, testUserID, ts.URL)
hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
@ -218,7 +227,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(404) rw.WriteHeader(404)
}) })
hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL)} d := setUpSnapshotTest(t, testUserID, ts.URL)
hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
@ -227,7 +237,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
loggedInUserScenarioWithRole(t, "Should be able to read a snapshot's unencrypted data when calling GET on", loggedInUserScenarioWithRole(t, "Should be able to read a snapshot's unencrypted data when calling GET on",
"GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { "GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, "")} d := setUpSnapshotTest(t, 0, "")
hs := buildHttpServer(d, true)
sc.handlerFunc = hs.GetDashboardSnapshot sc.handlerFunc = hs.GetDashboardSnapshot
sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
@ -262,7 +273,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
"GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET", "GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET",
"/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t) d := setUpSnapshotTest(t)
hs := &HTTPServer{dashboardsnapshotsService: d} hs := buildHttpServer(d, true)
sc.handlerFunc = hs.GetDashboardSnapshot sc.handlerFunc = hs.GetDashboardSnapshot
sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
@ -273,7 +284,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
"DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE", "DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE",
"/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t) d := setUpSnapshotTest(t)
hs := &HTTPServer{dashboardsnapshotsService: d} hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
@ -284,7 +295,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
"GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE", "GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE",
"/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t) d := setUpSnapshotTest(t)
hs := &HTTPServer{dashboardsnapshotsService: d} hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
@ -295,48 +306,94 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) {
func TestGetDashboardSnapshotFailure(t *testing.T) { func TestGetDashboardSnapshotFailure(t *testing.T) {
sqlmock := dbtest.NewFakeDB() sqlmock := dbtest.NewFakeDB()
setUpSnapshotTest := func(t *testing.T) dashboardsnapshots.Service { setUpSnapshotTest := func(t *testing.T, shouldMockDashSnapServ bool) dashboardsnapshots.Service {
t.Helper() t.Helper()
dashSnapSvc := dashboardsnapshots.NewMockService(t) if shouldMockDashSnapServ {
dashSnapSvc. dashSnapSvc := dashboardsnapshots.NewMockService(t)
On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")). dashSnapSvc.
Run(func(args mock.Arguments) {}). On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")).
Return(nil, errors.New("something went wrong")) Run(func(args mock.Arguments) {}).
Return(nil, errors.New("something went wrong"))
return dashSnapSvc return dashSnapSvc
} else {
return nil
}
} }
loggedInUserScenarioWithRole(t, loggedInUserScenarioWithRole(t,
"GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET", "GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET",
"/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t) d := setUpSnapshotTest(t, true)
hs := &HTTPServer{dashboardsnapshotsService: d} hs := buildHttpServer(d, true)
sc.handlerFunc = hs.GetDashboardSnapshot sc.handlerFunc = hs.GetDashboardSnapshot
sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
}, sqlmock) }, sqlmock)
loggedInUserScenarioWithRole(t,
"GET /snapshots/{key} should return 403 when snapshot is disabled", "GET",
"/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t, false)
hs := buildHttpServer(d, false)
sc.handlerFunc = hs.GetDashboardSnapshot
sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
assert.Equal(t, http.StatusForbidden, sc.resp.Code)
}, sqlmock)
loggedInUserScenarioWithRole(t, loggedInUserScenarioWithRole(t,
"DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE", "DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE",
"/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t) d := setUpSnapshotTest(t, true)
hs := &HTTPServer{dashboardsnapshotsService: d} hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshot sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
}, sqlmock) }, sqlmock)
loggedInUserScenarioWithRole(t,
"DELETE /snapshots/{key} should return 403 when snapshot is disabled", "DELETE",
"/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t, false)
hs := buildHttpServer(d, false)
sc.handlerFunc = hs.DeleteDashboardSnapshot
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
assert.Equal(t, http.StatusForbidden, sc.resp.Code)
}, sqlmock)
loggedInUserScenarioWithRole(t, loggedInUserScenarioWithRole(t,
"GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE", "GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE",
"/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t) d := setUpSnapshotTest(t, true)
hs := &HTTPServer{dashboardsnapshotsService: d} hs := buildHttpServer(d, true)
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
}, sqlmock) }, sqlmock)
loggedInUserScenarioWithRole(t,
"GET /snapshots-delete/{deleteKey} should return 403 when snapshot is disabled", "DELETE",
"/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) {
d := setUpSnapshotTest(t, false)
hs := buildHttpServer(d, false)
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
assert.Equal(t, http.StatusForbidden, sc.resp.Code)
}, sqlmock)
}
func buildHttpServer(d dashboardsnapshots.Service, snapshotEnabled bool) *HTTPServer {
hs := &HTTPServer{
dashboardsnapshotsService: d,
Cfg: &setting.Cfg{
SnapshotEnabled: snapshotEnabled,
},
}
return hs
} }

@ -209,6 +209,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"samlEnabled": hs.samlEnabled(), "samlEnabled": hs.samlEnabled(),
"samlName": hs.samlName(), "samlName": hs.samlName(),
"tokenExpirationDayLimit": hs.Cfg.SATokenExpirationDayLimit, "tokenExpirationDayLimit": hs.Cfg.SATokenExpirationDayLimit,
"snapshotEnabled": hs.Cfg.SnapshotEnabled,
} }
if hs.ThumbService != nil { if hs.ThumbService != nil {

@ -15,13 +15,14 @@ import (
type DashboardSnapshotStore struct { type DashboardSnapshotStore struct {
store db.DB store db.DB
log log.Logger log log.Logger
cfg *setting.Cfg
} }
// DashboardStore implements the Store interface // DashboardStore implements the Store interface
var _ dashboardsnapshots.Store = (*DashboardSnapshotStore)(nil) var _ dashboardsnapshots.Store = (*DashboardSnapshotStore)(nil)
func ProvideStore(db db.DB) *DashboardSnapshotStore { func ProvideStore(db db.DB, cfg *setting.Cfg) *DashboardSnapshotStore {
return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store")} return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store"), cfg: cfg}
} }
// DeleteExpiredSnapshots removes snapshots with old expiry dates. // DeleteExpiredSnapshots removes snapshots with old expiry dates.
@ -29,7 +30,7 @@ func ProvideStore(db db.DB) *DashboardSnapshotStore {
// Snapshot expiry is decided by the user when they share the snapshot. // Snapshot expiry is decided by the user when they share the snapshot.
func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd *dashboardsnapshots.DeleteExpiredSnapshotsCommand) error { func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd *dashboardsnapshots.DeleteExpiredSnapshotsCommand) error {
return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if !setting.SnapShotRemoveExpired { if !d.cfg.SnapShotRemoveExpired {
d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
return nil return nil
} }

@ -23,7 +23,7 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
sqlstore := db.InitTestDB(t) sqlstore := db.InitTestDB(t)
dashStore := ProvideStore(sqlstore) dashStore := ProvideStore(sqlstore, setting.NewCfg())
origSecret := setting.SecretKey origSecret := setting.SecretKey
setting.SecretKey = "dashboard_snapshot_testing" setting.SecretKey = "dashboard_snapshot_testing"
@ -154,10 +154,10 @@ func TestIntegrationDeleteExpiredSnapshots(t *testing.T) {
t.Skip("skipping integration test") t.Skip("skipping integration test")
} }
sqlstore := db.InitTestDB(t) sqlstore := db.InitTestDB(t)
dashStore := ProvideStore(sqlstore) dashStore := ProvideStore(sqlstore, setting.NewCfg())
t.Run("Testing dashboard snapshots clean up", func(t *testing.T) { t.Run("Testing dashboard snapshots clean up", func(t *testing.T) {
setting.SnapShotRemoveExpired = true dashStore.cfg.SnapShotRemoveExpired = true
nonExpiredSnapshot := createTestSnapshot(t, dashStore, "key1", 48000) nonExpiredSnapshot := createTestSnapshot(t, dashStore, "key1", 48000)
createTestSnapshot(t, dashStore, "key2", -1200) createTestSnapshot(t, dashStore, "key2", -1200)

@ -17,7 +17,7 @@ import (
func TestDashboardSnapshotsService(t *testing.T) { func TestDashboardSnapshotsService(t *testing.T) {
sqlStore := db.InitTestDB(t) sqlStore := db.InitTestDB(t)
dsStore := dashsnapdb.ProvideStore(sqlStore) dsStore := dashsnapdb.ProvideStore(sqlStore, setting.NewCfg())
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
s := ProvideService(dsStore, secretsService) s := ProvideService(dsStore, secretsService)

@ -376,13 +376,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
}) })
if c.IsSignedIn { if c.IsSignedIn {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ if s.cfg.SnapshotEnabled {
Text: "Snapshots", dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
SubTitle: "Interactive, publically available, point-in-time representations of dashboards", Text: "Snapshots",
Id: "dashboards/snapshots", SubTitle: "Interactive, publically available, point-in-time representations of dashboards",
Url: s.cfg.AppSubURL + "/dashboard/snapshots", Id: "dashboards/snapshots",
Icon: "camera", Url: s.cfg.AppSubURL + "/dashboard/snapshots",
}) Icon: "camera",
})
}
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Library panels", Text: "Library panels",

@ -87,12 +87,6 @@ var (
CookieSameSiteDisabled bool CookieSameSiteDisabled bool
CookieSameSiteMode http.SameSite CookieSameSiteMode http.SameSite
// Snapshots
ExternalSnapshotUrl string
ExternalSnapshotName string
ExternalEnabled bool
SnapShotRemoveExpired bool
// Dashboard history // Dashboard history
DashboardVersionsToKeep int DashboardVersionsToKeep int
MinRefreshInterval string MinRefreshInterval string
@ -407,6 +401,12 @@ type Cfg struct {
DataSourceLimit int DataSourceLimit int
// Snapshots // Snapshots
SnapshotEnabled bool
ExternalSnapshotUrl string
ExternalSnapshotName string
ExternalEnabled bool
SnapShotRemoveExpired bool
SnapshotPublicMode bool SnapshotPublicMode bool
ErrTemplateName string ErrTemplateName string
@ -1702,11 +1702,13 @@ func IsLegacyAlertingEnabled() bool {
func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error { func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error {
snapshots := iniFile.Section("snapshots") snapshots := iniFile.Section("snapshots")
ExternalSnapshotUrl = valueAsString(snapshots, "external_snapshot_url", "") cfg.SnapshotEnabled = snapshots.Key("enabled").MustBool(true)
ExternalSnapshotName = valueAsString(snapshots, "external_snapshot_name", "")
cfg.ExternalSnapshotUrl = valueAsString(snapshots, "external_snapshot_url", "")
cfg.ExternalSnapshotName = valueAsString(snapshots, "external_snapshot_name", "")
ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) cfg.ExternalEnabled = snapshots.Key("external_enabled").MustBool(true)
SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) cfg.SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true)
cfg.SnapshotPublicMode = snapshots.Key("public_mode").MustBool(false) cfg.SnapshotPublicMode = snapshots.Key("public_mode").MustBool(false)
return nil return nil

@ -27,22 +27,11 @@ export function addPanelShareTab(tab: ShareModalTabModel) {
customPanelTabs.push(tab); customPanelTabs.push(tab);
} }
function getInitialState(props: Props): State { function getTabs(panel?: PanelModel, activeTab?: string) {
const { tabs, activeTab } = getTabs(props);
return {
tabs,
activeTab,
};
}
function getTabs(props: Props) {
const { panel, activeTab } = props;
const linkLabel = t('share-modal.tab-title.link', 'Link'); const linkLabel = t('share-modal.tab-title.link', 'Link');
const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: 'link', component: ShareLink }]; const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: 'link', component: ShareLink }];
if (contextSrv.isSignedIn) { if (contextSrv.isSignedIn && config.snapshotEnabled) {
const snapshotLabel = t('share-modal.tab-title.snapshot', 'Snapshot'); const snapshotLabel = t('share-modal.tab-title.snapshot', 'Snapshot');
tabs.push({ label: snapshotLabel, value: 'snapshot', component: ShareSnapshot }); tabs.push({ label: snapshotLabel, value: 'snapshot', component: ShareSnapshot });
} }
@ -87,6 +76,15 @@ interface State {
activeTab: string; activeTab: string;
} }
function getInitialState(props: Props): State {
const { tabs, activeTab } = getTabs(props.panel, props.activeTab);
return {
tabs,
activeTab,
};
}
export class ShareModal extends React.Component<Props, State> { export class ShareModal extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@ -98,13 +96,9 @@ export class ShareModal extends React.Component<Props, State> {
} }
onSelectTab = (t: any) => { onSelectTab = (t: any) => {
this.setState({ activeTab: t.value }); this.setState((prevState) => ({ ...prevState, activeTab: t.value }));
}; };
getTabs() {
return getTabs(this.props).tabs;
}
getActiveTab() { getActiveTab() {
const { tabs, activeTab } = this.state; const { tabs, activeTab } = this.state;
return tabs.find((t) => t.value === activeTab)!; return tabs.find((t) => t.value === activeTab)!;
@ -114,12 +108,13 @@ export class ShareModal extends React.Component<Props, State> {
const { panel } = this.props; const { panel } = this.props;
const { activeTab } = this.state; const { activeTab } = this.state;
const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share');
const tabs = getTabs(this.props.panel, this.state.activeTab).tabs;
return ( return (
<ModalTabsHeader <ModalTabsHeader
title={title} title={title}
icon="share-alt" icon="share-alt"
tabs={this.getTabs()} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
onChangeTab={this.onSelectTab} onChangeTab={this.onSelectTab}
/> />

Loading…
Cancel
Save