Browse Source

refactor(): dashboard row model and hunting down memory leak

Torkel Ödegaard 9 years ago
parent
commit
4d420a0c33

+ 2 - 1
public/app/core/components/info_popover.ts

@@ -44,8 +44,9 @@ export function infoPopover() {
           }
         });
 
-        scope.$on('$destroy', function() {
+        var unbind = scope.$on('$destroy', function() {
           drop.destroy();
+          unbind();
         });
 
       });

+ 4 - 0
public/app/core/core.ts

@@ -42,6 +42,8 @@ import './filters/filters';
 import coreModule from './core_module';
 import appEvents from './app_events';
 import colors from './utils/colors';
+import {assignModelProperties} from './utils/model_utils';
+import {contextSrv} from './services/context_srv';
 
 
 export {
@@ -62,4 +64,6 @@ export {
   queryPartEditorDirective,
   WizardFlow,
   colors,
+  assignModelProperties,
+  contextSrv,
 };

+ 6 - 1
public/app/core/utils/emitter.ts

@@ -23,12 +23,17 @@ export class Emitter {
     this.emitter.on(name, handler);
 
     if (scope) {
-      scope.$on('$destroy', () => {
+      var unbind = scope.$on('$destroy', () => {
         this.emitter.off(name, handler);
+        unbind();
       });
     }
   }
 
+  removeAllListeners(evt?) {
+    this.emitter.removeAllListeners(evt);
+  }
+
   off(name, handler) {
     this.emitter.off(name, handler);
   }

+ 10 - 0
public/app/core/utils/model_utils.ts

@@ -0,0 +1,10 @@
+export function assignModelProperties(target, source, defaults) {
+  for (var key in defaults) {
+    if (!defaults.hasOwnProperty(key)) {
+      continue;
+    }
+
+    target[key] = source[key] === undefined ? defaults[key] : source[key];
+  }
+}
+

+ 3 - 2
public/app/features/alerting/alert_tab_ctrl.ts

@@ -52,11 +52,12 @@ export class AlertTabCtrl {
     var thresholdChangedEventHandler = this.graphThresholdChanged.bind(this);
     this.panelCtrl.events.on('threshold-changed', thresholdChangedEventHandler);
 
-   // set panel alert edit mode
-    this.$scope.$on("$destroy", () => {
+    // set panel alert edit mode
+    var unbind = this.$scope.$on("$destroy", () => {
       this.panelCtrl.events.off("threshold-changed", thresholdChangedEventHandler);
       this.panelCtrl.editingThresholds = false;
       this.panelCtrl.render();
+      unbind();
     });
 
     // build notification model

+ 3 - 7
public/app/features/dashboard/dashboard_ctrl.ts

@@ -102,12 +102,7 @@ export class DashboardCtrl {
       };
 
       $scope.addRowDefault = function() {
-        $scope.dashboard.rows.push({
-          title: 'New row',
-          panels: [],
-          height: '250px',
-          isNew: true,
-        });
+        $scope.dashboard.addEmptyRow();
       };
 
       $scope.showJsonEditor = function(evt, options) {
@@ -122,8 +117,9 @@ export class DashboardCtrl {
           $timeout.cancel(resizeEventTimeout);
           resizeEventTimeout = $timeout(function() { $scope.$broadcast('render'); }, 200);
         });
-        $scope.$on('$destroy', function() {
+        var unbind = $scope.$on('$destroy', function() {
           angular.element(window).unbind('resize');
+          unbind();
         });
       };
 

+ 7 - 2
public/app/features/dashboard/keybindings.js

@@ -11,8 +11,9 @@ function(angular, $) {
 
     this.shortcuts = function(scope) {
 
-      scope.$on('$destroy', function() {
+      var unbindDestroy = scope.$on('$destroy', function() {
         keyboardManager.unbindAll();
+        unbindDestroy();
       });
 
       var helpModalScope = null;
@@ -28,7 +29,11 @@ function(angular, $) {
           keyboard: false
         });
 
-        helpModalScope.$on('$destroy', function() { helpModalScope = null; });
+        var unbindModalDestroy = helpModalScope.$on('$destroy', function() {
+          helpModalScope = null;
+          unbindModalDestroy();
+        });
+
         $q.when(helpModal).then(function(modalEl) { modalEl.modal('show'); });
 
       }, { inputDisabled: true });

+ 20 - 6
public/app/features/dashboard/model.ts

@@ -6,8 +6,8 @@ import moment from 'moment';
 import _ from 'lodash';
 import $ from 'jquery';
 
-import {Emitter} from 'app/core/core';
-import {contextSrv} from 'app/core/services/context_srv';
+import {Emitter, contextSrv} from 'app/core/core';
+import {DashboardRow} from './row/row_model';
 
 export class DashboardModel {
   id: any;
@@ -19,7 +19,7 @@ export class DashboardModel {
   timezone: any;
   editable: any;
   sharedCrosshair: any;
-  rows: any;
+  rows: DashboardRow[];
   time: any;
   timepicker: any;
   templating: any;
@@ -51,7 +51,6 @@ export class DashboardModel {
     this.timezone = data.timezone || '';
     this.editable = data.editable !== false;
     this.sharedCrosshair = data.sharedCrosshair || false;
-    this.rows = data.rows || [];
     this.time = data.time || { from: 'now-6h', to: 'now' };
     this.timepicker = data.timepicker || {};
     this.templating = this.ensureListExist(data.templating);
@@ -63,10 +62,15 @@ export class DashboardModel {
     this.links = data.links || [];
     this.gnetId = data.gnetId || null;
 
+    this.rows = [];
+    if (data.rows) {
+      for (let row of data.rows) {
+        this.rows.push(new DashboardRow(row));
+      }
+    }
+
     this.updateSchema(data);
     this.initMeta(meta);
-
-    this.editMode = this.meta.isNew;
   }
 
   private initMeta(meta) {
@@ -84,6 +88,7 @@ export class DashboardModel {
     }
 
     this.meta = meta;
+    this.editMode = this.meta.isNew;
   }
 
   // cleans meta data and other non peristent state
@@ -91,18 +96,27 @@ export class DashboardModel {
     // temp remove stuff
     var events = this.events;
     var meta = this.meta;
+    var rows = this.rows;
     delete this.events;
     delete this.meta;
 
+    // prepare save model
+    this.rows = _.map(this.rows, row => row.getSaveModel());
     events.emit('prepare-save-model');
+
     var copy = $.extend(true, {}, this);
 
     // restore properties
     this.events = events;
     this.meta = meta;
+    this.rows = rows;
     return copy;
   }
 
+  addEmptyRow() {
+    this.rows.push(new DashboardRow({isNew: true}));
+  }
+
   private ensureListExist(data) {
     if (!data) { data = {}; }
     if (!data.list) { data.list = []; }

+ 10 - 3
public/app/features/dashboard/row/row.ts

@@ -19,7 +19,7 @@ export class DashRowCtrl {
   constructor(private $scope, private $rootScope, private $timeout, private uiSegmentSrv, private $q) {
     this.row.title = this.row.title || 'Row title';
 
-    if (this.dashboard.meta.isNew) {
+    if (this.row.isNew) {
       this.dropView = 1;
       delete this.row.isNew;
     }
@@ -200,13 +200,19 @@ coreModule.directive('panelDropZone', function($timeout) {
       }
 
       if (indrag === true) {
-        return showPanel(dropZoneSpan, 'Drop Here');
+        var dropZoneSpan = 12 - scope.ctrl.dashboard.rowSpan(scope.ctrl.row);
+        if (dropZoneSpan > 1) {
+          return showPanel(dropZoneSpan, 'Drop Here');
+        }
       }
 
       hidePanel();
     }
 
-    scope.$watchGroup(['ctrl.row.panels.length', 'ctrl.dashboard.editMode', 'ctrl.row.span'], updateState);
+    row.events.on('panel-added', updateState);
+    row.events.on('span-changed', updateState);
+
+    //scope.$watchGroup(['ctrl.row.panels.length', 'ctrl.dashboard.editMode', 'ctrl.row.span'], updateState);
 
     scope.$on("ANGULAR_DRAG_START", function() {
       indrag = true;
@@ -220,6 +226,7 @@ coreModule.directive('panelDropZone', function($timeout) {
     });
 
     scope.$on("ANGULAR_DRAG_END", function() {
+      console.log('drag end');
       indrag = false;
       updateState();
     });

+ 231 - 0
public/app/features/dashboard/row/row_ctrl.ts

@@ -0,0 +1,231 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import $ from 'jquery';
+import angular from 'angular';
+
+import config from 'app/core/config';
+import {coreModule} from 'app/core/core';
+
+import './options';
+import './add_panel';
+
+export class DashRowCtrl {
+  dashboard: any;
+  row: any;
+  dropView: number;
+
+  /** @ngInject */
+  constructor(private $scope, private $rootScope, private $timeout, private uiSegmentSrv, private $q) {
+    this.row.title = this.row.title || 'Row title';
+
+    if (this.dashboard.meta.isNew) {
+      this.dropView = 1;
+      delete this.row.isNew;
+    }
+  }
+
+  onDrop(panelId, dropTarget) {
+    var info = this.dashboard.getPanelInfoById(panelId);
+    if (dropTarget) {
+      var dropInfo = this.dashboard.getPanelInfoById(dropTarget.id);
+      dropInfo.row.panels[dropInfo.index] = info.panel;
+      info.row.panels[info.index] = dropTarget;
+      var dragSpan = info.panel.span;
+      info.panel.span = dropTarget.span;
+      dropTarget.span = dragSpan;
+    } else {
+      info.row.panels.splice(info.index, 1);
+      info.panel.span = 12 - this.dashboard.rowSpan(this.row);
+      this.row.panels.push(info.panel);
+    }
+
+    this.$rootScope.$broadcast('render');
+  }
+
+  setHeight(height) {
+    this.row.height = height;
+    this.$scope.$broadcast('render');
+  }
+
+  moveRow(direction) {
+    var rowsList = this.dashboard.rows;
+    var rowIndex = _.indexOf(rowsList, this.row);
+    var newIndex = rowIndex;
+    switch (direction) {
+      case 'up': {
+        newIndex = rowIndex - 1;
+        break;
+      }
+      case 'down': {
+        newIndex = rowIndex + 1;
+        break;
+      }
+      case 'top': {
+        newIndex = 0;
+        break;
+      }
+      case 'bottom': {
+        newIndex = rowsList.length - 1;
+        break;
+      }
+      default: {
+        newIndex = rowIndex;
+      }
+    }
+    if (newIndex >= 0 && newIndex <= (rowsList.length - 1)) {
+      _.move(rowsList, rowIndex, newIndex);
+    }
+  }
+
+  toggleCollapse() {
+    this.dropView = 0;
+    this.row.collapse = !this.row.collapse;
+  }
+
+  showAddPanel() {
+    this.row.collapse = false;
+    this.dropView = this.dropView === 1 ? 0 : 1;
+  }
+
+  showRowOptions() {
+    this.dropView = this.dropView === 2 ? 0 : 2;
+  }
+}
+
+export function rowDirective($rootScope) {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/features/dashboard/row/row.html',
+    controller: DashRowCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      dashboard: "=",
+      row: "=",
+    },
+    link: function(scope, element) {
+      scope.$watchGroup(['ctrl.row.collapse', 'ctrl.row.height'], function() {
+        element.find('.panels-wrapper').css({minHeight: scope.ctrl.row.collapse ? '5px' : scope.ctrl.row.height});
+      });
+
+      $rootScope.onAppEvent('panel-fullscreen-enter', function(evt, info) {
+        var hasPanel = _.find(scope.ctrl.row.panels, {id: info.panelId});
+        if (!hasPanel) {
+          element.hide();
+        }
+      }, scope);
+
+      $rootScope.onAppEvent('panel-fullscreen-exit', function() {
+        element.show();
+      }, scope);
+    }
+  };
+}
+
+coreModule.directive('dashRow', rowDirective);
+
+
+coreModule.directive('panelWidth', function($rootScope) {
+  return function(scope, element) {
+    var fullscreen = false;
+
+    function updateWidth() {
+      if (!fullscreen) {
+        element[0].style.width = ((scope.panel.span / 1.2) * 10) + '%';
+      }
+    }
+
+    $rootScope.onAppEvent('panel-fullscreen-enter', function(evt, info) {
+      fullscreen = true;
+
+      if (scope.panel.id !== info.panelId) {
+        element.hide();
+      } else {
+        element[0].style.width = '100%';
+      }
+    }, scope);
+
+    $rootScope.onAppEvent('panel-fullscreen-exit', function(evt, info) {
+      fullscreen = false;
+
+      if (scope.panel.id !== info.panelId) {
+        element.show();
+      }
+
+      updateWidth();
+    }, scope);
+
+    scope.$watch('panel.span', updateWidth);
+
+    if (fullscreen) {
+      element.hide();
+    }
+  };
+});
+
+
+coreModule.directive('panelDropZone', function($timeout) {
+  return function(scope, element) {
+    var row = scope.ctrl.row;
+    var indrag = false;
+    var textEl = element.find('.panel-drop-zone-text');
+
+    function showPanel(span, text) {
+      element.find('.panel-container').css('height', row.height);
+      element[0].style.width = ((span / 1.2) * 10) + '%';
+      textEl.text(text);
+      element.show();
+    }
+
+    function hidePanel() {
+      element.hide();
+      // element.removeClass('panel-drop-zone--empty');
+    }
+
+    function updateState() {
+      if (scope.ctrl.dashboard.editMode) {
+        if (row.panels.length === 0 && indrag === false) {
+          return showPanel(12, 'Empty Space');
+        }
+
+        var dropZoneSpan = 12 - scope.ctrl.dashboard.rowSpan(scope.ctrl.row);
+        if (dropZoneSpan > 1) {
+          if (indrag)  {
+            return showPanel(dropZoneSpan, 'Drop Here');
+          } else {
+            return showPanel(dropZoneSpan, 'Empty Space');
+          }
+        }
+      }
+
+      if (indrag === true) {
+        return showPanel(dropZoneSpan, 'Drop Here');
+      }
+
+      hidePanel();
+    }
+
+    scope.row.events.on('panel-added', updateState);
+    scope.row.events.on('span-changed', updateState);
+
+    scope.$watchGroup(['ctrl.row.panels.length', 'ctrl.dashboard.editMode', 'ctrl.row.span'], updateState);
+
+    scope.$on("ANGULAR_DRAG_START", function() {
+      indrag = true;
+      updateState();
+      // $timeout(function() {
+      //   var dropZoneSpan = 12 - scope.ctrl.dashboard.rowSpan(scope.ctrl.row);
+      //   if (dropZoneSpan > 0) {
+      //     showPanel(dropZoneSpan, 'Panel Drop Zone');
+      //   }
+      // });
+    });
+
+    scope.$on("ANGULAR_DRAG_END", function() {
+      indrag = false;
+      updateState();
+    });
+  };
+});
+

+ 32 - 0
public/app/features/dashboard/row/row_model.ts

@@ -0,0 +1,32 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import {Emitter, contextSrv} from 'app/core/core';
+import {assignModelProperties} from 'app/core/core';
+
+export class DashboardRow {
+  panels: any;
+  title: any;
+  showTitle: any;
+  titleSize: any;
+  events: Emitter;
+
+  defaults = {
+    title: 'Dashboard Row',
+    panels: [],
+    showTitle: false,
+    titleSize: 'h6',
+    height: 250,
+    isNew: false,
+  };
+
+  constructor(private model) {
+    assignModelProperties(this, model, this.defaults);
+    this.events = new Emitter();
+  }
+
+  getSaveModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+}
+

+ 1 - 0
public/app/features/dashboard/unsavedChangesSrv.js

@@ -65,6 +65,7 @@ function(angular, _) {
       dash.time = 0;
       dash.refresh = 0;
       dash.schemaVersion = 0;
+      dash.editMode = false;
 
       // filter row and panels properties that should be ignored
       dash.rows = _.filter(dash.rows, function(row) {

+ 2 - 1
public/app/features/dashboard/viewStateSrv.js

@@ -199,8 +199,9 @@ function (angular, _, $) {
         }
       }
 
-      panelScope.$on('$destroy', function() {
+      var unbind = panelScope.$on('$destroy', function() {
         self.panelScopes = _.without(self.panelScopes, panelScope);
+        unbind();
       });
     };
 

+ 6 - 1
public/app/features/panel/panel_ctrl.ts

@@ -50,7 +50,12 @@ export class PanelCtrl {
 
     $scope.$on("refresh", () => this.refresh());
     $scope.$on("render", () => this.render());
-    $scope.$on("$destroy", () => this.events.emit('panel-teardown'));
+
+    var unbindDestroy = $scope.$on("$destroy", () => {
+      this.events.emit('panel-teardown');
+      this.events.removeAllListeners();
+      unbindDestroy();
+    });
   }
 
   init() {

+ 2 - 2
public/app/features/panel/panel_directive.ts

@@ -100,7 +100,6 @@ module.directive('grafanaPanel', function() {
           panelContainer.removeClass('panel-alert-state--' + lastAlertState);
           lastAlertState = null;
         }
-
       });
 
       scope.$watchGroup(['ctrl.fullscreen', 'ctrl.containerHeight'], function() {
@@ -189,8 +188,9 @@ module.directive('panelResizer', function($rootScope) {
 
       elem.on('mousedown', dragStartHandler);
 
-      scope.$on("$destroy", function() {
+      var unbind = scope.$on("$destroy", function() {
         elem.off('mousedown', dragStartHandler);
+        unbind();
       });
     }
   };

+ 4 - 6
public/app/features/templating/variable.ts

@@ -2,6 +2,7 @@
 
 import _ from 'lodash';
 import kbn from 'app/core/utils/kbn';
+import {assignModelProperties} from 'app/core/core';
 
 export interface Variable {
   setValue(option);
@@ -13,12 +14,9 @@ export interface Variable {
 }
 
 export var variableTypes = {};
-
-export function assignModelProperties(target, source, defaults) {
-  _.forEach(defaults, function(value, key) {
-    target[key] = source[key] === undefined ? value : source[key];
-  });
-}
+export {
+  assignModelProperties
+};
 
 export function containsVariable(...args: any[]) {
   var variableName = args[args.length-1];

+ 15 - 9
public/app/plugins/panel/graph/graph.ts

@@ -34,6 +34,16 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
       var rootScope = scope.$root;
       var panelWidth = 0;
       var thresholdManager = new ThresholdManager(ctrl);
+      var plot;
+
+      ctrl.events.on('panel-teardown', () => {
+        thresholdManager = null;
+
+        if (plot) {
+          plot.destroy();
+          plot = null;
+        }
+      });
 
       rootScope.onAppEvent('setCrosshair', function(event, info) {
         // do not need to to this if event is from this panel
@@ -42,7 +52,6 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
         }
 
         if (dashboard.sharedCrosshair) {
-          var plot = elem.data().plot;
           if (plot) {
             plot.setCrosshair({ x: info.pos.x, y: info.pos.y });
           }
@@ -50,10 +59,7 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
       }, scope);
 
       rootScope.onAppEvent('clearCrosshair', function() {
-        var plot = elem.data().plot;
-        if (plot) {
-          plot.clearCrosshair();
-        }
+        plot.clearCrosshair();
       }, scope);
 
       // Receive render events
@@ -287,7 +293,7 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
 
         function callPlot(incrementRenderCounter) {
           try {
-            $.plot(elem, sortedSeries, options);
+            plot = $.plot(elem, sortedSeries, options);
             if (ctrl.renderError) {
               delete ctrl.error;
               delete ctrl.inspector;
@@ -529,9 +535,9 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
         return "%H:%M";
       }
 
-      new GraphTooltip(elem, dashboard, scope, function() {
-        return sortedSeries;
-      });
+      // new GraphTooltip(elem, dashboard, scope, function() {
+      //   return sortedSeries;
+      // });
 
       elem.bind("plotselected", function (event, ranges) {
         scope.$apply(function() {

+ 2 - 2
public/app/plugins/panel/graph/threshold_manager.ts

@@ -153,8 +153,8 @@ export class ThresholdManager {
       this.renderHandle(1, this.height-30);
     }
 
-    this.placeholder.off('mousedown', '.alert-handle');
-    this.placeholder.on('mousedown', '.alert-handle', this.initDragging.bind(this));
+    // this.placeholder.off('mousedown', '.alert-handle');
+    // this.placeholder.on('mousedown', '.alert-handle', this.initDragging.bind(this));
     this.needsCleanup = true;
   }
 

+ 2 - 1
public/app/plugins/panel/graph/thresholds_form.ts

@@ -17,9 +17,10 @@ export class ThresholdFormCtrl {
       this.disabled = true;
     }
 
-    $scope.$on("$destroy", () => {
+    var unbindDestroy = $scope.$on("$destroy", () => {
       this.panelCtrl.editingThresholds = false;
       this.panelCtrl.render();
+      unbindDestroy();
     });
 
     this.panelCtrl.editingThresholds = true;

+ 2 - 1
public/app/plugins/panel/table/module.ts

@@ -212,8 +212,9 @@ class TablePanelCtrl extends MetricsPanelCtrl {
 
     elem.on('click', '.table-panel-page-link', switchPage);
 
-    scope.$on('$destroy', function() {
+    var unbindDestroy = scope.$on('$destroy', function() {
       elem.off('click', '.table-panel-page-link');
+      unbindDestroy();
     });
 
     ctrl.events.on('render', function(renderData) {