Explorar o código

Merge branch 'develop' into develop-light-theme

Torkel Ödegaard %!s(int64=8) %!d(string=hai) anos
pai
achega
16fbefbbc5

+ 3 - 2
pkg/api/index.go

@@ -90,12 +90,13 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 	if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
 		data.NavTree = append(data.NavTree, &dtos.NavLink{
 			Text: "Create",
+			Id:   "create",
 			Icon: "fa fa-fw fa-plus",
 			Url:  "#",
 			Children: []*dtos.NavLink{
 				{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
 				{Text: "Folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboard/new/?editview=new-folder"},
-				{Text: "Import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/new/?editview=import"},
+				{Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"},
 			},
 		})
 	}
@@ -103,7 +104,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 	dashboardChildNavs := []*dtos.NavLink{
 		{Text: "Home", Url: setting.AppSubUrl + "/", Icon: "fa fa-fw fa-home", HideFromTabs: true},
 		{Divider: true, HideFromTabs: true},
-		{Text: "Manage", Id: "dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "fa fa-fw fa-sitemap"},
+		{Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "fa fa-fw fa-sitemap"},
 		{Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "fa fa-fw fa-film"},
 		{Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "icon-gf icon-gf-fw icon-gf-snapshot"},
 	}

+ 2 - 0
public/app/core/angular_wrappers.ts

@@ -1,10 +1,12 @@
 import { react2AngularDirective } from 'app/core/utils/react2angular';
 import { PasswordStrength } from './components/PasswordStrength';
 import PageHeader from './components/PageHeader';
+import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
 
 export function registerAngularDirectives() {
 
   react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
   react2AngularDirective('pageHeader', PageHeader, ['model', "noTabs"]);
+  react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
 
 }

+ 22 - 0
public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import EmptyListCTA from './EmptyListCTA';
+
+const model = {
+    title: 'Title',
+    buttonIcon: 'ga css class',
+    buttonLink: 'http://url/to/destination',
+    buttonTitle: 'Click me',
+    proTip: 'This is a tip',
+    proTipLink: 'http://url/to/tip/destination',
+    proTipLinkTitle: 'Learn more',
+    proTipTarget: '_blank'
+};
+
+describe('CollorPalette', () => {
+
+    it('renders correctly', () => {
+    const tree = renderer.create(<EmptyListCTA model={model} />).toJSON();
+    expect(tree).toMatchSnapshot();
+  });
+});

+ 34 - 0
public/app/core/components/EmptyListCTA/EmptyListCTA.tsx

@@ -0,0 +1,34 @@
+import React, { Component } from 'react';
+
+export interface IProps {
+    model: any;
+}
+
+class EmptyListCTA extends Component<IProps, any> {
+    render() {
+        const {
+            title,
+            buttonIcon,
+            buttonLink,
+            buttonTitle,
+            proTip,
+            proTipLink,
+            proTipLinkTitle,
+            proTipTarget
+        } = this.props.model;
+        return (
+            <div className="empty-list-cta p-t-2 p-b-1">
+                <div className="empty-list-cta__title">{title}</div>
+                <a href={buttonLink} className="empty-list-cta__button btn btn-xlarge btn-success"><i className={buttonIcon} />{buttonTitle}</a>
+                <div className="empty-list-cta__pro-tip">
+                    <i className="fa fa-rocket" /> ProTip: {proTip}
+                    <a className="text-link empty-list-cta__pro-tip-link"
+                        href={proTipLink}
+                        target={proTipTarget}>{proTipLinkTitle}</a>
+                </div>
+            </div>
+        );
+    }
+}
+
+export default EmptyListCTA;

+ 38 - 0
public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap

@@ -0,0 +1,38 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CollorPalette renders correctly 1`] = `
+<div
+  className="empty-list-cta p-t-2 p-b-1"
+>
+  <div
+    className="empty-list-cta__title"
+  >
+    Title
+  </div>
+  <a
+    className="empty-list-cta__button btn btn-xlarge btn-success"
+    href="http://url/to/destination"
+  >
+    <i
+      className="ga css class"
+    />
+    Click me
+  </a>
+  <div
+    className="empty-list-cta__pro-tip"
+  >
+    <i
+      className="fa fa-rocket"
+    />
+     ProTip: 
+    This is a tip
+    <a
+      className="text-link empty-list-cta__pro-tip-link"
+      href="http://url/to/tip/destination"
+      target="_blank"
+    >
+      Learn more
+    </a>
+  </div>
+</div>
+`;

+ 0 - 8
public/app/core/nav_model_srv.ts

@@ -119,14 +119,6 @@ export class NavModelSrv {
         clickHandler: () => dashNavCtrl.openEditView('annotations')
       });
 
-      if (dashboard.meta.canAdmin) {
-        menu.push({
-          title: 'Permissions...',
-          icon: 'fa fa-fw fa-lock',
-          clickHandler: () => dashNavCtrl.openEditView('permissions')
-        });
-      }
-
       if (!dashboard.meta.isHome) {
         menu.push({
           title: 'Version history',

+ 5 - 0
public/app/core/routes/routes.ts

@@ -48,6 +48,11 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
     reloadOnSearch: false,
     pageClass: 'page-dashboard',
   })
+  .when('/dashboard/import', {
+    templateUrl: 'public/app/features/dashboard/partials/dashboardImport.html',
+    controller : 'DashboardImportCtrl',
+    controllerAs: 'ctrl',
+  })
   .when('/datasources', {
     templateUrl: 'public/app/features/plugins/partials/ds_list.html',
     controller : 'DataSourcesCtrl',

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

@@ -15,7 +15,6 @@ import './unsavedChangesSrv';
 import './unsaved_changes_modal';
 import './timepicker/timepicker';
 import './upload';
-import './import/dash_import';
 import './export/export_modal';
 import './export_data/export_data_modal';
 import './ad_hoc_filters';
@@ -30,5 +29,7 @@ import './move_to_folder_modal/move_to_folder';
 import coreModule from 'app/core/core_module';
 
 import {DashboardListCtrl} from './dashboard_list_ctrl';
+import {DashboardImportCtrl} from './dashboard_import_ctrl';
 
 coreModule.controller('DashboardListCtrl', DashboardListCtrl);
+coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);

+ 6 - 19
public/app/features/dashboard/import/dash_import.ts → public/app/features/dashboard/dashboard_import_ctrl.ts

@@ -1,10 +1,8 @@
-///<reference path="../../../headers/common.d.ts" />
-
-import coreModule from 'app/core/core_module';
-import config from 'app/core/config';
 import _ from 'lodash';
+import config from 'app/core/config';
 
-export class DashImportCtrl {
+export class DashboardImportCtrl {
+  navModel: any;
   step: number;
   jsonText: string;
   parseError: string;
@@ -17,7 +15,9 @@ export class DashImportCtrl {
   gnetInfo: any;
 
   /** @ngInject */
-  constructor(private backendSrv, private $location, private $scope, $routeParams) {
+  constructor(private backendSrv, navModelSrv, private $location, private $scope, $routeParams) {
+    this.navModel = navModelSrv.getNav('create', 'import');
+
     this.step = 1;
     this.nameExists = false;
 
@@ -160,17 +160,4 @@ export class DashImportCtrl {
     this.gnetError = '';
     this.gnetInfo = '';
   }
-
-}
-
-export function dashImportDirective() {
-  return {
-    restrict: 'E',
-    templateUrl: 'public/app/features/dashboard/import/dash_import.html',
-    controller: DashImportCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-  };
 }
-
-coreModule.directive('dashImport', dashImportDirective);

+ 1 - 1
public/app/features/dashboard/dashboard_list_ctrl.ts

@@ -17,7 +17,7 @@ export class DashboardListCtrl {
 
   /** @ngInject */
   constructor(private backendSrv, navModelSrv, private $q, private searchSrv: SearchSrv) {
-    this.navModel = navModelSrv.getNav('dashboards', 'dashboards', 0);
+    this.navModel = navModelSrv.getNav('dashboards', 'manage-dashboards', 0);
     this.query = {query: '', mode: 'tree', tag: [], starred: false};
     this.selectedStarredFilter = this.starredFilterOptions[0];
 

+ 3 - 2
public/app/features/dashboard/dashboard_migration.ts

@@ -383,8 +383,8 @@ export class DashboardMigrator {
       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);
+    // Add special "row" panels if even one row is collapsed, repeated or has visible title
+    const showRows = _.some(old.rows, (row) => row.collapse || row.showTitle || row.repeat);
 
     for (let row of old.rows) {
       let height: any = row.height || DEFAULT_ROW_HEIGHT;
@@ -398,6 +398,7 @@ export class DashboardMigrator {
         rowPanel.type = 'row';
         rowPanel.title = row.title;
         rowPanel.collapsed = row.collapse;
+        rowPanel.repeat = row.repeat;
         rowPanel.panels = [];
         rowPanel.gridPos = {x: 0, y: yPos, w: GRID_COLUMN_COUNT, h: rowGridHeight};
         rowPanelModel = new PanelModel(rowPanel);

+ 133 - 38
public/app/features/dashboard/dashboard_model.ts

@@ -181,6 +181,14 @@ export class DashboardModel {
       if (panel.id > max) {
         max = panel.id;
       }
+
+      if (panel.collapsed) {
+        for (let rowPanel of panel.panels) {
+          if (rowPanel.id > max) {
+            max = rowPanel.id;
+          }
+        }
+      }
     }
 
     return max + 1;
@@ -251,16 +259,6 @@ export class DashboardModel {
       }
     }
 
-    // for (let panel of this.panels) {
-    //   if (panel.repeat) {
-    //     if (!cleanUpOnly) {
-    //       this.repeatPanel(panel);
-    //     }
-    //   } else if (panel.repeatPanelId && panel.repeatIteration !== this.iteration) {
-    //     panelsToRemove.push(panel);
-    //   }
-    // }
-
     // remove panels
     _.pull(this.panels, ...panelsToRemove);
 
@@ -274,21 +272,11 @@ export class DashboardModel {
       return sourcePanel;
     }
 
-    var clone = new PanelModel(sourcePanel.getSaveModel());
+    let clone = new PanelModel(sourcePanel.getSaveModel());
     clone.id = this.getNextPanelId();
 
-    if (sourcePanel.type === 'row') {
-      // for row clones we need to figure out panels under row to clone and where to insert clone
-      let rowPanels = this.getRowPanels(sourcePanelIndex);
-      clone.panels = _.map(rowPanels, panel => panel.getSaveModel());
-
-      // insert after preceding row's panels
-      let insertPos = sourcePanelIndex + ((rowPanels.length + 1)*valueIndex);
-      this.panels.splice(insertPos, 0, clone);
-    } else {
-      // insert after source panel + value index
-      this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
-    }
+    // insert after source panel + value index
+    this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
 
     clone.repeatIteration = this.iteration;
     clone.repeatPanelId = sourcePanel.id;
@@ -296,37 +284,60 @@ export class DashboardModel {
     return clone;
   }
 
-  getBottomYForRow() {
+  getRowRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
+    // if first clone return source
+    if (valueIndex === 0) {
+      if (!sourcePanel.collapsed) {
+        let rowPanels = this.getRowPanels(sourcePanelIndex);
+        sourcePanel.panels = rowPanels;
+      }
+      return sourcePanel;
+    }
+
+    let clone = new PanelModel(sourcePanel.getSaveModel());
+    // for row clones we need to figure out panels under row to clone and where to insert clone
+    let rowPanels, insertPos;
+    if (sourcePanel.collapsed) {
+      rowPanels = _.cloneDeep(sourcePanel.panels);
+      clone.panels = rowPanels;
+      // insert copied row after preceding row
+      insertPos = sourcePanelIndex + valueIndex;
+    } else {
+      rowPanels = this.getRowPanels(sourcePanelIndex);
+      clone.panels = _.map(rowPanels, panel => panel.getSaveModel());
+      // insert copied row after preceding row's panels
+      insertPos = sourcePanelIndex + ((rowPanels.length + 1)*valueIndex);
+    }
+    this.panels.splice(insertPos, 0, clone);
+
+    this.updateRepeatedPanelIds(clone);
+    return clone;
   }
 
   repeatPanel(panel: PanelModel, panelIndex: number) {
-    var variable = _.find(this.templating.list, {name: panel.repeat});
+    let variable = _.find(this.templating.list, {name: panel.repeat});
     if (!variable) {
       return;
     }
 
-    var selected;
-    if (variable.current.text === 'All') {
-      selected = variable.options.slice(1, variable.options.length);
-    } else {
-      selected = _.filter(variable.options, {selected: true});
+    if (panel.type === 'row') {
+      this.repeatRow(panel, panelIndex, variable);
+      return;
     }
 
+    let selectedOptions = this.getSelectedVariableOptions(variable);
     let minWidth = panel.minSpan || 6;
     let xPos = 0;
     let yPos = panel.gridPos.y;
 
-    for (let index = 0; index < selected.length; index++) {
-      var option = selected[index];
-      var copy = this.getPanelRepeatClone(panel, index, panelIndex);
+    for (let index = 0; index < selectedOptions.length; index++) {
+      let option = selectedOptions[index];
+      let copy;
 
+      copy = this.getPanelRepeatClone(panel, index, panelIndex);
       copy.scopedVars = {};
       copy.scopedVars[variable.name] = option;
 
-      if (copy.type === 'row') {
-        // place row below row panels
-      }
-
       if (panel.repeatDirection === REPEAT_DIR_VERTICAL) {
         copy.gridPos.y = yPos;
         yPos += copy.gridPos.h;
@@ -334,7 +345,7 @@ export class DashboardModel {
         // set width based on how many are selected
         // assumed the repeated panels should take up full row width
 
-        copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selected.length, minWidth);
+        copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, minWidth);
         copy.gridPos.x = xPos;
         copy.gridPos.y = yPos;
 
@@ -349,6 +360,90 @@ export class DashboardModel {
     }
   }
 
+  repeatRow(panel: PanelModel, panelIndex: number, variable) {
+    let selectedOptions = this.getSelectedVariableOptions(variable);
+    let yPos = panel.gridPos.y;
+
+    function setScopedVars(panel, variableOption) {
+      panel.scopedVars = {};
+      panel.scopedVars[variable.name] = variableOption;
+    }
+
+    for (let optionIndex = 0; optionIndex < selectedOptions.length; optionIndex++) {
+      let option = selectedOptions[optionIndex];
+      let rowCopy = this.getRowRepeatClone(panel, optionIndex, panelIndex);
+      setScopedVars(rowCopy, option);
+
+      let rowHeight = this.getRowHeight(rowCopy);
+      let rowPanels = rowCopy.panels || [];
+      let panelBelowIndex;
+
+      if (panel.collapsed) {
+        // For collapsed row just copy its panels and set scoped vars and proper IDs
+        _.each(rowPanels, (rowPanel, i) => {
+          setScopedVars(rowPanel, option);
+          if (optionIndex > 0) {
+            this.updateRepeatedPanelIds(rowPanel);
+          }
+        });
+        rowCopy.gridPos.y += optionIndex;
+        yPos += optionIndex;
+        panelBelowIndex = panelIndex + optionIndex + 1;
+      } else {
+        // insert after 'row' panel
+        let insertPos = panelIndex + ((rowPanels.length + 1) * optionIndex) + 1;
+        _.each(rowPanels, (rowPanel, i) => {
+          setScopedVars(rowPanel, option);
+          if (optionIndex > 0) {
+            let cloneRowPanel = new PanelModel(rowPanel);
+            this.updateRepeatedPanelIds(cloneRowPanel);
+            // For exposed row additionally set proper Y grid position and add it to dashboard panels
+            cloneRowPanel.gridPos.y += rowHeight * optionIndex;
+            this.panels.splice(insertPos+i, 0, cloneRowPanel);
+          }
+        });
+        rowCopy.panels = [];
+        rowCopy.gridPos.y += rowHeight * optionIndex;
+        yPos += rowHeight;
+        panelBelowIndex = insertPos+rowPanels.length;
+      }
+
+      // Update gridPos for panels below
+      for (let i = panelBelowIndex; i< this.panels.length; i++) {
+        this.panels[i].gridPos.y += yPos;
+      }
+    }
+  }
+
+  updateRepeatedPanelIds(panel: PanelModel) {
+    panel.repeatPanelId = panel.id;
+    panel.id = this.getNextPanelId();
+    panel.repeatIteration = this.iteration;
+    panel.repeat = null;
+    return panel;
+  }
+
+  getSelectedVariableOptions(variable) {
+    let selectedOptions;
+    if (variable.current.text === 'All') {
+      selectedOptions = variable.options.slice(1, variable.options.length);
+    } else {
+      selectedOptions = _.filter(variable.options, {selected: true});
+    }
+    return selectedOptions;
+  }
+
+  getRowHeight(rowPanel: PanelModel): number {
+    if (!rowPanel.panels || rowPanel.panels.length === 0) {
+      return 0;
+    }
+    const positions = _.map(rowPanel.panels, 'gridPos');
+    const maxPos = _.maxBy(positions, (pos) => {
+      return pos.y + pos.h;
+    });
+    return maxPos.h + 1;
+  }
+
   removePanel(panel: PanelModel) {
     var index = _.indexOf(this.panels, panel);
     this.panels.splice(index, 1);

+ 0 - 138
public/app/features/dashboard/import/dash_import.html

@@ -1,138 +0,0 @@
-
-	<div class="modal-header">
-		<h2 class="modal-header-title">
-			<i class="gicon gicon-dashboard-import"></i>
-			<span class="p-l-1">Import Dashboard</span>
-		</h2>
-
-		<a class="modal-header-close" ng-click="dismiss();">
-			<i class="fa fa-remove"></i>
-		</a>
-	</div>
-
-	<div class="modal-content" ng-cloak>
-		<div ng-if="ctrl.step === 1">
-
-			<form class="gf-form-group">
-				<dash-upload on-upload="ctrl.onUpload(dash)"></dash-upload>
-			</form>
-
-			<h5 class="section-heading">Grafana.com Dashboard</h5>
-
-      <div class="gf-form-group">
-				<div class="gf-form">
-					<input type="text" class="gf-form-input" ng-model="ctrl.gnetUrl" placeholder="Paste Grafana.com dashboard url or id" ng-blur="ctrl.checkGnetDashboard()"></textarea>
-				</div>
-        <div class="gf-form" ng-if="ctrl.gnetError">
-          <label class="gf-form-label text-warning">
-            <i class="fa fa-warning"></i>
-            {{ctrl.gnetError}}
-          </label>
-        </div>
-      </div>
-
-      <h5 class="section-heading">Or paste JSON</h5>
-
-			<div class="gf-form-group">
-				<div class="gf-form">
-					<textarea rows="7" data-share-panel-url="" class="gf-form-input" ng-model="ctrl.jsonText"></textarea>
-				</div>
-				<button type="button" class="btn btn-secondary" ng-click="ctrl.loadJsonText()">
-					<i class="fa fa-paste"></i>
-					Load
-				</button>
-				<span ng-if="ctrl.parseError" class="text-error p-l-1">
-					<i class="fa fa-warning"></i>
-					{{ctrl.parseError}}
-				</span>
-			</div>
-		</div>
-
-    <div ng-if="ctrl.step === 2">
-			<div class="gf-form-group" ng-if="ctrl.dash.gnetId">
-        <h3 class="section-heading">
-          Importing Dashboard from
-          <a href="https://grafana.com/dashboards/{{ctrl.dash.gnetId}}" class="external-link" target="_blank">Grafana.com</a>
-        </h3>
-
-        <div class="gf-form">
-          <label class="gf-form-label width-15">Published by</label>
-          <label class="gf-form-label width-15">{{ctrl.gnetInfo.orgName}}</label>
-        </div>
-        <div class="gf-form">
-          <label class="gf-form-label width-15">Updated on</label>
-          <label class="gf-form-label width-15">{{ctrl.gnetInfo.updatedAt | date : 'yyyy-MM-dd HH:mm:ss'}}</label>
-        </div>
-      </div>
-
-      <h3 class="section-heading">
-        Options
-      </h3>
-
-      <div class="gf-form-group">
-        <div class="gf-form-inline">
-          <div class="gf-form gf-form--grow">
-            <label class="gf-form-label width-15">Name</label>
-            <input type="text" class="gf-form-input" ng-model="ctrl.dash.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
-            <label class="gf-form-label text-success" ng-if="!ctrl.nameExists && ctrl.dash.title">
-              <i class="fa fa-check"></i>
-            </label>
-          </div>
-        </div>
-
-        <div class="gf-form-inline" ng-if="ctrl.nameExists">
-          <div class="gf-form offset-width-15 gf-form--grow">
-            <label class="gf-form-label text-warning gf-form-label--grow">
-              <i class="fa fa-warning"></i>
-              A Dashboard with the same name already exists
-            </label>
-          </div>
-        </div>
-
-        <div class="gf-form-inline" ng-if="!ctrl.dash.title">
-          <div class="gf-form offset-width-15 gf-form--grow">
-            <label class="gf-form-label text-warning gf-form-label--grow">
-              <i class="fa fa-warning"></i>
-              A Dashboard should have a name
-            </label>
-          </div>
-        </div>
-
-        <div ng-repeat="input in ctrl.inputs">
-          <div class="gf-form">
-            <label class="gf-form-label width-15">
-              {{input.label}}
-              <info-popover mode="right-normal">
-                {{input.info}}
-              </info-popover>
-            </label>
-            <!-- Data source input -->
-            <div class="gf-form-select-wrapper" style="width: 100%" ng-if="input.type === 'datasource'">
-              <select class="gf-form-input" ng-model="input.value" ng-options="v.value as v.text for v in input.options" ng-change="ctrl.inputValueChanged()">
-                <option value="" ng-hide="input.value">{{input.info}}</option>
-              </select>
-            </div>
-            <!-- Constant input -->
-            <input ng-if="input.type === 'constant'" type="text" class="gf-form-input" ng-model="input.value" placeholder="{{input.default}}" ng-change="ctrl.inputValueChanged()">
-            <label class="gf-form-label text-success" ng-show="input.value">
-              <i class="fa fa-check"></i>
-            </label>
-          </div>
-        </div>
-      </div>
-
-      <div class="gf-form-button-row">
-        <button type="button" class="btn gf-form-btn btn-success width-12" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
-          <i class="fa fa-save"></i> Import
-        </button>
-        <button type="button" class="btn gf-form-btn btn-danger width-12" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
-          <i class="fa fa-save"></i> Import (Overwrite)
-        </button>
-        <a class="btn btn-link" ng-click="dismiss()">Cancel</a>
-        <a class="btn btn-link" ng-click="ctrl.back()">Back</a>
-      </div>
-
-    </div>
-  </div>
-</div>
-

+ 126 - 0
public/app/features/dashboard/partials/dashboardImport.html

@@ -0,0 +1,126 @@
+<page-header model="ctrl.navModel"></page-header>
+
+<div class="page-container page-body" ng-cloak>
+  <div ng-if="ctrl.step === 1">
+
+    <form class="page-action-bar">
+      <div class="page-action-bar__spacer"></div>
+      <dash-upload on-upload="ctrl.onUpload(dash)"></dash-upload>
+    </form>
+
+    <h5 class="section-heading">Grafana.com Dashboard</h5>
+
+    <div class="gf-form-group">
+      <div class="gf-form gf-form--grow">
+        <input type="text" class="gf-form-input max-width-30" ng-model="ctrl.gnetUrl" placeholder="Paste Grafana.com dashboard url or id" ng-blur="ctrl.checkGnetDashboard()"></textarea>
+      </div>
+      <div class="gf-form" ng-if="ctrl.gnetError">
+        <label class="gf-form-label text-warning">
+          <i class="fa fa-warning"></i>
+          {{ctrl.gnetError}}
+        </label>
+      </div>
+    </div>
+
+    <h5 class="section-heading">Or paste JSON</h5>
+
+    <div class="gf-form-group">
+      <div class="gf-form">
+        <textarea rows="10" data-share-panel-url="" class="gf-form-input" ng-model="ctrl.jsonText"></textarea>
+      </div>
+      <button type="button" class="btn btn-secondary" ng-click="ctrl.loadJsonText()">
+        <i class="fa fa-paste"></i>
+        Load
+      </button>
+      <span ng-if="ctrl.parseError" class="text-error p-l-1">
+        <i class="fa fa-warning"></i>
+        {{ctrl.parseError}}
+      </span>
+    </div>
+  </div>
+
+  <div ng-if="ctrl.step === 2">
+    <div class="gf-form-group" ng-if="ctrl.dash.gnetId">
+      <h3 class="section-heading">
+        Importing Dashboard from
+        <a href="https://grafana.com/dashboards/{{ctrl.dash.gnetId}}" class="external-link" target="_blank">Grafana.com</a>
+      </h3>
+
+      <div class="gf-form">
+        <label class="gf-form-label width-15">Published by</label>
+        <label class="gf-form-label width-15">{{ctrl.gnetInfo.orgName}}</label>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-15">Updated on</label>
+        <label class="gf-form-label width-15">{{ctrl.gnetInfo.updatedAt | date : 'yyyy-MM-dd HH:mm:ss'}}</label>
+      </div>
+    </div>
+
+    <h3 class="section-heading">
+      Options
+    </h3>
+
+    <div class="gf-form-group">
+      <div class="gf-form-inline">
+        <div class="gf-form gf-form--grow">
+          <label class="gf-form-label width-15">Name</label>
+          <input type="text" class="gf-form-input" ng-model="ctrl.dash.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
+          <label class="gf-form-label text-success" ng-if="!ctrl.nameExists && ctrl.dash.title">
+            <i class="fa fa-check"></i>
+          </label>
+        </div>
+      </div>
+
+      <div class="gf-form-inline" ng-if="ctrl.nameExists">
+        <div class="gf-form offset-width-15 gf-form--grow">
+          <label class="gf-form-label text-warning gf-form-label--grow">
+            <i class="fa fa-warning"></i>
+            A Dashboard with the same name already exists
+          </label>
+        </div>
+      </div>
+
+      <div class="gf-form-inline" ng-if="!ctrl.dash.title">
+        <div class="gf-form offset-width-15 gf-form--grow">
+          <label class="gf-form-label text-warning gf-form-label--grow">
+            <i class="fa fa-warning"></i>
+            A Dashboard should have a name
+          </label>
+        </div>
+      </div>
+
+      <div ng-repeat="input in ctrl.inputs">
+        <div class="gf-form">
+          <label class="gf-form-label width-15">
+            {{input.label}}
+            <info-popover mode="right-normal">
+              {{input.info}}
+            </info-popover>
+          </label>
+          <!-- Data source input -->
+          <div class="gf-form-select-wrapper" style="width: 100%" ng-if="input.type === 'datasource'">
+            <select class="gf-form-input" ng-model="input.value" ng-options="v.value as v.text for v in input.options" ng-change="ctrl.inputValueChanged()">
+              <option value="" ng-hide="input.value">{{input.info}}</option>
+            </select>
+          </div>
+          <!-- Constant input -->
+          <input ng-if="input.type === 'constant'" type="text" class="gf-form-input" ng-model="input.value" placeholder="{{input.default}}" ng-change="ctrl.inputValueChanged()">
+          <label class="gf-form-label text-success" ng-show="input.value">
+            <i class="fa fa-check"></i>
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <div class="gf-form-button-row">
+      <button type="button" class="btn btn-success width-12" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
+        <i class="fa fa-save"></i> Import
+      </button>
+      <button type="button" class="btn btn-danger width-12" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
+        <i class="fa fa-save"></i> Import (Overwrite)
+      </button>
+      <a class="btn btn-link" ng-click="ctrl.back()">Cancel</a>
+    </div>
+
+  </div>
+</div>

+ 25 - 28
public/app/features/dashboard/specs/dash_import_ctrl_specs.ts → public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts

@@ -1,25 +1,24 @@
-import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+import {DashboardImportCtrl} from '../dashboard_import_ctrl';
+import config from '../../../core/config';
 
-import {DashImportCtrl} from 'app/features/dashboard/import/dash_import';
-import config from 'app/core/config';
-
-describe('DashImportCtrl', function() {
+describe('DashboardImportCtrl', function() {
   var ctx: any = {};
-  var backendSrv = {
-    search: sinon.stub().returns(Promise.resolve([])),
-    get: sinon.stub()
-  };
 
-  beforeEach(angularMocks.module('grafana.core'));
+  let navModelSrv;
+  let backendSrv;
 
-  beforeEach(angularMocks.inject(($rootScope, $controller, $q) => {
-    ctx.$q = $q;
-    ctx.scope = $rootScope.$new();
-    ctx.ctrl = $controller(DashImportCtrl, {
-      $scope: ctx.scope,
-      backendSrv: backendSrv,
-    });
-  }));
+  beforeEach(() => {
+    navModelSrv = {
+      getNav: () => {}
+    };
+
+    backendSrv = {
+      search: jest.fn().mockReturnValue(Promise.resolve([])),
+      get: jest.fn()
+    };
+
+    ctx.ctrl = new DashboardImportCtrl(backendSrv, navModelSrv, {}, {}, {});
+  });
 
   describe('when uploading json', function() {
     beforeEach(function() {
@@ -37,13 +36,13 @@ describe('DashImportCtrl', function() {
     });
 
     it('should build input model', function() {
-      expect(ctx.ctrl.inputs.length).to.eql(1);
-      expect(ctx.ctrl.inputs[0].name).to.eql('ds');
-      expect(ctx.ctrl.inputs[0].info).to.eql('Select a Test DB data source');
+      expect(ctx.ctrl.inputs.length).toBe(1);
+      expect(ctx.ctrl.inputs[0].name).toBe('ds');
+      expect(ctx.ctrl.inputs[0].info).toBe('Select a Test DB data source');
     });
 
     it('should set inputValid to false', function() {
-      expect(ctx.ctrl.inputsValid).to.eql(false);
+      expect(ctx.ctrl.inputsValid).toBe(false);
     });
   });
 
@@ -51,7 +50,7 @@ describe('DashImportCtrl', function() {
     beforeEach(function() {
       ctx.ctrl.gnetUrl = 'http://grafana.com/dashboards/123';
       // setup api mock
-      backendSrv.get = sinon.spy(() => {
+      backendSrv.get = jest.fn(() => {
         return Promise.resolve({
           json: {}
         });
@@ -60,7 +59,7 @@ describe('DashImportCtrl', function() {
     });
 
     it('should call gnet api with correct dashboard id', function() {
-      expect(backendSrv.get.getCall(0).args[0]).to.eql('api/gnet/dashboards/123');
+      expect(backendSrv.get.mock.calls[0][0]).toBe('api/gnet/dashboards/123');
     });
   });
 
@@ -68,7 +67,7 @@ describe('DashImportCtrl', function() {
     beforeEach(function() {
       ctx.ctrl.gnetUrl = '2342';
       // setup api mock
-      backendSrv.get = sinon.spy(() => {
+      backendSrv.get = jest.fn(() => {
         return Promise.resolve({
           json: {}
         });
@@ -77,10 +76,8 @@ describe('DashImportCtrl', function() {
     });
 
     it('should call gnet api with correct dashboard id', function() {
-      expect(backendSrv.get.getCall(0).args[0]).to.eql('api/gnet/dashboards/2342');
+      expect(backendSrv.get.mock.calls[0][0]).toBe('api/gnet/dashboards/2342');
     });
   });
 
 });
-
-

+ 24 - 2
public/app/features/dashboard/specs/dashboard_migration.jest.ts

@@ -2,6 +2,7 @@ import _ from 'lodash';
 import { DashboardModel } from '../dashboard_model';
 import { PanelModel } from '../panel_model';
 import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN} from 'app/core/constants';
+import { expect } from 'test/lib/common';
 
 jest.mock('app/core/services/context_srv', () => ({}));
 
@@ -315,12 +316,33 @@ describe('DashboardModel', function() {
 
       expect(panelGridPos).toEqual(expectedGrid);
     });
+
+    it('should add repeated row if repeat set', function() {
+      model.rows = [
+        createRow({showTitle: true, title: "Row", height: 8, repeat: "server"}, [[6]]),
+        createRow({height: 8}, [[12]])
+      ];
+      let dashboard = new DashboardModel(model);
+      let panelGridPos = getGridPositions(dashboard);
+      let expectedGrid = [
+        {x: 0, y: 0, w: 24, h: 8},
+        {x: 0, y: 1, w: 12, h: 8},
+        {x: 0, y: 9, w: 24, h: 8},
+        {x: 0, y: 10, w: 24, h: 8}
+      ];
+
+      expect(panelGridPos).toEqual(expectedGrid);
+      expect(dashboard.panels[0].repeat).toBe("server");
+      expect(dashboard.panels[1].repeat).toBeUndefined();
+      expect(dashboard.panels[2].repeat).toBeUndefined();
+      expect(dashboard.panels[3].repeat).toBeUndefined();
+    });
   });
 });
 
 function createRow(options, panelDescriptions: any[]) {
   const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN;
-  let {collapse, height, showTitle, title} = options;
+  let {collapse, height, showTitle, title, repeat} = options;
   height = height * PANEL_HEIGHT_STEP;
   let panels = [];
   _.each(panelDescriptions, panelDesc => {
@@ -330,7 +352,7 @@ function createRow(options, panelDescriptions: any[]) {
     }
     panels.push(panel);
   });
-  let row = {collapse, height, showTitle, title, panels};
+  let row = {collapse, height, showTitle, title, panels, repeat};
   return row;
 }
 

+ 134 - 28
public/app/features/dashboard/specs/repeat.jest.ts

@@ -1,4 +1,6 @@
+import _ from 'lodash';
 import {DashboardModel} from '../dashboard_model';
+import { expect } from 'test/lib/common';
 
 jest.mock('app/core/services/context_srv', () => ({
 
@@ -146,19 +148,19 @@ describe('given dashboard with panel repeat in vertical direction', function() {
   });
 });
 
-describe.skip('given dashboard with row repeat', function() {
-  var dashboard;
+describe('given dashboard with row repeat', function() {
+  let dashboard, dashboardJSON;
 
   beforeEach(function() {
-    dashboard = new DashboardModel({
+    dashboardJSON = {
       panels: [
-        {id: 1, type: 'row',   repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24}},
+        {id: 1, type: 'row',   gridPos: {x: 0, y: 0, h: 1 , w: 24}, repeat: 'apps'},
         {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
         {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
         {id: 4, type: 'row',   gridPos: {x: 0, y: 2, h: 1 , w: 24}},
         {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
       ],
-      templating:  {
+      templating: {
         list: [{
           name: 'apps',
           current: {
@@ -172,33 +174,137 @@ describe.skip('given dashboard with row repeat', function() {
           ]
         }]
       }
-    });
+    };
+    dashboard = new DashboardModel(dashboardJSON);
     dashboard.processRepeats();
   });
 
   it('should not repeat only row', function() {
-    expect(dashboard.panels[1].type).toBe('graph');
-  });
-  //
-  // it('should set scopedVars on panels', function() {
-  //   expect(dashboard.panels[1].scopedVars).toMatchObject({apps: {text: 'se1', value: 'se1'}})
-  // });
-  //
-  // it.skip('should repeat row and panels below two times', function() {
-  //   expect(dashboard.panels).toMatchObject([
-  //     // first (original row)
-  //     {id: 1, type: 'row',   repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24}},
-  //     {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
-  //     {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
-  //     // repeated row
-  //     {id: 1, type: 'row',   repeatPanelId: 1, gridPos: {x: 0, y: 0, h: 1 , w: 24}},
-  //     {id: 2, type: 'graph', repeatPanelId: 1, gridPos: {x: 0, y: 1, h: 1 , w: 6}},
-  //     {id: 3, type: 'graph', repeatPanelId: 1, gridPos: {x: 6, y: 1, h: 1 , w: 6}},
-  //     // row below dont touch
-  //     {id: 4, type: 'row',   gridPos: {x: 0, y: 2, h: 1 , w: 24}},
-  //     {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
-  //   ]);
-  // });
+    const panel_types = _.map(dashboard.panels, 'type');
+    expect(panel_types).toEqual([
+      'row', 'graph', 'graph',
+      'row', 'graph', 'graph',
+      'row', 'graph'
+    ]);
+  });
+
+  it('should set scopedVars for each panel', function() {
+    dashboardJSON.templating.list[0].options[2].selected = true;
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    expect(dashboard.panels[1].scopedVars).toMatchObject({apps: {text: 'se1', value: 'se1'}});
+    expect(dashboard.panels[4].scopedVars).toMatchObject({apps: {text: 'se2', value: 'se2'}});
+
+    const scopedVars = _.compact(_.map(dashboard.panels, (panel) => {
+      return panel.scopedVars ? panel.scopedVars.apps.value : null;
+    }));
+
+    expect(scopedVars).toEqual([
+      'se1', 'se1', 'se1',
+      'se2', 'se2', 'se2',
+      'se3', 'se3', 'se3',
+    ]);
+  });
+
+  it('should repeat only configured row', function() {
+    expect(dashboard.panels[6].id).toBe(4);
+    expect(dashboard.panels[7].id).toBe(5);
+  });
+
+  it('should repeat only row if it is collapsed', function() {
+    dashboardJSON.panels = [
+        {
+          id: 1, type: 'row', collapsed: true, repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24},
+          panels: [
+            {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
+            {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
+          ]
+        },
+        {id: 4, type: 'row',   gridPos: {x: 0, y: 1, h: 1 , w: 24}},
+        {id: 5, type: 'graph', gridPos: {x: 0, y: 2, h: 1 , w: 12}},
+    ];
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    const panel_types = _.map(dashboard.panels, 'type');
+    expect(panel_types).toEqual([
+      'row', 'row', 'row', 'graph'
+    ]);
+    expect(dashboard.panels[0].panels).toHaveLength(2);
+    expect(dashboard.panels[1].panels).toHaveLength(2);
+  });
+
+  it('should properly repeat multiple rows', function() {
+    dashboardJSON.panels = [
+      {id: 1, type: 'row',   gridPos: {x: 0, y: 0, h: 1 , w: 24}, repeat: 'apps'}, // repeat
+      {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
+      {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
+      {id: 4, type: 'row',   gridPos: {x: 0, y: 2, h: 1 , w: 24}}, // don't touch
+      {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
+      {id: 6, type: 'row',   gridPos: {x: 0, y: 4, h: 1 , w: 24}, repeat: 'hosts'}, // repeat
+      {id: 7, type: 'graph', gridPos: {x: 0, y: 5, h: 1 , w: 6}},
+      {id: 8, type: 'graph', gridPos: {x: 6, y: 5, h: 1 , w: 6}}
+    ];
+    dashboardJSON.templating.list.push({
+      name: 'hosts',
+      current: {
+        text: 'backend01, backend02',
+        value: ['backend01', 'backend02']
+      },
+      options: [
+        {text: 'backend01', value: 'backend01', selected: true},
+        {text: 'backend02', value: 'backend02', selected: true},
+        {text: 'backend03', value: 'backend03', selected: false}
+      ]
+    });
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    const panel_types = _.map(dashboard.panels, 'type');
+    expect(panel_types).toEqual([
+      'row', 'graph', 'graph',
+      'row', 'graph', 'graph',
+      'row', 'graph',
+      'row', 'graph', 'graph',
+      'row', 'graph', 'graph',
+    ]);
+
+    expect(dashboard.panels[0].scopedVars['apps'].value).toBe('se1');
+    expect(dashboard.panels[1].scopedVars['apps'].value).toBe('se1');
+    expect(dashboard.panels[3].scopedVars['apps'].value).toBe('se2');
+    expect(dashboard.panels[4].scopedVars['apps'].value).toBe('se2');
+    expect(dashboard.panels[8].scopedVars['hosts'].value).toBe('backend01');
+    expect(dashboard.panels[9].scopedVars['hosts'].value).toBe('backend01');
+    expect(dashboard.panels[11].scopedVars['hosts'].value).toBe('backend02');
+    expect(dashboard.panels[12].scopedVars['hosts'].value).toBe('backend02');
+  });
+
+  it('should assign unique ids for repeated panels', function() {
+    dashboardJSON.panels = [
+        {
+          id: 1, type: 'row', collapsed: true, repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24},
+          panels: [
+            {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
+            {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
+          ]
+        },
+        {id: 4, type: 'row',   gridPos: {x: 0, y: 1, h: 1 , w: 24}},
+        {id: 5, type: 'graph', gridPos: {x: 0, y: 2, h: 1 , w: 12}},
+    ];
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    const panel_ids = _.flattenDeep(_.map(dashboard.panels, (panel) => {
+      let ids = [];
+      if (panel.panels && panel.panels.length) {
+        ids = _.map(panel.panels, 'id');
+      }
+      ids.push(panel.id);
+      return ids;
+    }));
+    expect(panel_ids.length).toEqual(_.uniq(panel_ids).length);
+  });
 });
 
 

+ 1 - 1
public/app/features/dashboard/upload.ts

@@ -4,7 +4,7 @@ import coreModule from 'app/core/core_module';
 
 var template = `
 <input type="file" id="dashupload" name="dashupload" class="hide"/>
-<label class="btn btn-secondary" for="dashupload">
+<label class="btn btn-success" for="dashupload">
   <i class="fa fa-upload"></i>
   Upload .json File
 </label>

+ 46 - 35
public/app/features/plugins/partials/ds_list.html

@@ -1,46 +1,57 @@
 <page-header model="ctrl.navModel"></page-header>
 
 <div class="page-container page-body">
-  <div class="page-action-bar">
-    <div class="page-action-bar__spacer"></div>
-    <a class="page-header__cta btn btn-success" href="datasources/new">
-      <i class="fa fa-plus"></i>
-      Add data source
-    </a>
-	</div>
+	<div ng-if="ctrl.datasources.length">
+		<div class="page-action-bar">
+			<div class="page-action-bar__spacer"></div>
+			<a class="page-header__cta btn btn-success" href="datasources/new">
+				<i class="fa fa-plus"></i>
+				Add data source
+			</a>
+		</div>
 
-	<section class="card-section" layout-mode>
-		<layout-selector></layout-selector>
-		<ol class="card-list">
-			<li class="card-item-wrapper" ng-repeat="ds in ctrl.datasources">
-				<a class="card-item" href="datasources/edit/{{ds.id}}/">
-					<div class="card-item-header">
-						<div class="card-item-type">
-							{{ds.type}}
-						</div>
-					</div>
-					<div class="card-item-body">
-						<figure class="card-item-figure">
-							<img ng-src="{{ds.typeLogoUrl}}">
-						</figure>
-						<div class="card-item-details">
-							<div class="card-item-name">
-								{{ds.name}}
-								<span ng-if="ds.isDefault">
-									<span class="btn btn-secondary btn-mini">default</span>
-								</span>
+		<section class="card-section" layout-mode>
+			<layout-selector></layout-selector>
+			<ol class="card-list">
+				<li class="card-item-wrapper" ng-repeat="ds in ctrl.datasources">
+					<a class="card-item" href="datasources/edit/{{ds.id}}/">
+						<div class="card-item-header">
+							<div class="card-item-type">
+								{{ds.type}}
 							</div>
-							<div class="card-item-sub-name">
-								{{ds.url}}
+						</div>
+						<div class="card-item-body">
+							<figure class="card-item-figure">
+								<img ng-src="{{ds.typeLogoUrl}}">
+							</figure>
+							<div class="card-item-details">
+								<div class="card-item-name">
+									{{ds.name}}
+									<span ng-if="ds.isDefault">
+										<span class="btn btn-secondary btn-mini">default</span>
+									</span>
+								</div>
+								<div class="card-item-sub-name">
+									{{ds.url}}
+								</div>
 							</div>
 						</div>
-					</div>
-				</a>
-			</li>
-		</ol>
-	</section>
+					</a>
+				</li>
+			</ol>
+		</section>
+	</div>
 
 	<div ng-if="ctrl.datasources.length === 0">
-		<em>No data sources defined</em>
+		<empty-list-cta model="{
+			title: 'There are no data sources defined yet',
+			buttonIcon: 'gicon gicon-dashboard-new',
+			buttonLink: '/datasources/new',
+			buttonTitle: 'Add data source',
+			proTip: 'You can also define data sources through configuration files.',
+			proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
+			proTipLinkTitle: 'Learn more',
+			proTipTarget: '_blank'
+		}" />
 	</div>
 </div>

+ 1 - 0
public/sass/_grafana.scss

@@ -86,6 +86,7 @@
 @import "components/dashboard_grid";
 @import "components/dashboard_list";
 @import "components/page_header";
+@import "components/empty_list_cta";
 
 
 // PAGES

+ 5 - 2
public/sass/_variables.scss

@@ -218,8 +218,11 @@ $btn-font-weight:                500 !default;
 $btn-padding-x-sm:               .5rem !default;
 $btn-padding-y-sm:               .25rem !default;
 
-$btn-padding-x-lg:               1.5rem !default;
-$btn-padding-y-lg:               .75rem !default;
+$btn-padding-x-lg:               21px !default;
+$btn-padding-y-lg:               11px !default;
+
+$btn-padding-x-xl:               21px !default;
+$btn-padding-y-xl:               11px !default;
 
 $btn-border-radius:              3px;
 

+ 2 - 0
public/sass/base/_type.scss

@@ -48,6 +48,8 @@ a.text-success:hover,
 a.text-success:focus { color: darken($success-text-color, 10%); }
 a { cursor: pointer; }
 
+.text-link { text-decoration: underline; }
+
 a:focus {
   outline:0 none !important;
 }

+ 11 - 0
public/sass/components/_buttons.scss

@@ -51,10 +51,21 @@
 
 // Button Sizes
 // --------------------------------------------------
+// XLarge
+.btn-xlarge {
+  @include button-size($btn-padding-y-xl, $btn-padding-x-xl, $font-size-lg, $btn-border-radius);
+  font-weight: normal;
+  padding-bottom: $btn-padding-y-xl - 3;
+  .gicon {
+    font-size: 31px;
+    margin-right: 1rem;
+  }
+}
 
 // Large
 .btn-large {
   @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $font-size-lg, $btn-border-radius);
+  font-weight: normal;
 }
 
 .btn-small {

+ 21 - 0
public/sass/components/_empty_list_cta.scss

@@ -0,0 +1,21 @@
+.empty-list-cta {
+    background-color: $search-filter-box-bg;
+    text-align: center;
+}
+
+.empty-list-cta__title {
+    padding-bottom: 30px;
+    font-style: italic;
+}
+
+.empty-list-cta__button {
+    margin-bottom: 50px;
+}
+
+.empty-list-cta__pro-tip {
+    padding-bottom: 20px;
+}
+
+.empty-list-cta__pro-tip-link {
+    margin-left: 5px;
+}

+ 1 - 1
public/sass/components/_tabbed_view.scss

@@ -26,7 +26,7 @@
 
 .tabbed-view-panel-title {
   float: left;
-  padding-top: 1rem;
+  padding-top: 9px;
   margin: 0 2rem 0 0;
 }