Explorar el Código

Improve dashboard grid layout migration WIP (#9943)

* grid: fix small panels migration and make panel height closer to row height

* grid: migrate rows

* grid: increase min panel height

* grid: fix panel placement for complex layouts

* dashboard migration: refactor

* dashboard migration: add tests for grid layout

* dashboard: fix complex layout migration

* dashboard migration fix: fill current row if it possible

* test: fix karma tests by setting default panel span

* dashboard: fix migration when panel height more than row height

* dashboard: fix migration for collapsed rows

* grid: add all rows if even one collapsed or titled row is present
Alexander Zobnin hace 8 años
padre
commit
24fe3af20f

+ 3 - 1
public/app/core/constants.ts

@@ -4,4 +4,6 @@ export const GRID_CELL_VMARGIN = 10;
 export const GRID_COLUMN_COUNT = 24;
 export const REPEAT_DIR_VERTICAL = 'v';
 
-
+export const DEFAULT_PANEL_SPAN = 4;
+export const DEFAULT_ROW_HEIGHT = 250;
+export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3;

+ 532 - 0
public/app/features/dashboard/dashboard_migration.ts

@@ -0,0 +1,532 @@
+import _ from 'lodash';
+import {GRID_COLUMN_COUNT, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN,
+        DEFAULT_ROW_HEIGHT, MIN_PANEL_HEIGHT, DEFAULT_PANEL_SPAN} from 'app/core/constants';
+import {PanelModel} from './panel_model';
+import {DashboardModel} from './dashboard_model';
+
+export class DashboardMigrator {
+  dashboard: DashboardModel;
+
+  constructor(dashboardModel: DashboardModel) {
+    this.dashboard = dashboardModel;
+  }
+
+  updateSchema(old) {
+    var i, j, k;
+    var oldVersion = this.dashboard.schemaVersion;
+    var panelUpgrades = [];
+    this.dashboard.schemaVersion = 16;
+
+    if (oldVersion === this.dashboard.schemaVersion) {
+      return;
+    }
+
+    // version 2 schema changes
+    if (oldVersion < 2) {
+      if (old.services) {
+        if (old.services.filter) {
+          this.dashboard.time = old.services.filter.time;
+          this.dashboard.templating.list = old.services.filter.list || [];
+        }
+      }
+
+      panelUpgrades.push(function(panel) {
+        // rename panel type
+        if (panel.type === 'graphite') {
+          panel.type = 'graph';
+        }
+
+        if (panel.type !== 'graph') {
+          return;
+        }
+
+        if (_.isBoolean(panel.legend)) {
+          panel.legend = {show: panel.legend};
+        }
+
+        if (panel.grid) {
+          if (panel.grid.min) {
+            panel.grid.leftMin = panel.grid.min;
+            delete panel.grid.min;
+          }
+
+          if (panel.grid.max) {
+            panel.grid.leftMax = panel.grid.max;
+            delete panel.grid.max;
+          }
+        }
+
+        if (panel.y_format) {
+          panel.y_formats[0] = panel.y_format;
+          delete panel.y_format;
+        }
+
+        if (panel.y2_format) {
+          panel.y_formats[1] = panel.y2_format;
+          delete panel.y2_format;
+        }
+      });
+    }
+
+    // schema version 3 changes
+    if (oldVersion < 3) {
+      // ensure panel ids
+      var maxId = this.dashboard.getNextPanelId();
+      panelUpgrades.push(function(panel) {
+        if (!panel.id) {
+          panel.id = maxId;
+          maxId += 1;
+        }
+      });
+    }
+
+    // schema version 4 changes
+    if (oldVersion < 4) {
+      // move aliasYAxis changes
+      panelUpgrades.push(function(panel) {
+        if (panel.type !== 'graph') {
+          return;
+        }
+        _.each(panel.aliasYAxis, function(value, key) {
+          panel.seriesOverrides = [{alias: key, yaxis: value}];
+        });
+        delete panel.aliasYAxis;
+      });
+    }
+
+    if (oldVersion < 6) {
+      // move pulldowns to new schema
+      var annotations = _.find(old.pulldowns, {type: 'annotations'});
+
+      if (annotations) {
+        this.dashboard.annotations = {
+          list: annotations.annotations || [],
+        };
+      }
+
+      // update template variables
+      for (i = 0; i < this.dashboard.templating.list.length; i++) {
+        var variable = this.dashboard.templating.list[i];
+        if (variable.datasource === void 0) {
+          variable.datasource = null;
+        }
+        if (variable.type === 'filter') {
+          variable.type = 'query';
+        }
+        if (variable.type === void 0) {
+          variable.type = 'query';
+        }
+        if (variable.allFormat === void 0) {
+          variable.allFormat = 'glob';
+        }
+      }
+    }
+
+    if (oldVersion < 7) {
+      if (old.nav && old.nav.length) {
+        this.dashboard.timepicker = old.nav[0];
+      }
+
+      // ensure query refIds
+      panelUpgrades.push(function(panel) {
+        _.each(
+          panel.targets,
+          function(target) {
+            if (!target.refId) {
+              target.refId = this.dashboard.getNextQueryLetter(panel);
+            }
+          }.bind(this),
+        );
+      });
+    }
+
+    if (oldVersion < 8) {
+      panelUpgrades.push(function(panel) {
+        _.each(panel.targets, function(target) {
+          // update old influxdb query schema
+          if (target.fields && target.tags && target.groupBy) {
+            if (target.rawQuery) {
+              delete target.fields;
+              delete target.fill;
+            } else {
+              target.select = _.map(target.fields, function(field) {
+                var parts = [];
+                parts.push({type: 'field', params: [field.name]});
+                parts.push({type: field.func, params: []});
+                if (field.mathExpr) {
+                  parts.push({type: 'math', params: [field.mathExpr]});
+                }
+                if (field.asExpr) {
+                  parts.push({type: 'alias', params: [field.asExpr]});
+                }
+                return parts;
+              });
+              delete target.fields;
+              _.each(target.groupBy, function(part) {
+                if (part.type === 'time' && part.interval) {
+                  part.params = [part.interval];
+                  delete part.interval;
+                }
+                if (part.type === 'tag' && part.key) {
+                  part.params = [part.key];
+                  delete part.key;
+                }
+              });
+
+              if (target.fill) {
+                target.groupBy.push({type: 'fill', params: [target.fill]});
+                delete target.fill;
+              }
+            }
+          }
+        });
+      });
+    }
+
+    // schema version 9 changes
+    if (oldVersion < 9) {
+      // move aliasYAxis changes
+      panelUpgrades.push(function(panel) {
+        if (panel.type !== 'singlestat' && panel.thresholds !== '') {
+          return;
+        }
+
+        if (panel.thresholds) {
+          var k = panel.thresholds.split(',');
+
+          if (k.length >= 3) {
+            k.shift();
+            panel.thresholds = k.join(',');
+          }
+        }
+      });
+    }
+
+    // schema version 10 changes
+    if (oldVersion < 10) {
+      // move aliasYAxis changes
+      panelUpgrades.push(function(panel) {
+        if (panel.type !== 'table') {
+          return;
+        }
+
+        _.each(panel.styles, function(style) {
+          if (style.thresholds && style.thresholds.length >= 3) {
+            var k = style.thresholds;
+            k.shift();
+            style.thresholds = k;
+          }
+        });
+      });
+    }
+
+    if (oldVersion < 12) {
+      // update template variables
+      _.each(this.dashboard.templating.list, function(templateVariable) {
+        if (templateVariable.refresh) {
+          templateVariable.refresh = 1;
+        }
+        if (!templateVariable.refresh) {
+          templateVariable.refresh = 0;
+        }
+        if (templateVariable.hideVariable) {
+          templateVariable.hide = 2;
+        } else if (templateVariable.hideLabel) {
+          templateVariable.hide = 1;
+        }
+      });
+    }
+
+    if (oldVersion < 12) {
+      // update graph yaxes changes
+      panelUpgrades.push(function(panel) {
+        if (panel.type !== 'graph') {
+          return;
+        }
+        if (!panel.grid) {
+          return;
+        }
+
+        if (!panel.yaxes) {
+          panel.yaxes = [
+            {
+              show: panel['y-axis'],
+              min: panel.grid.leftMin,
+              max: panel.grid.leftMax,
+              logBase: panel.grid.leftLogBase,
+              format: panel.y_formats[0],
+              label: panel.leftYAxisLabel,
+            },
+            {
+              show: panel['y-axis'],
+              min: panel.grid.rightMin,
+              max: panel.grid.rightMax,
+              logBase: panel.grid.rightLogBase,
+              format: panel.y_formats[1],
+              label: panel.rightYAxisLabel,
+            },
+          ];
+
+          panel.xaxis = {
+            show: panel['x-axis'],
+          };
+
+          delete panel.grid.leftMin;
+          delete panel.grid.leftMax;
+          delete panel.grid.leftLogBase;
+          delete panel.grid.rightMin;
+          delete panel.grid.rightMax;
+          delete panel.grid.rightLogBase;
+          delete panel.y_formats;
+          delete panel.leftYAxisLabel;
+          delete panel.rightYAxisLabel;
+          delete panel['y-axis'];
+          delete panel['x-axis'];
+        }
+      });
+    }
+
+    if (oldVersion < 13) {
+      // update graph yaxes changes
+      panelUpgrades.push(function(panel) {
+        if (panel.type !== 'graph') {
+          return;
+        }
+        if (!panel.grid) {
+          return;
+        }
+
+        panel.thresholds = [];
+        var t1: any = {},
+          t2: any = {};
+
+        if (panel.grid.threshold1 !== null) {
+          t1.value = panel.grid.threshold1;
+          if (panel.grid.thresholdLine) {
+            t1.line = true;
+            t1.lineColor = panel.grid.threshold1Color;
+            t1.colorMode = 'custom';
+          } else {
+            t1.fill = true;
+            t1.fillColor = panel.grid.threshold1Color;
+            t1.colorMode = 'custom';
+          }
+        }
+
+        if (panel.grid.threshold2 !== null) {
+          t2.value = panel.grid.threshold2;
+          if (panel.grid.thresholdLine) {
+            t2.line = true;
+            t2.lineColor = panel.grid.threshold2Color;
+            t2.colorMode = 'custom';
+          } else {
+            t2.fill = true;
+            t2.fillColor = panel.grid.threshold2Color;
+            t2.colorMode = 'custom';
+          }
+        }
+
+        if (_.isNumber(t1.value)) {
+          if (_.isNumber(t2.value)) {
+            if (t1.value > t2.value) {
+              t1.op = t2.op = 'lt';
+              panel.thresholds.push(t1);
+              panel.thresholds.push(t2);
+            } else {
+              t1.op = t2.op = 'gt';
+              panel.thresholds.push(t1);
+              panel.thresholds.push(t2);
+            }
+          } else {
+            t1.op = 'gt';
+            panel.thresholds.push(t1);
+          }
+        }
+
+        delete panel.grid.threshold1;
+        delete panel.grid.threshold1Color;
+        delete panel.grid.threshold2;
+        delete panel.grid.threshold2Color;
+        delete panel.grid.thresholdLine;
+      });
+    }
+
+    if (oldVersion < 14) {
+      this.dashboard.graphTooltip = old.sharedCrosshair ? 1 : 0;
+    }
+
+    if (oldVersion < 16) {
+      this.upgradeToGridLayout(old);
+    }
+
+    if (panelUpgrades.length === 0) {
+      return;
+    }
+
+    for (j = 0; j < this.dashboard.panels.length; j++) {
+      for (k = 0; k < panelUpgrades.length; k++) {
+        panelUpgrades[k].call(this, this.dashboard.panels[j]);
+      }
+    }
+  }
+
+  upgradeToGridLayout(old) {
+    let yPos = 0;
+    let widthFactor = GRID_COLUMN_COUNT / 12;
+
+    const maxPanelId = _.max(_.flattenDeep(_.map(old.rows, (row) => {
+      return _.map(row.panels, 'id');
+    })));
+    let nextRowId = maxPanelId + 1;
+
+    if (!old.rows) {
+      return;
+    }
+
+    // Add special "row" panels if even one row is collapsed or has visible title
+    const showRows = _.some(old.rows, (row) => row.collapse || row.showTitle);
+
+    for (let row of old.rows) {
+      let xPos = 0;
+      let height: any = row.height || DEFAULT_ROW_HEIGHT;
+      const rowGridHeight = getGridHeight(height);
+
+      let rowPanel: any = {};
+      let rowPanelModel: PanelModel;
+      if (showRows) {
+        // add special row panel
+        rowPanel.id = nextRowId;
+        rowPanel.type = 'row';
+        rowPanel.title = row.title;
+        rowPanel.collapsed = row.collapse;
+        rowPanel.panels = [];
+        rowPanel.gridPos = {x: 0, y: yPos, w: GRID_COLUMN_COUNT, h: rowGridHeight};
+        rowPanelModel = new PanelModel(rowPanel);
+        nextRowId++;
+        yPos++;
+      }
+
+      let rowArea = new RowArea(rowGridHeight, GRID_COLUMN_COUNT, yPos);
+
+      for (let panel of row.panels) {
+        panel.span = panel.span || DEFAULT_PANEL_SPAN;
+        const panelWidth = Math.floor(panel.span) * widthFactor;
+        const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight;
+
+        let panelPos = rowArea.getPanelPosition(panelHeight, panelWidth);
+        yPos = rowArea.yPos;
+        panel.gridPos = {x: panelPos.x, y: yPos + panelPos.y, w: panelWidth, h: panelHeight};
+        rowArea.addPanel(panel.gridPos);
+
+        delete panel.span;
+
+        xPos += panel.gridPos.w;
+
+        if (rowPanelModel && rowPanel.collapsed) {
+          rowPanelModel.panels.push(panel);
+        } else {
+          this.dashboard.panels.push(new PanelModel(panel));
+        }
+      }
+
+      if (rowPanelModel) {
+        this.dashboard.panels.push(rowPanelModel);
+      }
+
+      if (!(rowPanelModel && rowPanel.collapsed)) {
+        yPos += rowGridHeight;
+      }
+    }
+  }
+}
+
+function getGridHeight(height) {
+  if (_.isString(height)) {
+    height = parseInt(height.replace('px', ''), 10);
+  }
+
+  if (height < MIN_PANEL_HEIGHT) {
+    height = MIN_PANEL_HEIGHT;
+  }
+
+  const gridHeight = Math.ceil(height / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN));
+  return gridHeight;
+}
+
+/**
+ * RowArea represents dashboard row filled by panels
+ * area is an array of numbers represented filled column's cells like
+ *  -----------------------
+ * |******** ****
+ * |******** ****
+ * |********
+ *  -----------------------
+ *  33333333 2222 00000 ...
+ */
+class RowArea {
+  area: number[];
+  yPos: number;
+  height: number;
+
+  constructor(height, width = GRID_COLUMN_COUNT, rowYPos = 0) {
+    this.area = new Array(width).fill(0);
+    this.yPos = rowYPos;
+    this.height = height;
+  }
+
+  reset() {
+    this.area.fill(0);
+  }
+
+  /**
+   * Update area after adding the panel.
+   */
+  addPanel(gridPos) {
+    for (let i = gridPos.x; i < gridPos.x + gridPos.w; i++) {
+      if (!this.area[i] || gridPos.y + gridPos.h - this.yPos > this.area[i]) {
+        this.area[i] = gridPos.y + gridPos.h - this.yPos;
+      }
+    }
+    return this.area;
+  }
+
+  /**
+   * Calculate position for the new panel in the row.
+   */
+  getPanelPosition(panelHeight, panelWidth, callOnce = false) {
+    let startPlace, endPlace;
+    let place;
+    for (let i = this.area.length - 1; i >= 0; i--) {
+      if (this.height - this.area[i] > 0) {
+        if (endPlace === undefined) {
+          endPlace = i;
+        } else {
+          if (i < this.area.length - 1 && this.area[i] <= this.area[i+1]) {
+            startPlace = i;
+          } else {
+            break;
+          }
+        }
+      } else {
+        break;
+      }
+    }
+
+    if (startPlace !== undefined && endPlace !== undefined && endPlace - startPlace >= panelWidth - 1) {
+      const yPos = _.max(this.area.slice(startPlace));
+      place = {
+        x: startPlace,
+        y: yPos
+      };
+    } else if (!callOnce) {
+      // wrap to next row
+      this.yPos += this.height;
+      this.reset();
+      return this.getPanelPosition(panelHeight, panelWidth, true);
+    } else {
+      return null;
+    }
+
+    return place;
+  }
+}

+ 4 - 412
public/app/features/dashboard/dashboard_model.ts

@@ -1,13 +1,14 @@
 import moment from 'moment';
 import _ from 'lodash';
 
-import {GRID_COLUMN_COUNT, GRID_CELL_HEIGHT, REPEAT_DIR_VERTICAL} from 'app/core/constants';
+import {GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL} from 'app/core/constants';
 import {DEFAULT_ANNOTATION_COLOR} from 'app/core/utils/colors';
 import {Emitter} from 'app/core/utils/emitter';
 import {contextSrv} from 'app/core/services/context_srv';
 import sortByKeys from 'app/core/utils/sort_by_keys';
 
 import {PanelModel} from './panel_model';
+import {DashboardMigrator} from './dashboard_migration';
 
 export class DashboardModel {
   id: any;
@@ -554,416 +555,7 @@ export class DashboardModel {
   }
 
   private updateSchema(old) {
-    var i, j, k;
-    var oldVersion = this.schemaVersion;
-    var panelUpgrades = [];
-    this.schemaVersion = 16;
-
-    if (oldVersion === this.schemaVersion) {
-      return;
-    }
-
-    // version 2 schema changes
-    if (oldVersion < 2) {
-      if (old.services) {
-        if (old.services.filter) {
-          this.time = old.services.filter.time;
-          this.templating.list = old.services.filter.list || [];
-        }
-      }
-
-      panelUpgrades.push(function(panel) {
-        // rename panel type
-        if (panel.type === 'graphite') {
-          panel.type = 'graph';
-        }
-
-        if (panel.type !== 'graph') {
-          return;
-        }
-
-        if (_.isBoolean(panel.legend)) {
-          panel.legend = {show: panel.legend};
-        }
-
-        if (panel.grid) {
-          if (panel.grid.min) {
-            panel.grid.leftMin = panel.grid.min;
-            delete panel.grid.min;
-          }
-
-          if (panel.grid.max) {
-            panel.grid.leftMax = panel.grid.max;
-            delete panel.grid.max;
-          }
-        }
-
-        if (panel.y_format) {
-          panel.y_formats[0] = panel.y_format;
-          delete panel.y_format;
-        }
-
-        if (panel.y2_format) {
-          panel.y_formats[1] = panel.y2_format;
-          delete panel.y2_format;
-        }
-      });
-    }
-
-    // schema version 3 changes
-    if (oldVersion < 3) {
-      // ensure panel ids
-      var maxId = this.getNextPanelId();
-      panelUpgrades.push(function(panel) {
-        if (!panel.id) {
-          panel.id = maxId;
-          maxId += 1;
-        }
-      });
-    }
-
-    // schema version 4 changes
-    if (oldVersion < 4) {
-      // move aliasYAxis changes
-      panelUpgrades.push(function(panel) {
-        if (panel.type !== 'graph') {
-          return;
-        }
-        _.each(panel.aliasYAxis, function(value, key) {
-          panel.seriesOverrides = [{alias: key, yaxis: value}];
-        });
-        delete panel.aliasYAxis;
-      });
-    }
-
-    if (oldVersion < 6) {
-      // move pulldowns to new schema
-      var annotations = _.find(old.pulldowns, {type: 'annotations'});
-
-      if (annotations) {
-        this.annotations = {
-          list: annotations.annotations || [],
-        };
-      }
-
-      // update template variables
-      for (i = 0; i < this.templating.list.length; i++) {
-        var variable = this.templating.list[i];
-        if (variable.datasource === void 0) {
-          variable.datasource = null;
-        }
-        if (variable.type === 'filter') {
-          variable.type = 'query';
-        }
-        if (variable.type === void 0) {
-          variable.type = 'query';
-        }
-        if (variable.allFormat === void 0) {
-          variable.allFormat = 'glob';
-        }
-      }
-    }
-
-    if (oldVersion < 7) {
-      if (old.nav && old.nav.length) {
-        this.timepicker = old.nav[0];
-      }
-
-      // ensure query refIds
-      panelUpgrades.push(function(panel) {
-        _.each(
-          panel.targets,
-          function(target) {
-            if (!target.refId) {
-              target.refId = this.getNextQueryLetter(panel);
-            }
-          }.bind(this),
-        );
-      });
-    }
-
-    if (oldVersion < 8) {
-      panelUpgrades.push(function(panel) {
-        _.each(panel.targets, function(target) {
-          // update old influxdb query schema
-          if (target.fields && target.tags && target.groupBy) {
-            if (target.rawQuery) {
-              delete target.fields;
-              delete target.fill;
-            } else {
-              target.select = _.map(target.fields, function(field) {
-                var parts = [];
-                parts.push({type: 'field', params: [field.name]});
-                parts.push({type: field.func, params: []});
-                if (field.mathExpr) {
-                  parts.push({type: 'math', params: [field.mathExpr]});
-                }
-                if (field.asExpr) {
-                  parts.push({type: 'alias', params: [field.asExpr]});
-                }
-                return parts;
-              });
-              delete target.fields;
-              _.each(target.groupBy, function(part) {
-                if (part.type === 'time' && part.interval) {
-                  part.params = [part.interval];
-                  delete part.interval;
-                }
-                if (part.type === 'tag' && part.key) {
-                  part.params = [part.key];
-                  delete part.key;
-                }
-              });
-
-              if (target.fill) {
-                target.groupBy.push({type: 'fill', params: [target.fill]});
-                delete target.fill;
-              }
-            }
-          }
-        });
-      });
-    }
-
-    // schema version 9 changes
-    if (oldVersion < 9) {
-      // move aliasYAxis changes
-      panelUpgrades.push(function(panel) {
-        if (panel.type !== 'singlestat' && panel.thresholds !== '') {
-          return;
-        }
-
-        if (panel.thresholds) {
-          var k = panel.thresholds.split(',');
-
-          if (k.length >= 3) {
-            k.shift();
-            panel.thresholds = k.join(',');
-          }
-        }
-      });
-    }
-
-    // schema version 10 changes
-    if (oldVersion < 10) {
-      // move aliasYAxis changes
-      panelUpgrades.push(function(panel) {
-        if (panel.type !== 'table') {
-          return;
-        }
-
-        _.each(panel.styles, function(style) {
-          if (style.thresholds && style.thresholds.length >= 3) {
-            var k = style.thresholds;
-            k.shift();
-            style.thresholds = k;
-          }
-        });
-      });
-    }
-
-    if (oldVersion < 12) {
-      // update template variables
-      _.each(this.templating.list, function(templateVariable) {
-        if (templateVariable.refresh) {
-          templateVariable.refresh = 1;
-        }
-        if (!templateVariable.refresh) {
-          templateVariable.refresh = 0;
-        }
-        if (templateVariable.hideVariable) {
-          templateVariable.hide = 2;
-        } else if (templateVariable.hideLabel) {
-          templateVariable.hide = 1;
-        }
-      });
-    }
-
-    if (oldVersion < 12) {
-      // update graph yaxes changes
-      panelUpgrades.push(function(panel) {
-        if (panel.type !== 'graph') {
-          return;
-        }
-        if (!panel.grid) {
-          return;
-        }
-
-        if (!panel.yaxes) {
-          panel.yaxes = [
-            {
-              show: panel['y-axis'],
-              min: panel.grid.leftMin,
-              max: panel.grid.leftMax,
-              logBase: panel.grid.leftLogBase,
-              format: panel.y_formats[0],
-              label: panel.leftYAxisLabel,
-            },
-            {
-              show: panel['y-axis'],
-              min: panel.grid.rightMin,
-              max: panel.grid.rightMax,
-              logBase: panel.grid.rightLogBase,
-              format: panel.y_formats[1],
-              label: panel.rightYAxisLabel,
-            },
-          ];
-
-          panel.xaxis = {
-            show: panel['x-axis'],
-          };
-
-          delete panel.grid.leftMin;
-          delete panel.grid.leftMax;
-          delete panel.grid.leftLogBase;
-          delete panel.grid.rightMin;
-          delete panel.grid.rightMax;
-          delete panel.grid.rightLogBase;
-          delete panel.y_formats;
-          delete panel.leftYAxisLabel;
-          delete panel.rightYAxisLabel;
-          delete panel['y-axis'];
-          delete panel['x-axis'];
-        }
-      });
-    }
-
-    if (oldVersion < 13) {
-      // update graph yaxes changes
-      panelUpgrades.push(function(panel) {
-        if (panel.type !== 'graph') {
-          return;
-        }
-        if (!panel.grid) {
-          return;
-        }
-
-        panel.thresholds = [];
-        var t1: any = {},
-          t2: any = {};
-
-        if (panel.grid.threshold1 !== null) {
-          t1.value = panel.grid.threshold1;
-          if (panel.grid.thresholdLine) {
-            t1.line = true;
-            t1.lineColor = panel.grid.threshold1Color;
-            t1.colorMode = 'custom';
-          } else {
-            t1.fill = true;
-            t1.fillColor = panel.grid.threshold1Color;
-            t1.colorMode = 'custom';
-          }
-        }
-
-        if (panel.grid.threshold2 !== null) {
-          t2.value = panel.grid.threshold2;
-          if (panel.grid.thresholdLine) {
-            t2.line = true;
-            t2.lineColor = panel.grid.threshold2Color;
-            t2.colorMode = 'custom';
-          } else {
-            t2.fill = true;
-            t2.fillColor = panel.grid.threshold2Color;
-            t2.colorMode = 'custom';
-          }
-        }
-
-        if (_.isNumber(t1.value)) {
-          if (_.isNumber(t2.value)) {
-            if (t1.value > t2.value) {
-              t1.op = t2.op = 'lt';
-              panel.thresholds.push(t1);
-              panel.thresholds.push(t2);
-            } else {
-              t1.op = t2.op = 'gt';
-              panel.thresholds.push(t1);
-              panel.thresholds.push(t2);
-            }
-          } else {
-            t1.op = 'gt';
-            panel.thresholds.push(t1);
-          }
-        }
-
-        delete panel.grid.threshold1;
-        delete panel.grid.threshold1Color;
-        delete panel.grid.threshold2;
-        delete panel.grid.threshold2Color;
-        delete panel.grid.thresholdLine;
-      });
-    }
-
-    if (oldVersion < 14) {
-      this.graphTooltip = old.sharedCrosshair ? 1 : 0;
-    }
-
-    if (oldVersion < 16) {
-      this.upgradeToGridLayout(old);
-    }
-
-    if (panelUpgrades.length === 0) {
-      return;
-    }
-
-    for (j = 0; j < this.panels.length; j++) {
-      for (k = 0; k < panelUpgrades.length; k++) {
-        panelUpgrades[k].call(this, this.panels[j]);
-      }
-    }
-  }
-
-  upgradeToGridLayout(old) {
-    let yPos = 0;
-    let widthFactor = GRID_COLUMN_COUNT / 12;
-    //let rowIds = 1000;
-    //
-
-    if (!old.rows) {
-      return;
-    }
-
-    for (let row of old.rows) {
-      let xPos = 0;
-      let height: any = row.height || 250;
-
-      // if (this.meta.keepRows) {
-      //   this.panels.push({
-      //     id: rowIds++,
-      //     type: 'row',
-      //     title: row.title,
-      //     x: 0,
-      //     y: yPos,
-      //     height: 1,
-      //     width: 12
-      //   });
-      //
-      //   yPos += 1;
-      // }
-
-      if (_.isString(height)) {
-        height = parseInt(height.replace('px', ''), 10);
-      }
-
-      const rowGridHeight = Math.ceil(height / GRID_CELL_HEIGHT);
-
-      for (let panel of row.panels) {
-        const panelWidth = Math.floor(panel.span) * widthFactor;
-
-        // should wrap to next row?
-        if (xPos + panelWidth >= GRID_COLUMN_COUNT) {
-          yPos += rowGridHeight;
-        }
-
-        panel.gridPos = {x: xPos, y: yPos, w: panelWidth, h: rowGridHeight};
-
-        delete panel.span;
-
-        xPos += panel.gridPos.w;
-
-        this.panels.push(new PanelModel(panel));
-      }
-
-      yPos += rowGridHeight;
-    }
+    let migrator = new DashboardMigrator(this);
+    migrator.updateSchema(old);
   }
 }

+ 341 - 0
public/app/features/dashboard/specs/dashboard_migration.jest.ts

@@ -0,0 +1,341 @@
+import _ from 'lodash';
+import { DashboardModel } from '../dashboard_model';
+import { PanelModel } from '../panel_model';
+import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN} from 'app/core/constants';
+
+jest.mock('app/core/services/context_srv', () => ({}));
+
+describe('DashboardModel', function() {
+  describe('when creating dashboard with old schema', function() {
+    var model;
+    var graph;
+    var singlestat;
+    var table;
+
+    beforeEach(function() {
+      model = new DashboardModel({
+        services: { filter: { time: { from: 'now-1d', to: 'now' }, list: [{}] } },
+        pulldowns: [
+          { type: 'filtering', enable: true },
+          { type: 'annotations', enable: true, annotations: [{ name: 'old' }] },
+        ],
+        panels: [
+          {
+            type: 'graph',
+            legend: true,
+            aliasYAxis: { test: 2 },
+            y_formats: ['kbyte', 'ms'],
+            grid: {
+              min: 1,
+              max: 10,
+              rightMin: 5,
+              rightMax: 15,
+              leftLogBase: 1,
+              rightLogBase: 2,
+              threshold1: 200,
+              threshold2: 400,
+              threshold1Color: 'yellow',
+              threshold2Color: 'red',
+            },
+            leftYAxisLabel: 'left label',
+            targets: [{ refId: 'A' }, {}],
+          },
+          {
+            type: 'singlestat',
+            legend: true,
+            thresholds: '10,20,30',
+            aliasYAxis: { test: 2 },
+            grid: { min: 1, max: 10 },
+            targets: [{ refId: 'A' }, {}],
+          },
+          {
+            type: 'table',
+            legend: true,
+            styles: [{ thresholds: ['10', '20', '30'] }, { thresholds: ['100', '200', '300'] }],
+            targets: [{ refId: 'A' }, {}],
+          },
+        ],
+      });
+
+      graph = model.panels[0];
+      singlestat = model.panels[1];
+      table = model.panels[2];
+    });
+
+    it('should have title', function() {
+      expect(model.title).toBe('No Title');
+    });
+
+    it('should have panel id', function() {
+      expect(graph.id).toBe(1);
+    });
+
+    it('should move time and filtering list', function() {
+      expect(model.time.from).toBe('now-1d');
+      expect(model.templating.list[0].allFormat).toBe('glob');
+    });
+
+    it('graphite panel should change name too graph', function() {
+      expect(graph.type).toBe('graph');
+    });
+
+    it('single stat panel should have two thresholds', function() {
+      expect(singlestat.thresholds).toBe('20,30');
+    });
+
+    it('queries without refId should get it', function() {
+      expect(graph.targets[1].refId).toBe('B');
+    });
+
+    it('update legend setting', function() {
+      expect(graph.legend.show).toBe(true);
+    });
+
+    it('move aliasYAxis to series override', function() {
+      expect(graph.seriesOverrides[0].alias).toBe('test');
+      expect(graph.seriesOverrides[0].yaxis).toBe(2);
+    });
+
+    it('should move pulldowns to new schema', function() {
+      expect(model.annotations.list[1].name).toBe('old');
+    });
+
+    it('table panel should only have two thresholds values', function() {
+      expect(table.styles[0].thresholds[0]).toBe('20');
+      expect(table.styles[0].thresholds[1]).toBe('30');
+      expect(table.styles[1].thresholds[0]).toBe('200');
+      expect(table.styles[1].thresholds[1]).toBe('300');
+    });
+
+    it('graph grid to yaxes options', function() {
+      expect(graph.yaxes[0].min).toBe(1);
+      expect(graph.yaxes[0].max).toBe(10);
+      expect(graph.yaxes[0].format).toBe('kbyte');
+      expect(graph.yaxes[0].label).toBe('left label');
+      expect(graph.yaxes[0].logBase).toBe(1);
+      expect(graph.yaxes[1].min).toBe(5);
+      expect(graph.yaxes[1].max).toBe(15);
+      expect(graph.yaxes[1].format).toBe('ms');
+      expect(graph.yaxes[1].logBase).toBe(2);
+
+      expect(graph.grid.rightMax).toBe(undefined);
+      expect(graph.grid.rightLogBase).toBe(undefined);
+      expect(graph.y_formats).toBe(undefined);
+    });
+
+    it('dashboard schema version should be set to latest', function() {
+      expect(model.schemaVersion).toBe(16);
+    });
+
+    it('graph thresholds should be migrated', function() {
+      expect(graph.thresholds.length).toBe(2);
+      expect(graph.thresholds[0].op).toBe('gt');
+      expect(graph.thresholds[0].value).toBe(200);
+      expect(graph.thresholds[0].fillColor).toBe('yellow');
+      expect(graph.thresholds[1].value).toBe(400);
+      expect(graph.thresholds[1].fillColor).toBe('red');
+    });
+  });
+
+  describe('when migrating to the grid layout', function() {
+    let model;
+
+    beforeEach(function() {
+      model = {
+        rows: []
+      };
+    });
+
+    it('should create proper grid', function() {
+      model.rows = [
+        createRow({collapse: false, height: 8}, [[6], [6]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 12, h: 8}, {x: 12, y: 0, w: 12, h: 8}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should add special "row" panel if row is collapsed', function() {
+      model.rows = [
+        createRow({collapse: true, height: 8}, [[6], [6]]),
+        createRow({height: 8}, [[12]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 24, h: 8}, // row
+        {x: 0, y: 1, w: 24, h: 8}, // row
+        {x: 0, y: 2, w: 24, h: 8}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should add special "row" panel if row has visible title', function() {
+      model.rows = [
+        createRow({showTitle: true, title: "Row", height: 8}, [[6], [6]]),
+        createRow({height: 8}, [[12]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 24, h: 8}, // row
+        {x: 0, y: 1, w: 12, h: 8}, {x: 12, y: 1, w: 12, h: 8},
+        {x: 0, y: 9, w: 24, h: 8}, // row
+        {x: 0, y: 10, w: 24, h: 8},
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should not add "row" panel if row has not visible title or not collapsed', function() {
+      model.rows = [
+        createRow({collapse: true, height: 8}, [[12]]),
+        createRow({height: 8}, [[12]]),
+        createRow({height: 8}, [[12], [6], [6]]),
+        createRow({collapse: true, height: 8}, [[12]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 24, h: 8}, // row
+        {x: 0, y: 1, w: 24, h: 8}, // row
+        {x: 0, y: 2, w: 24, h: 8},
+        {x: 0, y: 10, w: 24, h: 8}, // row
+        {x: 0, y: 11, w: 24, h: 8},
+        {x: 0, y: 19, w: 12, h: 8}, {x: 12, y: 19, w: 12, h: 8},
+        {x: 0, y: 27, w: 24, h: 8}, // row
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should add all rows if even one collapsed or titled row is present', function() {
+      model.rows = [
+        createRow({collapse: true, height: 8}, [[6], [6]]),
+        createRow({height: 8}, [[12]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 24, h: 8}, // row
+        {x: 0, y: 1, w: 24, h: 8}, // row
+        {x: 0, y: 2, w: 24, h: 8}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should properly place panels with fixed height', function() {
+      model.rows = [
+        createRow({height: 6}, [[6], [6, 3], [6, 3]]),
+        createRow({height: 6}, [[4], [4], [4, 3], [4, 3]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 12, h: 6}, {x: 12, y: 0, w: 12, h: 3}, {x: 12, y: 3, w: 12, h: 3},
+        {x: 0, y: 6, w: 8, h: 6}, {x: 8, y: 6, w: 8, h: 6}, {x: 16, y: 6, w: 8, h: 3}, {x: 16, y: 9, w: 8, h: 3}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should place panel to the right side of panel having bigger height', function() {
+      model.rows = [
+        createRow({height: 6}, [[4], [2, 3], [4, 6], [2, 3], [2, 3]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 8, h: 6}, {x: 8, y: 0, w: 4, h: 3},
+        {x: 12, y: 0, w: 8, h: 6}, {x: 20, y: 0, w: 4, h: 3}, {x: 20, y: 3, w: 4, h: 3}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should fill current row if it possible', function() {
+      model.rows = [
+        createRow({height: 9}, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 8, h: 9}, {x: 8, y: 0, w: 4, h: 3}, {x: 12, y: 0, w: 8, h: 6},
+        {x: 20, y: 0, w: 4, h: 3}, {x: 20, y: 3, w: 4, h: 3}, {x: 8, y: 6, w: 16, h: 3}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should fill current row if it possible (2)', function() {
+      model.rows = [
+        createRow({height: 8}, [[4], [2, 3], [4, 6], [2, 3], [2, 3], [8, 3]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 8, h: 8}, {x: 8, y: 0, w: 4, h: 3}, {x: 12, y: 0, w: 8, h: 6},
+        {x: 20, y: 0, w: 4, h: 3}, {x: 20, y: 3, w: 4, h: 3}, {x: 8, y: 6, w: 16, h: 3}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should fill current row if panel height more than row height', function() {
+      model.rows = [
+        createRow({height: 6}, [[4], [2, 3], [4, 8], [2, 3], [2, 3]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 8, h: 6}, {x: 8, y: 0, w: 4, h: 3},
+        {x: 12, y: 0, w: 8, h: 8}, {x: 20, y: 0, w: 4, h: 3}, {x: 20, y: 3, w: 4, h: 3}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+
+    it('should wrap panels to multiple rows', function() {
+      model.rows = [
+        createRow({height: 6}, [[6], [6], [12], [6], [3], [3]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 12, h: 6}, {x: 12, y: 0, w: 12, h: 6},
+        {x: 0, y: 6, w: 24, h: 6},
+        {x: 0, y: 12, w: 12, h: 6}, {x: 12, y: 12, w: 6, h: 6}, {x: 18, y: 12, w: 6, h: 6},
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+    });
+  });
+});
+
+function createRow(options, panelDescriptions: any[]) {
+  const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN;
+  let {collapse, height, showTitle, title} = options;
+  height = height * PANEL_HEIGHT_STEP;
+  let panels = [];
+  _.each(panelDescriptions, panelDesc => {
+    let panel = {span: panelDesc[0]};
+    if (panelDesc.length > 1) {
+      panel['height'] = panelDesc[1] * PANEL_HEIGHT_STEP;
+    }
+    panels.push(panel);
+  });
+  let row = {collapse, height, showTitle, title, panels};
+  return row;
+}
+
+function getGridPositions(dashboard: DashboardModel) {
+  return _.map(dashboard.panels, (panel: PanelModel) => {
+    return panel.gridPos;
+  });
+}

+ 0 - 131
public/app/features/dashboard/specs/dashboard_model.jest.ts

@@ -84,137 +84,6 @@ describe('DashboardModel', function() {
     });
   });
 
-  describe('when creating dashboard with old schema', function() {
-    var model;
-    var graph;
-    var singlestat;
-    var table;
-
-    beforeEach(function() {
-      model = new DashboardModel({
-        services: { filter: { time: { from: 'now-1d', to: 'now' }, list: [{}] } },
-        pulldowns: [
-          { type: 'filtering', enable: true },
-          { type: 'annotations', enable: true, annotations: [{ name: 'old' }] },
-        ],
-        panels: [
-          {
-            type: 'graph',
-            legend: true,
-            aliasYAxis: { test: 2 },
-            y_formats: ['kbyte', 'ms'],
-            grid: {
-              min: 1,
-              max: 10,
-              rightMin: 5,
-              rightMax: 15,
-              leftLogBase: 1,
-              rightLogBase: 2,
-              threshold1: 200,
-              threshold2: 400,
-              threshold1Color: 'yellow',
-              threshold2Color: 'red',
-            },
-            leftYAxisLabel: 'left label',
-            targets: [{ refId: 'A' }, {}],
-          },
-          {
-            type: 'singlestat',
-            legend: true,
-            thresholds: '10,20,30',
-            aliasYAxis: { test: 2 },
-            grid: { min: 1, max: 10 },
-            targets: [{ refId: 'A' }, {}],
-          },
-          {
-            type: 'table',
-            legend: true,
-            styles: [{ thresholds: ['10', '20', '30'] }, { thresholds: ['100', '200', '300'] }],
-            targets: [{ refId: 'A' }, {}],
-          },
-        ],
-      });
-
-      graph = model.panels[0];
-      singlestat = model.panels[1];
-      table = model.panels[2];
-    });
-
-    it('should have title', function() {
-      expect(model.title).toBe('No Title');
-    });
-
-    it('should have panel id', function() {
-      expect(graph.id).toBe(1);
-    });
-
-    it('should move time and filtering list', function() {
-      expect(model.time.from).toBe('now-1d');
-      expect(model.templating.list[0].allFormat).toBe('glob');
-    });
-
-    it('graphite panel should change name too graph', function() {
-      expect(graph.type).toBe('graph');
-    });
-
-    it('single stat panel should have two thresholds', function() {
-      expect(singlestat.thresholds).toBe('20,30');
-    });
-
-    it('queries without refId should get it', function() {
-      expect(graph.targets[1].refId).toBe('B');
-    });
-
-    it('update legend setting', function() {
-      expect(graph.legend.show).toBe(true);
-    });
-
-    it('move aliasYAxis to series override', function() {
-      expect(graph.seriesOverrides[0].alias).toBe('test');
-      expect(graph.seriesOverrides[0].yaxis).toBe(2);
-    });
-
-    it('should move pulldowns to new schema', function() {
-      expect(model.annotations.list[1].name).toBe('old');
-    });
-
-    it('table panel should only have two thresholds values', function() {
-      expect(table.styles[0].thresholds[0]).toBe('20');
-      expect(table.styles[0].thresholds[1]).toBe('30');
-      expect(table.styles[1].thresholds[0]).toBe('200');
-      expect(table.styles[1].thresholds[1]).toBe('300');
-    });
-
-    it('graph grid to yaxes options', function() {
-      expect(graph.yaxes[0].min).toBe(1);
-      expect(graph.yaxes[0].max).toBe(10);
-      expect(graph.yaxes[0].format).toBe('kbyte');
-      expect(graph.yaxes[0].label).toBe('left label');
-      expect(graph.yaxes[0].logBase).toBe(1);
-      expect(graph.yaxes[1].min).toBe(5);
-      expect(graph.yaxes[1].max).toBe(15);
-      expect(graph.yaxes[1].format).toBe('ms');
-      expect(graph.yaxes[1].logBase).toBe(2);
-
-      expect(graph.grid.rightMax).toBe(undefined);
-      expect(graph.grid.rightLogBase).toBe(undefined);
-      expect(graph.y_formats).toBe(undefined);
-    });
-
-    it('dashboard schema version should be set to latest', function() {
-      expect(model.schemaVersion).toBe(16);
-    });
-
-    it('graph thresholds should be migrated', function() {
-      expect(graph.thresholds.length).toBe(2);
-      expect(graph.thresholds[0].op).toBe('gt');
-      expect(graph.thresholds[0].value).toBe(200);
-      expect(graph.thresholds[0].fillColor).toBe('yellow');
-      expect(graph.thresholds[1].value).toBe(400);
-      expect(graph.thresholds[1].fillColor).toBe('red');
-    });
-  });
-
   describe('Given editable false dashboard', function() {
     var model;