소스 검색

Dashboard: When saving a dashboard and another user has made changes inbetween, the user is promted with a warning if he really wants to overwrite the other's changes, Closes #718

Torkel Ödegaard 10 년 전
부모
커밋
04d25dc58a

+ 1 - 0
CHANGELOG.md

@@ -1,6 +1,7 @@
 # 2.0.0 (unreleased)
 
 **New features**
+- [Issue #718](https://github.com/grafana/grafana/issues/718).   Dashboard: When saving a dashboard and another user has made changes inbetween the user is promted with a warning if he really wants to overwrite the other's changes
 - [Issue #1331](https://github.com/grafana/grafana/issues/1331). Graph & Singlestat: New axis/unit format selector and more units (kbytes, Joule, Watt, eV), and new design for graph axis & grid tab and single stat options tab views
 - [Issue #1241](https://github.com/grafana/grafana/issues/1242). Timepicker: New option in timepicker (under dashboard settings), to change ``now`` to be for example ``now-1m``, usefull when you want to ignore last minute because it contains incomplete data
 - [Issue #171](https://github.com/grafana/grafana/issues/171).   Panel: Different time periods, panels can override dashboard relative time and/or add a time shift

+ 6 - 2
pkg/api/dashboard.go

@@ -77,14 +77,18 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) {
 	err := bus.Dispatch(&cmd)
 	if err != nil {
 		if err == m.ErrDashboardWithSameNameExists {
-			c.JsonApiErr(400, "Dashboard with the same title already exists", nil)
+			c.JSON(412, util.DynMap{"status": "name-exists", "message": err.Error()})
+			return
+		}
+		if err == m.ErrDashboardVersionMismatch {
+			c.JSON(412, util.DynMap{"status": "version-mismatch", "message": err.Error()})
 			return
 		}
 		c.JsonApiErr(500, "Failed to save dashboard", err)
 		return
 	}
 
-	c.JSON(200, util.DynMap{"status": "success", "slug": cmd.Result.Slug})
+	c.JSON(200, util.DynMap{"status": "success", "slug": cmd.Result.Slug, "version": cmd.Result.Version})
 }
 
 func GetHomeDashboard(c *middleware.Context) {

+ 7 - 1
pkg/models/dashboards.go

@@ -11,6 +11,7 @@ import (
 var (
 	ErrDashboardNotFound           = errors.New("Account not found")
 	ErrDashboardWithSameNameExists = errors.New("A dashboard with the same name already exists")
+	ErrDashboardVersionMismatch    = errors.New("The dashboard has been changed by someone else")
 )
 
 type Dashboard struct {
@@ -58,6 +59,10 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
 
 	if dash.Data["id"] != nil {
 		dash.Id = int64(dash.Data["id"].(float64))
+
+		if dash.Data["version"] != nil {
+			dash.Version = int(dash.Data["version"].(float64))
+		}
 	}
 
 	return dash
@@ -79,7 +84,8 @@ func (dash *Dashboard) UpdateSlug() {
 //
 
 type SaveDashboardCommand struct {
-	Dashboard map[string]interface{} `json:"dashboard"`
+	Dashboard map[string]interface{} `json:"dashboard" binding:"Required"`
+	Overwrite bool                   `json:"overwrite"`
 	OrgId     int64                  `json:"-"`
 
 	Result *Dashboard

+ 19 - 2
pkg/services/sqlstore/dashboard.go

@@ -28,13 +28,30 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			return err
 		}
 
-		if hasExisting && dash.Id != existing.Id {
-			return m.ErrDashboardWithSameNameExists
+		if hasExisting {
+			// another dashboard with same name
+			if dash.Id != existing.Id {
+				if cmd.Overwrite {
+					dash.Id = existing.Id
+				} else {
+					return m.ErrDashboardWithSameNameExists
+				}
+			}
+			// check for is someone else has written in between
+			if dash.Version != existing.Version {
+				if cmd.Overwrite {
+					dash.Version = existing.Version
+				} else {
+					return m.ErrDashboardVersionMismatch
+				}
+			}
 		}
 
 		if dash.Id == 0 {
 			_, err = sess.Insert(dash)
 		} else {
+			dash.Version += 1
+			dash.Data["version"] = dash.Version
 			_, err = sess.Id(dash.Id).Update(dash)
 		}
 

+ 2 - 7
src/app/features/dashboard/cloneDashboardCtrl.js

@@ -6,26 +6,21 @@ function (angular) {
 
   var module = angular.module('grafana.controllers');
 
-  module.controller('CloneDashboardCtrl', function($scope, datasourceSrv, $location) {
+  module.controller('CloneDashboardCtrl', function($scope, backendSrv, $location) {
 
     $scope.init = function() {
-      $scope.db = datasourceSrv.getGrafanaDB();
       $scope.clone.id = null;
       $scope.clone.editable = true;
       $scope.clone.title = $scope.clone.title + " Copy";
     };
 
     $scope.saveClone = function() {
-      $scope.db.saveDashboard($scope.clone)
+      backendSrv.saveDashboard($scope.clone)
         .then(function(result) {
-
           $scope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + result.title]);
           $location.url(result.url);
           $scope.appEvent('dashboard-saved', $scope.clone);
           $scope.dismiss();
-
-        }, function(err) {
-          $scope.appEvent('alert-error', ['Save failed', err]);
         });
     };
   });

+ 32 - 3
src/app/features/dashboard/dashboardNavCtrl.js

@@ -55,10 +55,11 @@ function (angular, _, moment) {
       $scope.appEvent('hide-dash-editor');
     };
 
-    $scope.saveDashboard = function() {
+    $scope.saveDashboard = function(options) {
       var clone = angular.copy($scope.dashboard);
 
-      backendSrv.saveDashboard(clone).then(function(data) {
+      backendSrv.saveDashboard(clone, options).then(function(data) {
+        $scope.dashboard.version = data.version;
         $scope.appEvent('dashboard-saved', $scope.dashboard);
 
         var dashboardUrl = '/dashboard/db/' + data.slug;
@@ -68,7 +69,35 @@ function (angular, _, moment) {
         }
 
         $scope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]);
-      });
+      }, $scope.handleSaveDashError);
+    };
+
+    $scope.handleSaveDashError = function(err) {
+      if (err.data && err.data.status === "version-mismatch" ) {
+        err.isHandled = true;
+
+        $scope.appEvent('confirm-modal', {
+          title: 'Someone else has updated this dashboard!',
+          text: "Do you STILL want to save?",
+          icon: "fa-warning",
+          onConfirm: function() {
+            $scope.saveDashboard({overwrite: true});
+          }
+        });
+      }
+
+      if (err.data && err.data.status === "name-exists" ) {
+        err.isHandled = true;
+
+        $scope.appEvent('confirm-modal', {
+          title: 'Another dashboard with the same name exists',
+          text: "Do you STILL want to save and ovewrite it?",
+          icon: "fa-warning",
+          onConfirm: function() {
+            $scope.saveDashboard({overwrite: true});
+          }
+        });
+      }
     };
 
     $scope.deleteDashboard = function() {

+ 7 - 3
src/app/features/dashboard/dashboardSrv.js

@@ -18,6 +18,10 @@ function (angular, $, kbn, _, moment) {
         data = {};
       }
 
+      if (!data.id && data.version) {
+        data.schemaVersion = data.version;
+      }
+
       this.id = data.id || null;
       this.title = data.title || 'No Title';
       this.originalTitle = this.title;
@@ -33,8 +37,8 @@ function (angular, $, kbn, _, moment) {
       this.templating = this._ensureListExist(data.templating);
       this.annotations = this._ensureListExist(data.annotations);
       this.refresh = data.refresh;
+      this.schemaVersion = data.schemaVersion || 0;
       this.version = data.version || 0;
-      this.hideAllLegends = data.hideAllLegends || false;
 
       if (this.nav.length === 0) {
         this.nav.push({ type: 'timepicker' });
@@ -134,9 +138,9 @@ function (angular, $, kbn, _, moment) {
 
     p._updateSchema = function(old) {
       var i, j, k;
-      var oldVersion = this.version;
+      var oldVersion = this.schemaVersion;
       var panelUpgrades = [];
-      this.version = 6;
+      this.schemaVersion = 6;
 
       if (oldVersion === 6) {
         return;

+ 2 - 0
src/app/features/dashboard/unsavedChangesSrv.js

@@ -83,6 +83,8 @@ function(angular, _, config) {
       // ignore timespan changes
       current.time = original.time = {};
       current.refresh = original.refresh;
+      // ignore version
+      current.version = original.version;
 
       // ignore template variable values
       _.each(current.templating.list, function(value, index) {

+ 1 - 1
src/app/partials/confirm_modal.html

@@ -1,7 +1,7 @@
 <div class="modal-body gf-box gf-box-no-margin">
 	<div class="gf-box-header">
 		<div class="gf-box-title">
-			<i class="fa fa-check"></i>
+			<i class="fa {{icon}}"></i>
 			{{title}}
 		</div>
 	</div>

+ 116 - 0
src/app/routes/all.js

@@ -0,0 +1,116 @@
+define([
+  'angular',
+  './dashLoadControllers',
+], function(angular) {
+  "use strict";
+
+  var module = angular.module('grafana.routes');
+
+  module.config(function($routeProvider, $locationProvider) {
+    $locationProvider.html5Mode(true);
+
+    $routeProvider
+      .when('/', {
+        templateUrl: 'app/partials/dashboard.html',
+        controller : 'DashFromDBCtrl',
+        reloadOnSearch: false,
+      })
+      .when('/dashboard/db/:slug', {
+        templateUrl: 'app/partials/dashboard.html',
+        controller : 'DashFromDBCtrl',
+        reloadOnSearch: false,
+      })
+      .when('/dashboard/file/:jsonFile', {
+        templateUrl: 'app/partials/dashboard.html',
+        controller : 'DashFromFileCtrl',
+        reloadOnSearch: false,
+      })
+      .when('/dashboard/script/:jsFile', {
+        templateUrl: 'app/partials/dashboard.html',
+        controller : 'DashFromScriptCtrl',
+        reloadOnSearch: false,
+      })
+      .when('/dashboard/import/:file', {
+        templateUrl: 'app/partials/dashboard.html',
+        controller : 'DashFromImportCtrl',
+        reloadOnSearch: false,
+      })
+      .when('/dashboard/new', {
+        templateUrl: 'app/partials/dashboard.html',
+        controller : 'NewDashboardCtrl',
+        reloadOnSearch: false,
+      })
+      .when('/dashboard/import', {
+        templateUrl: 'app/features/dashboard/partials/import.html',
+        controller : 'DashboardImportCtrl',
+      })
+      .when('/datasources', {
+        templateUrl: 'app/features/org/partials/datasources.html',
+        controller : 'DataSourcesCtrl',
+      })
+      .when('/datasources/edit/:id', {
+        templateUrl: 'app/features/org/partials/datasourceEdit.html',
+        controller : 'DataSourceEditCtrl',
+      })
+      .when('/datasources/new', {
+        templateUrl: 'app/features/org/partials/datasourceEdit.html',
+        controller : 'DataSourceEditCtrl',
+      })
+      .when('/org', {
+        templateUrl: 'app/features/org/partials/orgDetails.html',
+        controller : 'OrgDetailsCtrl',
+      })
+      .when('/org/new', {
+        templateUrl: 'app/features/org/partials/newOrg.html',
+        controller : 'NewOrgCtrl',
+      })
+      .when('/org/users', {
+        templateUrl: 'app/features/org/partials/orgUsers.html',
+        controller : 'OrgUsersCtrl',
+      })
+      .when('/org/apikeys', {
+        templateUrl: 'app/features/org/partials/orgApiKeys.html',
+        controller : 'OrgApiKeysCtrl',
+      })
+      .when('/profile', {
+        templateUrl: 'app/features/profile/partials/profile.html',
+        controller : 'ProfileCtrl',
+      })
+      .when('/profile/password', {
+        templateUrl: 'app/features/profile/partials/password.html',
+        controller : 'ChangePasswordCtrl',
+      })
+      .when('/admin/settings', {
+        templateUrl: 'app/features/admin/partials/settings.html',
+        controller : 'AdminSettingsCtrl',
+      })
+      .when('/admin/users', {
+        templateUrl: 'app/features/admin/partials/users.html',
+        controller : 'AdminUsersCtrl',
+      })
+      .when('/admin/users/create', {
+        templateUrl: 'app/features/admin/partials/new_user.html',
+        controller : 'AdminEditUserCtrl',
+      })
+      .when('/admin/users/edit/:id', {
+        templateUrl: 'app/features/admin/partials/edit_user.html',
+        controller : 'AdminEditUserCtrl',
+      })
+      .when('/admin/orgs', {
+        templateUrl: 'app/features/admin/partials/orgs.html',
+      })
+      .when('/login', {
+        templateUrl: 'app/partials/login.html',
+        controller : 'LoginCtrl',
+      })
+      .when('/dashboard/solo/:slug/', {
+        templateUrl: 'app/features/panel/partials/soloPanel.html',
+        controller : 'SoloPanelCtrl',
+      })
+      .otherwise({
+        templateUrl: 'app/partials/error.html',
+        controller: 'ErrorCtrl'
+      });
+  });
+
+});

+ 126 - 0
src/app/routes/dashLoadControllers.js

@@ -0,0 +1,126 @@
+define([
+  'angular',
+  'lodash',
+  'kbn',
+  'moment',
+  'jquery',
+],
+function (angular, _, kbn, moment, $) {
+  "use strict";
+
+  var module = angular.module('grafana.routes');
+
+  module.controller('DashFromDBCtrl', function($scope, $routeParams, backendSrv) {
+
+    if (!$routeParams.slug) {
+      backendSrv.get('/api/dashboards/home').then(function(result) {
+        $scope.initDashboard(result, $scope);
+      },function() {
+        $scope.initDashboard({}, $scope);
+        $scope.appEvent('alert-error', ['Load dashboard failed', '']);
+      });
+
+      return;
+    }
+
+    return backendSrv.getDashboard($routeParams.slug).then(function(result) {
+      $scope.initDashboard(result, $scope);
+    }, function() {
+      $scope.initDashboard({
+        meta: {},
+        model: { title: 'Not found' }
+      }, $scope);
+    });
+  });
+
+  module.controller('DashFromImportCtrl', function($scope, $location, alertSrv) {
+    if (!window.grafanaImportDashboard) {
+      alertSrv.set('Not found', 'Cannot reload page with unsaved imported dashboard', 'warning', 7000);
+      $location.path('');
+      return;
+    }
+    $scope.initDashboard({ meta: {}, model: window.grafanaImportDashboard }, $scope);
+  });
+
+  module.controller('NewDashboardCtrl', function($scope) {
+    $scope.initDashboard({
+      meta: {},
+      model: {
+        title: "New dashboard",
+        rows: [{ height: '250px', panels:[] }]
+      },
+    }, $scope);
+  });
+
+  module.controller('DashFromFileCtrl', function($scope, $rootScope, $http, $routeParams) {
+
+    var file_load = function(file) {
+      return $http({
+        url: "public/dashboards/"+file.replace(/\.(?!json)/,"/")+'?' + new Date().getTime(),
+        method: "GET",
+        transformResponse: function(response) {
+          return angular.fromJson(response);
+        }
+      }).then(function(result) {
+        if(!result) {
+          return false;
+        }
+        return result.data;
+      },function() {
+        $scope.appEvent('alert-error', ["Dashboard load failed", "Could not load <i>dashboards/"+file+"</i>. Please make sure it exists"]);
+        return false;
+      });
+    };
+
+    file_load($routeParams.jsonFile).then(function(result) {
+      $scope.initDashboard({meta: {}, model: result}, $scope);
+    });
+
+  });
+
+  module.controller('DashFromScriptCtrl', function($scope, $rootScope, $http, $routeParams, $q, dashboardSrv, datasourceSrv, $timeout) {
+
+    var execute_script = function(result) {
+      var services = {
+        dashboardSrv: dashboardSrv,
+        datasourceSrv: datasourceSrv,
+        $q: $q,
+      };
+
+      /*jshint -W054 */
+      var script_func = new Function('ARGS','kbn','_','moment','window','document','$','jQuery', 'services', result.data);
+      var script_result = script_func($routeParams, kbn, _ , moment, window, document, $, $, services);
+
+      // Handle async dashboard scripts
+      if (_.isFunction(script_result)) {
+        var deferred = $q.defer();
+        script_result(function(dashboard) {
+          $timeout(function() {
+            deferred.resolve({ data: dashboard });
+          });
+        });
+        return deferred.promise;
+      }
+
+      return { data: script_result };
+    };
+
+    var script_load = function(file) {
+      var url = 'public/dashboards/'+file.replace(/\.(?!js)/,"/") + '?' + new Date().getTime();
+
+      return $http({ url: url, method: "GET" })
+        .then(execute_script)
+        .then(null,function(err) {
+          console.log('Script dashboard error '+ err);
+          $scope.appEvent('alert-error', ["Script Error", "Please make sure it exists and returns a valid dashboard"]);
+          return false;
+        });
+    };
+
+    script_load($routeParams.jsFile).then(function(result) {
+      $scope.initDashboard({meta: {}, model: result.data}, $scope);
+    });
+
+  });
+
+});

+ 1 - 0
src/app/services/alertSrv.js

@@ -64,6 +64,7 @@ function (angular, _) {
       scope.title = payload.title;
       scope.text = payload.text;
       scope.onConfirm = payload.onConfirm;
+      scope.icon = payload.icon || "fa-check";
 
       var confirmModal = $modal({
         template: './app/partials/confirm_modal.html',

+ 3 - 2
src/app/services/backendSrv.js

@@ -85,8 +85,9 @@ function (angular, _, config) {
       return this.get('/api/dashboards/db/' + slug);
     };
 
-    this.saveDashboard = function(dash) {
-      return this.post('/api/dashboards/db/', {dashboard: dash});
+    this.saveDashboard = function(dash, options) {
+      options = (options || {});
+      return this.post('/api/dashboards/db/', {dashboard: dash, overwrite: options.overwrite === true});
     };
 
   });

+ 1 - 1
src/test/specs/dashboardSrv-specs.js

@@ -161,7 +161,7 @@ define([
     });
 
     it('dashboard schema version should be set to latest', function() {
-      expect(model.version).to.be(6);
+      expect(model.schemaVersion).to.be(6);
     });
 
   });