Просмотр исходного кода

Merge pull request #11398 from bergquist/readonly_dashboards

improved workflow for provisioned dashboards
Marcus Efraimsson 7 лет назад
Родитель
Сommit
c120415406

+ 12 - 1
pkg/api/dashboard.go

@@ -102,6 +102,16 @@ func GetDashboard(c *m.ReqContext) Response {
 		meta.FolderUrl = query.Result.GetUrl()
 	}
 
+	isDashboardProvisioned := &m.IsDashboardProvisionedQuery{DashboardId: dash.Id}
+	err = bus.Dispatch(isDashboardProvisioned)
+	if err != nil {
+		return Error(500, "Error while checking if dashboard is provisioned", err)
+	}
+
+	if isDashboardProvisioned.Result {
+		meta.Provisioned = true
+	}
+
 	// make sure db version is in sync with json model version
 	dash.Data.Set("version", dash.Version)
 
@@ -228,7 +238,8 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response {
 		err == m.ErrDashboardWithSameUIDExists ||
 		err == m.ErrFolderNotFound ||
 		err == m.ErrDashboardFolderCannotHaveParent ||
-		err == m.ErrDashboardFolderNameExists {
+		err == m.ErrDashboardFolderNameExists ||
+		err == m.ErrDashboardCannotSaveProvisionedDashboard {
 		return Error(400, err.Error(), nil)
 	}
 

+ 21 - 0
pkg/api/dashboard_test.go

@@ -42,6 +42,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			return nil
 		})
 
+		bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
+			query.Result = false
+			return nil
+		})
+
 		viewerRole := m.ROLE_VIEWER
 		editorRole := m.ROLE_EDITOR
 
@@ -192,6 +197,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		fakeDash.HasAcl = true
 		setting.ViewersCanEdit = false
 
+		bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
+			query.Result = false
+			return nil
+		})
+
 		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
 			dashboards := []*m.Dashboard{fakeDash}
 			query.Result = dashboards
@@ -625,6 +635,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		dashTwo.FolderId = 3
 		dashTwo.HasAcl = false
 
+		bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
+			query.Result = false
+			return nil
+		})
+
 		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
 			dashboards := []*m.Dashboard{dashOne, dashTwo}
 			query.Result = dashboards
@@ -720,6 +735,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				{SaveError: m.ErrDashboardUpdateAccessDenied, ExpectedStatusCode: 403},
 				{SaveError: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400},
 				{SaveError: m.ErrDashboardUidToLong, ExpectedStatusCode: 400},
+				{SaveError: m.ErrDashboardCannotSaveProvisionedDashboard, ExpectedStatusCode: 400},
 				{SaveError: m.UpdatePluginDashboardError{PluginId: "plug"}, ExpectedStatusCode: 412},
 			}
 
@@ -750,6 +766,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			return nil
 		})
 
+		bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
+			query.Result = false
+			return nil
+		})
+
 		bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
 			query.Result = &m.DashboardVersion{
 				Data: simplejson.NewFromAny(map[string]interface{}{

+ 1 - 0
pkg/api/dtos/dashboard.go

@@ -28,6 +28,7 @@ type DashboardMeta struct {
 	FolderId    int64     `json:"folderId"`
 	FolderTitle string    `json:"folderTitle"`
 	FolderUrl   string    `json:"folderUrl"`
+	Provisioned bool      `json:"provisioned"`
 }
 
 type DashboardFullWithMeta struct {

+ 27 - 20
pkg/models/dashboards.go

@@ -13,26 +13,27 @@ import (
 
 // Typed errors
 var (
-	ErrDashboardNotFound                      = errors.New("Dashboard not found")
-	ErrDashboardFolderNotFound                = errors.New("Folder not found")
-	ErrDashboardSnapshotNotFound              = errors.New("Dashboard snapshot not found")
-	ErrDashboardWithSameUIDExists             = errors.New("A dashboard with the same uid already exists")
-	ErrDashboardWithSameNameInFolderExists    = errors.New("A dashboard with the same name in the folder already exists")
-	ErrDashboardVersionMismatch               = errors.New("The dashboard has been changed by someone else")
-	ErrDashboardTitleEmpty                    = errors.New("Dashboard title cannot be empty")
-	ErrDashboardFolderCannotHaveParent        = errors.New("A Dashboard Folder cannot be added to another folder")
-	ErrDashboardContainsInvalidAlertData      = errors.New("Invalid alert data. Cannot save dashboard")
-	ErrDashboardFailedToUpdateAlertData       = errors.New("Failed to save alert data")
-	ErrDashboardsWithSameSlugExists           = errors.New("Multiple dashboards with the same slug exists")
-	ErrDashboardFailedGenerateUniqueUid       = errors.New("Failed to generate unique dashboard id")
-	ErrDashboardTypeMismatch                  = errors.New("Dashboard cannot be changed to a folder")
-	ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards")
-	ErrDashboardWithSameNameAsFolder          = errors.New("Dashboard name cannot be the same as folder")
-	ErrDashboardFolderNameExists              = errors.New("A folder with that name already exists")
-	ErrDashboardUpdateAccessDenied            = errors.New("Access denied to save dashboard")
-	ErrDashboardInvalidUid                    = errors.New("uid contains illegal characters")
-	ErrDashboardUidToLong                     = errors.New("uid to long. max 40 characters")
-	RootFolderName                            = "General"
+	ErrDashboardNotFound                       = errors.New("Dashboard not found")
+	ErrDashboardFolderNotFound                 = errors.New("Folder not found")
+	ErrDashboardSnapshotNotFound               = errors.New("Dashboard snapshot not found")
+	ErrDashboardWithSameUIDExists              = errors.New("A dashboard with the same uid already exists")
+	ErrDashboardWithSameNameInFolderExists     = errors.New("A dashboard with the same name in the folder already exists")
+	ErrDashboardVersionMismatch                = errors.New("The dashboard has been changed by someone else")
+	ErrDashboardTitleEmpty                     = errors.New("Dashboard title cannot be empty")
+	ErrDashboardFolderCannotHaveParent         = errors.New("A Dashboard Folder cannot be added to another folder")
+	ErrDashboardContainsInvalidAlertData       = errors.New("Invalid alert data. Cannot save dashboard")
+	ErrDashboardFailedToUpdateAlertData        = errors.New("Failed to save alert data")
+	ErrDashboardsWithSameSlugExists            = errors.New("Multiple dashboards with the same slug exists")
+	ErrDashboardFailedGenerateUniqueUid        = errors.New("Failed to generate unique dashboard id")
+	ErrDashboardTypeMismatch                   = errors.New("Dashboard cannot be changed to a folder")
+	ErrDashboardFolderWithSameNameAsDashboard  = errors.New("Folder name cannot be the same as one of its dashboards")
+	ErrDashboardWithSameNameAsFolder           = errors.New("Dashboard name cannot be the same as folder")
+	ErrDashboardFolderNameExists               = errors.New("A folder with that name already exists")
+	ErrDashboardUpdateAccessDenied             = errors.New("Access denied to save dashboard")
+	ErrDashboardInvalidUid                     = errors.New("uid contains illegal characters")
+	ErrDashboardUidToLong                      = errors.New("uid to long. max 40 characters")
+	ErrDashboardCannotSaveProvisionedDashboard = errors.New("Cannot save provisioned dashboard")
+	RootFolderName                             = "General"
 )
 
 type UpdatePluginDashboardError struct {
@@ -322,6 +323,12 @@ type GetDashboardSlugByIdQuery struct {
 	Result string
 }
 
+type IsDashboardProvisionedQuery struct {
+	DashboardId int64
+
+	Result bool
+}
+
 type GetProvisionedDashboardDataQuery struct {
 	Name string
 

+ 18 - 5
pkg/services/dashboards/dashboard_service.go

@@ -57,7 +57,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod
 	return cmd.Result, nil
 }
 
-func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) {
+func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) {
 	dash := dto.Dashboard
 
 	dash.Title = strings.TrimSpace(dash.Title)
@@ -113,6 +113,19 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
 		}
 	}
 
+	if validateProvisionedDashboard {
+		isDashboardProvisioned := &models.IsDashboardProvisionedQuery{DashboardId: dash.Id}
+		err := bus.Dispatch(isDashboardProvisioned)
+
+		if err != nil {
+			return nil, err
+		}
+
+		if isDashboardProvisioned.Result {
+			return nil, models.ErrDashboardCannotSaveProvisionedDashboard
+		}
+	}
+
 	guard := guardian.New(dash.GetDashboardIdForSavePermissionCheck(), dto.OrgId, dto.User)
 	if canSave, err := guard.CanSave(); err != nil || !canSave {
 		if err != nil {
@@ -158,7 +171,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto, true)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true, false)
 	if err != nil {
 		return nil, err
 	}
@@ -188,7 +201,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto, false)
+	cmd, err := dr.buildSaveDashboardCommand(dto, false, false)
 	if err != nil {
 		return nil, err
 	}
@@ -207,7 +220,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 }
 
 func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
-	cmd, err := dr.buildSaveDashboardCommand(dto, true)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true, true)
 	if err != nil {
 		return nil, err
 	}
@@ -226,7 +239,7 @@ func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Da
 }
 
 func (dr *dashboardServiceImpl) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
-	cmd, err := dr.buildSaveDashboardCommand(dto, false)
+	cmd, err := dr.buildSaveDashboardCommand(dto, false, true)
 	if err != nil {
 		return nil, err
 	}

+ 113 - 2
pkg/services/dashboards/dashboard_service_test.go

@@ -14,7 +14,9 @@ import (
 
 func TestDashboardService(t *testing.T) {
 	Convey("Dashboard service tests", t, func() {
-		service := dashboardServiceImpl{}
+		bus.ClearBusHandlers()
+
+		service := &dashboardServiceImpl{}
 
 		origNewDashboardGuardian := guardian.New
 		guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
@@ -55,6 +57,11 @@ func TestDashboardService(t *testing.T) {
 					return nil
 				})
 
+				bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
+					cmd.Result = false
+					return nil
+				})
+
 				testCases := []struct {
 					Uid   string
 					Error error
@@ -73,12 +80,42 @@ func TestDashboardService(t *testing.T) {
 					dto.Dashboard.SetUid(tc.Uid)
 					dto.User = &models.SignedInUser{}
 
-					_, err := service.buildSaveDashboardCommand(dto, true)
+					_, err := service.buildSaveDashboardCommand(dto, true, false)
 					So(err, ShouldEqual, tc.Error)
 				}
 			})
 
+			Convey("Should return validation error if dashboard is provisioned", func() {
+				provisioningValidated := false
+				bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
+					provisioningValidated = true
+					cmd.Result = true
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
+					cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
+					return nil
+				})
+
+				dto.Dashboard = models.NewDashboard("Dash")
+				dto.Dashboard.SetId(3)
+				dto.User = &models.SignedInUser{UserId: 1}
+				_, err := service.SaveDashboard(dto)
+				So(provisioningValidated, ShouldBeTrue)
+				So(err, ShouldEqual, models.ErrDashboardCannotSaveProvisionedDashboard)
+			})
+
 			Convey("Should return validation error if alert data is invalid", func() {
+				bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
+					cmd.Result = false
+					return nil
+				})
+
 				bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
 					return errors.New("error")
 				})
@@ -89,6 +126,80 @@ func TestDashboardService(t *testing.T) {
 			})
 		})
 
+		Convey("Save provisioned dashboard validation", func() {
+			dto := &SaveDashboardDTO{}
+
+			Convey("Should not return validation error if dashboard is provisioned", func() {
+				provisioningValidated := false
+				bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
+					provisioningValidated = true
+					cmd.Result = true
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
+					cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.SaveProvisionedDashboardCommand) error {
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error {
+					return nil
+				})
+
+				dto.Dashboard = models.NewDashboard("Dash")
+				dto.Dashboard.SetId(3)
+				dto.User = &models.SignedInUser{UserId: 1}
+				_, err := service.SaveProvisionedDashboard(dto, nil)
+				So(err, ShouldBeNil)
+				So(provisioningValidated, ShouldBeFalse)
+			})
+		})
+
+		Convey("Import dashboard validation", func() {
+			dto := &SaveDashboardDTO{}
+
+			Convey("Should return validation error if dashboard is provisioned", func() {
+				provisioningValidated := false
+				bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
+					provisioningValidated = true
+					cmd.Result = true
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
+					cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.SaveProvisionedDashboardCommand) error {
+					return nil
+				})
+
+				bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error {
+					return nil
+				})
+
+				dto.Dashboard = models.NewDashboard("Dash")
+				dto.Dashboard.SetId(3)
+				dto.User = &models.SignedInUser{UserId: 1}
+				_, err := service.ImportDashboard(dto)
+				So(provisioningValidated, ShouldBeTrue)
+				So(err, ShouldEqual, models.ErrDashboardCannotSaveProvisionedDashboard)
+			})
+		})
+
 		Reset(func() {
 			guardian.New = origNewDashboardGuardian
 		})

+ 2 - 2
pkg/services/dashboards/folder_service.go

@@ -104,7 +104,7 @@ func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) er
 		User:      dr.user,
 	}
 
-	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
+	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false, false)
 	if err != nil {
 		return toFolderError(err)
 	}
@@ -141,7 +141,7 @@ func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.Upd
 		Overwrite: cmd.Overwrite,
 	}
 
-	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
+	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false, false)
 	if err != nil {
 		return toFolderError(err)
 	}

+ 9 - 0
pkg/services/dashboards/folder_service_test.go

@@ -110,11 +110,19 @@ func TestFolderService(t *testing.T) {
 				return nil
 			})
 
+			provisioningValidated := false
+
+			bus.AddHandler("test", func(query *models.IsDashboardProvisionedQuery) error {
+				provisioningValidated = true
+				return nil
+			})
+
 			Convey("When creating folder should not return access denied error", func() {
 				err := service.CreateFolder(&models.CreateFolderCommand{
 					Title: "Folder",
 				})
 				So(err, ShouldBeNil)
+				So(provisioningValidated, ShouldBeFalse)
 			})
 
 			Convey("When updating folder should not return access denied error", func() {
@@ -123,6 +131,7 @@ func TestFolderService(t *testing.T) {
 					Title: "Folder",
 				})
 				So(err, ShouldBeNil)
+				So(provisioningValidated, ShouldBeFalse)
 			})
 
 			Convey("When deleting folder by uid should not return access denied error", func() {

+ 0 - 3
pkg/services/provisioning/dashboards/types.go

@@ -55,9 +55,6 @@ func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *Das
 	dash.OrgId = cfg.OrgId
 	dash.Dashboard.OrgId = cfg.OrgId
 	dash.Dashboard.FolderId = folderId
-	if !cfg.Editable {
-		dash.Dashboard.Data.Set("editable", cfg.Editable)
-	}
 
 	if dash.Dashboard.Title == "" {
 		return nil, models.ErrDashboardTitleEmpty

+ 14 - 0
pkg/services/sqlstore/dashboard_provisioning.go

@@ -8,6 +8,7 @@ import (
 func init() {
 	bus.AddHandler("sql", GetProvisionedDashboardDataQuery)
 	bus.AddHandler("sql", SaveProvisionedDashboard)
+	bus.AddHandler("sql", GetProvisionedDataByDashboardId)
 }
 
 type DashboardExtras struct {
@@ -17,6 +18,19 @@ type DashboardExtras struct {
 	Value       string
 }
 
+func GetProvisionedDataByDashboardId(cmd *models.IsDashboardProvisionedQuery) error {
+	result := &models.DashboardProvisioning{}
+
+	exist, err := x.Where("dashboard_id = ?", cmd.DashboardId).Get(result)
+	if err != nil {
+		return err
+	}
+
+	cmd.Result = exist
+
+	return nil
+}
+
 func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 		err := saveDashboard(sess, cmd.DashboardCmd)

+ 17 - 0
pkg/services/sqlstore/dashboard_provisioning_test.go

@@ -50,6 +50,23 @@ func TestDashboardProvisioningTest(t *testing.T) {
 				So(query.Result[0].DashboardId, ShouldEqual, dashId)
 				So(query.Result[0].Updated, ShouldEqual, now.Unix())
 			})
+
+			Convey("Can query for one provisioned dashboard", func() {
+				query := &models.IsDashboardProvisionedQuery{DashboardId: cmd.Result.Id}
+
+				err := GetProvisionedDataByDashboardId(query)
+				So(err, ShouldBeNil)
+
+				So(query.Result, ShouldBeTrue)
+			})
+
+			Convey("Can query for none provisioned dashboard", func() {
+				query := &models.IsDashboardProvisionedQuery{DashboardId: 3000}
+
+				err := GetProvisionedDataByDashboardId(query)
+				So(err, ShouldBeNil)
+				So(query.Result, ShouldBeFalse)
+			})
 		})
 	})
 }

+ 5 - 1
pkg/services/sqlstore/dashboard_service_integration_test.go

@@ -19,7 +19,6 @@ func TestIntegratedDashboardService(t *testing.T) {
 		var testOrgId int64 = 1
 
 		Convey("Given saved folders and dashboards in organization A", func() {
-
 			bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
 				return nil
 			})
@@ -28,6 +27,11 @@ func TestIntegratedDashboardService(t *testing.T) {
 				return nil
 			})
 
+			bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
+				cmd.Result = false
+				return nil
+			})
+
 			savedFolder := saveTestFolder("Saved folder", testOrgId)
 			savedDashInFolder := saveTestDashboard("Saved dash in folder", testOrgId, savedFolder.Id)
 			saveTestDashboard("Other saved dash in folder", testOrgId, savedFolder.Id)

+ 1 - 0
public/app/features/dashboard/all.ts

@@ -6,6 +6,7 @@ import './dashnav/dashnav';
 import './submenu/submenu';
 import './save_as_modal';
 import './save_modal';
+import './save_provisioned_modal';
 import './shareModalCtrl';
 import './share_snapshot_ctrl';
 import './dashboard_srv';

+ 10 - 0
public/app/features/dashboard/dashboard_srv.ts

@@ -105,6 +105,10 @@ export class DashboardSrv {
       this.setCurrent(this.create(clone, this.dash.meta));
     }
 
+    if (this.dash.meta.provisioned) {
+      return this.showDashboardProvisionedModal();
+    }
+
     if (!this.dash.meta.canSave && options.makeEditable !== true) {
       return Promise.resolve();
     }
@@ -120,6 +124,12 @@ export class DashboardSrv {
     return this.save(this.dash.getSaveModelClone(), options);
   }
 
+  showDashboardProvisionedModal() {
+    this.$rootScope.appEvent('show-modal', {
+      templateHtml: '<save-provisioned-dashboard-modal dismiss="dismiss()"></save-provisioned-dashboard-modal>',
+    });
+  }
+
   showSaveAsModal() {
     this.$rootScope.appEvent('show-modal', {
       templateHtml: '<save-dashboard-as-modal dismiss="dismiss()"></save-dashboard-as-modal>',

+ 77 - 0
public/app/features/dashboard/save_provisioned_modal.ts

@@ -0,0 +1,77 @@
+import angular from 'angular';
+import { saveAs } from 'file-saver';
+import coreModule from 'app/core/core_module';
+
+const template = `
+<div class="modal-body">
+  <div class="modal-header">
+    <h2 class="modal-header-title">
+      <i class="fa fa-save"></i><span class="p-l-1">Cannot save provisioned dashboard</span>
+    </h2>
+
+    <a class="modal-header-close" ng-click="ctrl.dismiss();">
+      <i class="fa fa-remove"></i>
+    </a>
+  </div>
+
+  <div class="modal-content">
+    <small>
+      This dashboard cannot be saved from Grafana's UI since it has been provisioned from another source.
+      Copy the JSON or save it to a file below. Then you can update your dashboard in corresponding provisioning source.<br/>
+      <i>See <a class="external-link" href="http://docs.grafana.org/administration/provisioning/#dashboards" target="_blank">
+      documentation</a> for more information about provisioning.</i>
+    </small>
+    <div class="p-t-2">
+      <div class="gf-form">
+        <code-editor content="ctrl.dashboardJson" data-mode="json" data-max-lines=15></code-editor>
+      </div>
+      <div class="gf-form-button-row">
+        <button class="btn btn-success" clipboard-button="ctrl.getJsonForClipboard()">
+          <i class="fa fa-clipboard"></i>&nbsp;Copy JSON to Clipboard
+        </button>
+        <button class="btn btn-secondary" clipboard-button="ctrl.save()">
+          <i class="fa fa-save"></i>&nbsp;Save JSON to file
+        </button>
+        <a class="btn btn-link" ng-click="ctrl.dismiss();">Cancel</a>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
+export class SaveProvisionedDashboardModalCtrl {
+  dash: any;
+  dashboardJson: string;
+  dismiss: () => void;
+
+  /** @ngInject */
+  constructor(dashboardSrv) {
+    this.dash = dashboardSrv.getCurrent().getSaveModelClone();
+    delete this.dash.id;
+    this.dashboardJson = JSON.stringify(this.dash, null, 2);
+  }
+
+  save() {
+    var blob = new Blob([angular.toJson(this.dash, true)], {
+      type: 'application/json;charset=utf-8',
+    });
+    saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json');
+  }
+
+  getJsonForClipboard() {
+    return this.dashboardJson;
+  }
+}
+
+export function saveProvisionedDashboardModalDirective() {
+  return {
+    restrict: 'E',
+    template: template,
+    controller: SaveProvisionedDashboardModalCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: { dismiss: '&' },
+  };
+}
+
+coreModule.directive('saveProvisionedDashboardModal', saveProvisionedDashboardModalDirective);

+ 30 - 0
public/app/features/dashboard/specs/save_provisioned_modal.jest.ts

@@ -0,0 +1,30 @@
+import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal';
+
+describe('SaveProvisionedDashboardModalCtrl', () => {
+  var json = {
+    title: 'name',
+    id: 5,
+  };
+
+  var mockDashboardSrv = {
+    getCurrent: function() {
+      return {
+        id: 5,
+        meta: {},
+        getSaveModelClone: function() {
+          return json;
+        },
+      };
+    },
+  };
+
+  var ctrl = new SaveProvisionedDashboardModalCtrl(mockDashboardSrv);
+
+  it('should remove id from dashboard model', () => {
+    expect(ctrl.dash.id).toBeUndefined();
+  });
+
+  it('should remove id from dashboard model in clipboard json', () => {
+    expect(ctrl.getJsonForClipboard()).toBe(JSON.stringify({ title: 'name' }, null, 2));
+  });
+});