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

Merge remote-tracking branch 'upstream/develop' into graph-legend-v5

Alexander Zobnin 8 лет назад
Родитель
Сommit
9bff005faf
51 измененных файлов с 1325 добавлено и 504 удалено
  1. 3 2
      pkg/api/index.go
  2. 2 4
      public/app/core/angular_wrappers.ts
  3. 41 4
      public/app/core/components/PageHeader/PageHeader.tsx
  4. 2 1
      public/app/core/components/grafana_app.ts
  5. 5 30
      public/app/core/components/search/search.html
  6. 335 0
      public/app/core/components/search/search.jest.ts
  7. 88 17
      public/app/core/components/search/search.ts
  8. 47 0
      public/app/core/components/search/search_results.html
  9. 75 0
      public/app/core/components/search/search_results.jest.ts
  10. 70 0
      public/app/core/components/search/search_results.ts
  11. 3 1
      public/app/core/core.ts
  12. 0 8
      public/app/core/nav_model_srv.ts
  13. 10 0
      public/app/core/routes/routes.ts
  14. 1 0
      public/app/core/services/all.js
  15. 21 0
      public/app/core/services/global_event_srv.ts
  16. 19 13
      public/app/core/services/search_srv.ts
  17. 40 0
      public/app/core/specs/search_srv.jest.ts
  18. 0 3
      public/app/core/utils/react2angular.ts
  19. 2 1
      public/app/features/dashboard/all.ts
  20. 6 19
      public/app/features/dashboard/dashboard_import_ctrl.ts
  21. 24 17
      public/app/features/dashboard/dashboard_list_ctrl.ts
  22. 3 2
      public/app/features/dashboard/dashboard_migration.ts
  23. 0 138
      public/app/features/dashboard/import/dash_import.html
  24. 126 0
      public/app/features/dashboard/partials/dashboardImport.html
  25. 57 67
      public/app/features/dashboard/partials/dashboardList.html
  26. 25 28
      public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts
  27. 1 4
      public/app/features/dashboard/specs/dashboard_list_ctrl.jest.ts
  28. 24 2
      public/app/features/dashboard/specs/dashboard_migration.jest.ts
  29. 1 1
      public/app/features/dashboard/upload.ts
  30. 13 1
      public/app/features/plugins/ds_list_ctrl.ts
  31. 1 1
      public/app/features/plugins/partials/ds_list.html
  32. 57 52
      public/app/partials/error.html
  33. 10 1
      public/sass/_variables.dark.scss
  34. 54 43
      public/sass/_variables.light.scss
  35. 3 3
      public/sass/_variables.scss
  36. 15 0
      public/sass/base/_icons.scss
  37. 1 1
      public/sass/components/_buttons.scss
  38. 2 3
      public/sass/components/_cards.scss
  39. 1 1
      public/sass/components/_dashboard_grid.scss
  40. 10 4
      public/sass/components/_dashboard_list.scss
  41. 1 2
      public/sass/components/_filter-table.scss
  42. 3 3
      public/sass/components/_navbar.scss
  43. 15 0
      public/sass/components/_page_header.scss
  44. 1 1
      public/sass/components/_panel_pluginlist.scss
  45. 5 19
      public/sass/components/_search.scss
  46. 2 1
      public/sass/components/_sidemenu.scss
  47. 45 0
      public/sass/components/_switch.scss
  48. 1 1
      public/sass/components/_tabbed_view.scss
  49. 14 2
      public/sass/components/_tabs.scss
  50. 6 2
      public/sass/layout/_page.scss
  51. 34 1
      public/sass/pages/_errorpage.scss

+ 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 - 4
public/app/core/angular_wrappers.ts

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

+ 41 - 4
public/app/core/components/PageHeader.tsx → public/app/core/components/PageHeader/PageHeader.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
-import { NavModel, NavModelItem } from '../nav_model_srv';
+import { NavModel, NavModelItem } from '../../nav_model_srv';
 import classNames from 'classnames';
+import appEvents from 'app/core/app_events';
 
 export interface IProps {
   model: NavModel;
@@ -26,8 +27,44 @@ function TabItem(tab: NavModelItem) {
   );
 }
 
-function Tabs({main}: {main: NavModelItem}) {
-  return <ul className="gf-tabs">{main.children.map(TabItem)}</ul>;
+function SelectOption(navItem: NavModelItem) {
+  if (navItem.hideFromTabs) { // TODO: Rename hideFromTabs => hideFromNav
+    return (null);
+  }
+
+  return (
+    <option key={navItem.url} value={navItem.url}>
+      {navItem.text}
+    </option>
+  );
+}
+
+function Navigation({main}: {main: NavModelItem}) {
+  return (<nav>
+    <SelectNav customCss="page-header__select_nav" main={main} />
+    <Tabs customCss="page-header__tabs" main={main} />
+  </nav>);
+}
+
+function SelectNav({main, customCss}: {main: NavModelItem, customCss: string}) {
+  const defaultSelectedItem = main.children.find(navItem => {
+    return navItem.active === true;
+  });
+
+  const gotoUrl = evt => {
+    var element = evt.target;
+    var url = element.options[element.selectedIndex].value;
+    appEvents.emit('location-change', {href: url});
+  };
+
+  return (<select
+    className={`gf-select-nav ${customCss}`}
+    defaultValue={defaultSelectedItem.url}
+    onChange={gotoUrl}>{main.children.map(SelectOption)}</select>);
+}
+
+function Tabs({main, customCss}: {main: NavModelItem, customCss: string}) {
+  return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
 }
 
 export default class PageHeader extends React.Component<IProps, any> {
@@ -63,7 +100,7 @@ export default class PageHeader extends React.Component<IProps, any> {
         <div className="page-container">
           <div className="page-header">
             {this.renderHeaderTitle(this.props.model.main)}
-            {this.props.model.main.children && <Tabs main={this.props.model.main} />}
+            {this.props.model.main.children && <Navigation main={this.props.model.main} />}
           </div>
         </div>
       </div>

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

@@ -12,7 +12,7 @@ import Drop from 'tether-drop';
 export class GrafanaCtrl {
 
   /** @ngInject */
-  constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv) {
+  constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, globalEventSrv) {
 
     $scope.init = function() {
       $scope.contextSrv = contextSrv;
@@ -23,6 +23,7 @@ export class GrafanaCtrl {
       profiler.init(config, $rootScope);
       alertSrv.init();
       utilSrv.init();
+      globalEventSrv.init();
 
       $scope.dashAlerts = alertSrv;
     };

+ 5 - 30
public/app/core/components/search/search.html

@@ -20,37 +20,12 @@
 
 	<div class="search-dropdown">
     <div class="search-dropdown__col_1">
-      <div class="search-results-container" grafana-scrollbar>
-        <h6 ng-show="!ctrl.isLoading && results.length">No dashboards matching your query were found.</h6>
-
-        <div ng-repeat="section in ctrl.results" class="search-section">
-          <a class="search-section__header pointer" ng-hide="section.hideHeader" ng-click="ctrl.toggleFolder(section)">
-            <i class="search-section__header__icon" ng-class="section.icon"></i>
-            <span class="search-section__header__text">{{::section.title}}</span>
-            <i class="fa fa-minus search-section__header__toggle" ng-show="section.expanded"></i>
-            <i class="fa fa-plus search-section__header__toggle" ng-hide="section.expanded"></i>
-          </a>
-
-          <div ng-if="section.expanded">
-            <a ng-repeat="item in section.items" class="search-item" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}">
-              <span class="search-item__icon">
-                <i class="fa fa-th-large"></i>
-              </span>
-              <span class="search-item__body">
-                <div class="search-item__body-title">{{::item.title}}</div>
-                <div class="search-item__body-sub-title" ng-show="item.folderTitle && section.hideHeader">
-                  {{::item.folderTitle}}
-                </div>
-              </span>
-              <span class="search-item__tags">
-                <span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in item.tags" tag-color-from-name="tag" class="label label-tag">
-                  {{tag}}
-                </span>
-              </span>
-            </a>
-          </div>
+        <div class="search-results-container" grafana-scrollbar>
+            <h6 ng-show="!ctrl.isLoading && ctrl.results.length === 0">No dashboards matching your query were found.</h6>
+            <dashboard-search-results
+              results="ctrl.results"
+              on-tag-selected="ctrl.filterByTag($tag)" />
         </div>
-      </div>
     </div>
 
     <div class="search-dropdown__col_2">

+ 335 - 0
public/app/core/components/search/search.jest.ts

@@ -0,0 +1,335 @@
+import { SearchCtrl } from './search';
+import { SearchSrv } from 'app/core/services/search_srv';
+
+describe('SearchCtrl', () => {
+  const searchSrvStub = {
+    search: (options: any) => {},
+    getDashboardTags: () => {}
+  };
+  let ctrl = new SearchCtrl({}, {}, {}, <SearchSrv>searchSrvStub, { onAppEvent: () => { } });
+
+  describe('Given an empty result', () => {
+    beforeEach(() => {
+      ctrl.results = [];
+    });
+
+    describe('When navigating down one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(1);
+      });
+
+      it('should not navigate', () => {
+        expect(ctrl.selectedIndex).toBe(0);
+      });
+    });
+
+    describe('When navigating up one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(-1);
+      });
+
+      it('should not navigate', () => {
+        expect(ctrl.selectedIndex).toBe(0);
+      });
+    });
+  });
+
+  describe('Given a result of one selected collapsed folder with no dashboards and a root folder with 2 dashboards', () => {
+    beforeEach(() => {
+      ctrl.results = [
+        {
+          id: 1,
+          title: 'folder',
+          items: [],
+          selected: true,
+          expanded: false,
+          toggle: (i) => i.expanded = !i.expanded
+        },
+        {
+          id: 0,
+          title: 'Root',
+          items: [
+            { id: 3, selected: false },
+            { id: 5, selected: false }
+          ],
+          selected: false,
+          expanded: true,
+          toggle: (i) => i.expanded = !i.expanded
+        }
+      ];
+    });
+
+    describe('When navigating down one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(1);
+      });
+
+      it('should select first dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeTruthy();
+        expect(ctrl.results[1].items[1].selected).toBeFalsy();
+      });
+    });
+
+    describe('When navigating down two steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+      });
+
+      it('should select last dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeFalsy();
+        expect(ctrl.results[1].items[1].selected).toBeTruthy();
+      });
+    });
+
+    describe('When navigating down three steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+      });
+
+      it('should select first folder', () => {
+        expect(ctrl.results[0].selected).toBeTruthy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeFalsy();
+        expect(ctrl.results[1].items[1].selected).toBeFalsy();
+      });
+    });
+
+    describe('When navigating up one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(-1);
+      });
+
+      it('should select last dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeFalsy();
+        expect(ctrl.results[1].items[1].selected).toBeTruthy();
+      });
+    });
+
+    describe('When navigating up two steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(-1);
+        ctrl.moveSelection(-1);
+      });
+
+      it('should select first dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeTruthy();
+        expect(ctrl.results[1].items[1].selected).toBeFalsy();
+      });
+    });
+  });
+
+  describe('Given a result of one selected collapsed folder with 2 dashboards and a root folder with 2 dashboards', () => {
+    beforeEach(() => {
+      ctrl.results = [
+        {
+          id: 1,
+          title: 'folder',
+          items: [
+            { id: 2, selected: false },
+            { id: 4, selected: false }
+          ],
+          selected: true,
+          expanded: false,
+          toggle: (i) => i.expanded = !i.expanded
+        },
+        {
+          id: 0,
+          title: 'Root',
+          items: [
+            { id: 3, selected: false },
+            { id: 5, selected: false }
+          ],
+          selected: false,
+          expanded: true,
+          toggle: (i) => i.expanded = !i.expanded
+        }
+      ];
+    });
+
+    describe('When navigating down one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(1);
+      });
+
+      it('should select first dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeTruthy();
+        expect(ctrl.results[1].items[1].selected).toBeFalsy();
+      });
+    });
+
+    describe('When navigating down two steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+      });
+
+      it('should select last dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeFalsy();
+        expect(ctrl.results[1].items[1].selected).toBeTruthy();
+      });
+    });
+
+    describe('When navigating down three steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+      });
+
+      it('should select first folder', () => {
+        expect(ctrl.results[0].selected).toBeTruthy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeFalsy();
+        expect(ctrl.results[1].items[1].selected).toBeFalsy();
+      });
+    });
+
+    describe('When navigating up one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(-1);
+      });
+
+      it('should select last dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeFalsy();
+        expect(ctrl.results[1].items[1].selected).toBeTruthy();
+      });
+    });
+
+    describe('When navigating up two steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 0;
+        ctrl.moveSelection(-1);
+        ctrl.moveSelection(-1);
+      });
+
+      it('should select first dashboard in root folder', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[1].selected).toBeFalsy();
+        expect(ctrl.results[1].items[0].selected).toBeTruthy();
+        expect(ctrl.results[1].items[1].selected).toBeFalsy();
+      });
+    });
+  });
+
+  describe('Given a result of a search with 2 dashboards where the first is selected', () => {
+    beforeEach(() => {
+      ctrl.results = [
+        {
+          hideHeader: true,
+          items: [
+            { id: 3, selected: true },
+            { id: 5, selected: false }
+          ],
+          selected: false,
+          expanded: true,
+          toggle: (i) => i.expanded = !i.expanded
+        }
+      ];
+    });
+
+    describe('When navigating down one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 1;
+        ctrl.moveSelection(1);
+      });
+
+      it('should select last dashboard', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[1].selected).toBeTruthy();
+      });
+    });
+
+    describe('When navigating down two steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 1;
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+      });
+
+      it('should select first dashboard', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeTruthy();
+        expect(ctrl.results[0].items[1].selected).toBeFalsy();
+      });
+    });
+
+    describe('When navigating down three steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 1;
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+        ctrl.moveSelection(1);
+      });
+
+      it('should select last dashboard', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[1].selected).toBeTruthy();
+      });
+    });
+
+    describe('When navigating up one step', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 1;
+        ctrl.moveSelection(-1);
+      });
+
+      it('should select last dashboard', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[1].selected).toBeTruthy();
+      });
+    });
+
+    describe('When navigating up two steps', () => {
+      beforeEach(() => {
+        ctrl.selectedIndex = 1;
+        ctrl.moveSelection(-1);
+        ctrl.moveSelection(-1);
+      });
+
+      it('should select first dashboard', () => {
+        expect(ctrl.results[0].selected).toBeFalsy();
+        expect(ctrl.results[0].items[0].selected).toBeTruthy();
+        expect(ctrl.results[0].items[1].selected).toBeFalsy();
+      });
+    });
+  });
+});

+ 88 - 17
public/app/core/components/search/search.ts

@@ -64,18 +64,70 @@ export class SearchCtrl {
       this.moveSelection(-1);
     }
     if (evt.keyCode === 13) {
-      var selectedDash = this.results[this.selectedIndex];
-      if (selectedDash) {
-        this.$location.search({});
-        this.$location.path(selectedDash.url);
+      const flattenedResult = this.getFlattenedResultForNavigation();
+      const currentItem = flattenedResult[this.selectedIndex];
+
+      if (currentItem) {
+        if (currentItem.dashboardIndex !== undefined) {
+          const selectedDash = this.results[currentItem.folderIndex].items[currentItem.dashboardIndex];
+
+          if (selectedDash) {
+            this.$location.search({});
+            this.$location.path(selectedDash.url);
+          }
+        } else {
+          const selectedFolder = this.results[currentItem.folderIndex];
+
+          if (selectedFolder) {
+            selectedFolder.toggle(selectedFolder);
+          }
+        }
       }
     }
   }
 
   moveSelection(direction) {
-    var max = (this.results || []).length;
-    var newIndex = this.selectedIndex + direction;
+    if (this.results.length === 0) {
+      return;
+    }
+
+    const flattenedResult = this.getFlattenedResultForNavigation();
+    const currentItem = flattenedResult[this.selectedIndex];
+
+    if (currentItem) {
+      if (currentItem.dashboardIndex !== undefined) {
+        this.results[currentItem.folderIndex].items[currentItem.dashboardIndex].selected = false;
+      } else {
+        this.results[currentItem.folderIndex].selected = false;
+      }
+    }
+
+    const max = flattenedResult.length;
+    let newIndex = this.selectedIndex + direction;
     this.selectedIndex = ((newIndex %= max) < 0) ? newIndex + max : newIndex;
+    const selectedItem = flattenedResult[this.selectedIndex];
+
+    if (selectedItem.dashboardIndex === undefined && this.results[selectedItem.folderIndex].id === 0) {
+      this.moveSelection(direction);
+      return;
+    }
+
+    if (selectedItem.dashboardIndex !== undefined) {
+      if (!this.results[selectedItem.folderIndex].expanded) {
+        this.moveSelection(direction);
+        return;
+      }
+
+      this.results[selectedItem.folderIndex].items[selectedItem.dashboardIndex].selected = true;
+      return;
+    }
+
+    if (this.results[selectedItem.folderIndex].hideHeader) {
+      this.moveSelection(direction);
+      return;
+    }
+
+    this.results[selectedItem.folderIndex].selected = true;
   }
 
   searchDashboards() {
@@ -84,8 +136,9 @@ export class SearchCtrl {
 
     return this.searchSrv.search(this.query).then(results => {
       if (localSearchId < this.currentSearchId) { return; }
-      this.results = results;
+      this.results = results || [];
       this.isLoading = false;
+      this.moveSelection(1);
     });
   }
 
@@ -94,13 +147,11 @@ export class SearchCtrl {
     return query.query === '' && query.starred === false && query.tag.length === 0;
   }
 
-  filterByTag(tag, evt) {
-    this.query.tag.push(tag);
-    this.search();
-    this.giveSearchFocus = this.giveSearchFocus + 1;
-    if (evt) {
-      evt.stopPropagation();
-      evt.preventDefault();
+  filterByTag(tag) {
+    if (_.indexOf(this.query.tag, tag) === -1) {
+      this.query.tag.push(tag);
+      this.search();
+      this.giveSearchFocus = this.giveSearchFocus + 1;
     }
   }
 
@@ -127,12 +178,32 @@ export class SearchCtrl {
 
   search() {
     this.showImport = false;
-    this.selectedIndex = 0;
+    this.selectedIndex = -1;
     this.searchDashboards();
   }
 
-  toggleFolder(section) {
-    this.searchSrv.toggleSection(section);
+  private getFlattenedResultForNavigation() {
+    let folderIndex = 0;
+
+    return _.flatMap(this.results, (s) => {
+      let result = [];
+
+      result.push({
+        folderIndex: folderIndex
+      });
+
+      let dashboardIndex = 0;
+
+      result = result.concat(_.map(s.items || [], (i) => {
+        return {
+          folderIndex: folderIndex,
+          dashboardIndex: dashboardIndex++
+        };
+      }));
+
+      folderIndex++;
+      return result;
+    });
   }
 }
 

+ 47 - 0
public/app/core/components/search/search_results.html

@@ -0,0 +1,47 @@
+<div ng-repeat="section in ctrl.results" class="search-section">
+  <a class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)">
+    <div ng-click="ctrl.toggleSelection(section, $event)">
+      <gf-form-switch
+        ng-show="ctrl.editable"
+        on-change="ctrl.selectionChanged($event)"
+        checked="section.checked"
+        switch-class="gf-form-switch--transparent gf-form-switch--search-result__section">
+      </gf-form-switch>
+    </div>
+    <i class="search-section__header__icon" ng-class="section.icon"></i>
+    <span class="search-section__header__text">{{::section.title}}</span>
+    <div ng-show="ctrl.editable && section.id > 0 && section.expanded" ng-click="ctrl.navigateToFolder(section, $event)">
+        <i class="fa fa-cog search-section__header__toggle"></i>&nbsp;
+    </div>
+    <i class="fa fa-minus search-section__header__toggle" ng-show="section.expanded"></i>
+    <i class="fa fa-plus search-section__header__toggle" ng-hide="section.expanded"></i>
+  </a>
+  <div class="search-section__header" ng-show="section.hideHeader"></div>
+
+  <div ng-if="section.expanded">
+    <a ng-repeat="item in section.items" class="search-item" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}">
+      <div ng-click="ctrl.toggleSelection(item, $event)">
+        <gf-form-switch
+          ng-show="ctrl.editable"
+          on-change="ctrl.selectionChanged()"
+          checked="item.checked"
+          switch-class="gf-form-switch--transparent gf-form-switch--search-result__item">
+        </gf-form-switch>
+      </div>
+      <span class="search-item__icon">
+        <i class="fa fa-th-large"></i>
+      </span>
+      <span class="search-item__body">
+        <div class="search-item__body-title">{{::item.title}}</div>
+        <div class="search-item__body-sub-title" ng-show="item.folderTitle && section.hideHeader">
+          {{::item.folderTitle}}
+        </div>
+      </span>
+      <span class="search-item__tags">
+        <span ng-click="ctrl.selectTag(tag, $event)" ng-repeat="tag in item.tags" tag-color-from-name="tag" class="label label-tag">
+          {{tag}}
+        </span>
+      </span>
+    </a>
+  </div>
+</div>

+ 75 - 0
public/app/core/components/search/search_results.jest.ts

@@ -0,0 +1,75 @@
+import { SearchResultsCtrl } from './search_results';
+
+describe('SearchResultsCtrl', () => {
+  let ctrl;
+
+  describe('when checking an item that is not checked', () => {
+    let item = {checked: false};
+    let selectionChanged = false;
+
+    beforeEach(() => {
+      ctrl = new SearchResultsCtrl({});
+      ctrl.onSelectionChanged = () => selectionChanged = true;
+      ctrl.toggleSelection(item);
+    });
+
+    it('should set checked to true', () => {
+      expect(item.checked).toBeTruthy();
+    });
+
+    it('should trigger selection changed callback', () => {
+      expect(selectionChanged).toBeTruthy();
+    });
+  });
+
+  describe('when checking an item that is checked', () => {
+    let item = {checked: true};
+    let selectionChanged = false;
+
+    beforeEach(() => {
+      ctrl = new SearchResultsCtrl({});
+      ctrl.onSelectionChanged = () => selectionChanged = true;
+      ctrl.toggleSelection(item);
+    });
+
+    it('should set checked to false', () => {
+      expect(item.checked).toBeFalsy();
+    });
+
+    it('should trigger selection changed callback', () => {
+      expect(selectionChanged).toBeTruthy();
+    });
+  });
+
+  describe('when selecting a tag', () => {
+    let selectedTag = null;
+
+    beforeEach(() => {
+      ctrl = new SearchResultsCtrl({});
+      ctrl.onTagSelected = (tag) => selectedTag = tag;
+      ctrl.selectTag('tag-test');
+    });
+
+    it('should trigger tag selected callback', () => {
+      expect(selectedTag["$tag"]).toBe('tag-test');
+    });
+  });
+
+  describe('when toggle a folder', () => {
+    let folderToggled = false;
+    let folder = {
+      toggle: () => {
+        folderToggled = true;
+      }
+    };
+
+    beforeEach(() => {
+      ctrl = new SearchResultsCtrl({});
+      ctrl.toggleFolderExpand(folder);
+    });
+
+    it('should trigger folder toggle callback', () => {
+      expect(folderToggled).toBeTruthy();
+    });
+  });
+});

+ 70 - 0
public/app/core/components/search/search_results.ts

@@ -0,0 +1,70 @@
+// import _ from 'lodash';
+import coreModule from '../../core_module';
+
+export class SearchResultsCtrl {
+  results: any;
+  onSelectionChanged: any;
+  onTagSelected: any;
+
+  /** @ngInject */
+  constructor(private $location) {
+
+  }
+
+  toggleFolderExpand(section) {
+    if (section.toggle) {
+      section.toggle(section);
+    }
+  }
+
+  navigateToFolder(section, evt) {
+    this.$location.path('/dashboards/folder/' + section.id + '/' + section.uri);
+
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+  }
+
+  toggleSelection(item, evt) {
+    item.checked = !item.checked;
+
+    if (this.onSelectionChanged) {
+      this.onSelectionChanged();
+    }
+
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+  }
+
+  selectTag(tag, evt) {
+    if (this.onTagSelected) {
+      this.onTagSelected({$tag: tag});
+    }
+
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+  }
+}
+
+export function searchResultsDirective() {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/core/components/search/search_results.html',
+    controller: SearchResultsCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      editable: '@',
+      results: '=',
+      onSelectionChanged: '&',
+      onTagSelected: '&'
+    },
+  };
+}
+
+coreModule.directive('dashboardSearchResults', searchResultsDirective);

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

@@ -54,6 +54,7 @@ import {profiler} from './profiler';
 import {registerAngularDirectives} from './angular_wrappers';
 import {updateLegendValues} from './time_series2';
 import TimeSeries from './time_series2';
+import {searchResultsDirective} from './components/search/search_results';
 
 export {
   profiler,
@@ -87,5 +88,6 @@ export {
   gfPageDirective,
   orgSwitcher,
   TimeSeries,
-  updateLegendValues
+  updateLegendValues,
+  searchResultsDirective
 };

+ 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',

+ 10 - 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',
@@ -68,6 +73,11 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
     controller : 'DashboardListCtrl',
     controllerAs: 'ctrl',
   })
+  .when('/dashboards/folder/:folderId/:type/:slug', {
+    templateUrl: 'public/app/features/dashboard/partials/dashboardList.html',
+    controller : 'DashboardListCtrl',
+    controllerAs: 'ctrl',
+  })
   .when('/org', {
     templateUrl: 'public/app/features/org/partials/orgDetails.html',
     controller : 'OrgDetailsCtrl',

+ 1 - 0
public/app/core/services/all.js

@@ -8,5 +8,6 @@ define([
   './segment_srv',
   './backend_srv',
   './dynamic_directive_srv',
+  './global_event_srv'
 ],
 function () {});

+ 21 - 0
public/app/core/services/global_event_srv.ts

@@ -0,0 +1,21 @@
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+
+// This service is for registering global events.
+// Good for communication react > angular and vice verse
+export class GlobalEventSrv {
+
+  /** @ngInject */
+  constructor(private $location, private $timeout) {
+  }
+
+  init() {
+    appEvents.on('location-change', payload => {
+        this.$timeout(() => { // A hack to use timeout when we're changing things (in this case the url) from outside of Angular.
+            this.$location.path(payload.href);
+        });
+    });
+  }
+}
+
+coreModule.service('globalEventSrv', GlobalEventSrv);

+ 19 - 13
public/app/core/services/search_srv.ts

@@ -128,14 +128,20 @@ export class SearchSrv {
     });
   }
 
-  private browse() {
+  private browse(options) {
     let sections: any = {};
 
-    let promises = [
-      this.getRecentDashboards(sections),
-      this.getStarred(sections),
-      this.getDashboardsAndFolders(sections),
-    ];
+    let promises = [];
+
+    if (!options.skipRecent) {
+      promises.push(this.getRecentDashboards(sections));
+    }
+
+    if (!options.skipStarred) {
+      promises.push(this.getStarred(sections));
+    }
+
+    promises.push(this.getDashboardsAndFolders(sections));
 
     return this.$q.all(promises).then(() => {
       return _.sortBy(_.values(sections), 'score');
@@ -148,15 +154,19 @@ export class SearchSrv {
   }
 
   search(options) {
-    if (!options.query && (!options.tag || options.tag.length === 0) && !options.starred) {
-      return this.browse();
+    if (!options.folderIds && !options.query && (!options.tag || options.tag.length === 0) && !options.starred) {
+      return this.browse(options);
     }
 
     let query = _.clone(options);
-    query.folderIds = [];
+    query.folderIds = options.folderIds || [];
     query.type = 'dash-db';
 
     return this.backendSrv.search(query).then(results => {
+      if (results.length === 0) {
+        return results;
+      }
+
       let section = {
         hideHeader: true,
         items: [],
@@ -191,10 +201,6 @@ export class SearchSrv {
     });
   }
 
-  toggleSection(section) {
-    section.toggle(section);
-  }
-
   getDashboardTags() {
     return this.backendSrv.get('/api/dashboards/tags');
   }

+ 40 - 0
public/app/core/specs/search_srv.jest.ts

@@ -2,6 +2,7 @@ import { SearchSrv } from 'app/core/services/search_srv';
 import { BackendSrvMock } from 'test/mocks/backend_srv';
 import impressionSrv from 'app/core/services/impression_srv';
 import { contextSrv } from 'app/core/services/context_srv';
+import { beforeEach } from 'test/lib/common';
 
 jest.mock('app/core/store', () => {
   return {
@@ -244,4 +245,43 @@ describe('SearchSrv', () => {
       expect(backendSrvMock.search.mock.calls[0][0].starred).toEqual(true);
     });
   });
+
+  describe('when skipping recent dashboards', () => {
+    let getRecentDashboardsCalled = false;
+
+    beforeEach(() => {
+      backendSrvMock.search = jest.fn();
+      backendSrvMock.search.mockReturnValue(Promise.resolve([]));
+
+      searchSrv.getRecentDashboards = () => {
+        getRecentDashboardsCalled = true;
+      };
+
+      return searchSrv.search({ skipRecent: true }).then(() => {});
+    });
+
+    it('should not fetch recent dashboards', () => {
+      expect(getRecentDashboardsCalled).toBeFalsy();
+    });
+  });
+
+  describe('when skipping starred dashboards', () => {
+    let getStarredCalled = false;
+
+    beforeEach(() => {
+      backendSrvMock.search = jest.fn();
+      backendSrvMock.search.mockReturnValue(Promise.resolve([]));
+      impressionSrv.getDashboardOpened = jest.fn().mockReturnValue([]);
+
+      searchSrv.getStarred = () => {
+        getStarredCalled = true;
+      };
+
+      return searchSrv.search({ skipStarred: true }).then(() => {});
+    });
+
+    it('should not fetch starred dashboards', () => {
+      expect(getStarredCalled).toBeFalsy();
+    });
+  });
 });

+ 0 - 3
public/app/core/utils/react2angular.ts

@@ -1,10 +1,7 @@
 import coreModule from 'app/core/core_module';
 
 export function react2AngularDirective(name: string, component: any, options: any) {
-
   coreModule.directive(name, ['reactDirective', reactDirective => {
     return reactDirective(component, options);
   }]);
-
 }
-

+ 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);

+ 24 - 17
public/app/features/dashboard/dashboard_list_ctrl.ts

@@ -14,16 +14,29 @@ export class DashboardListCtrl {
   selectAllChecked = false;
   starredFilterOptions = [{text: 'Filter by Starred', disabled: true}, {text: 'Yes'}, {text: 'No'}];
   selectedStarredFilter: any;
+  folderTitle = null;
 
   /** @ngInject */
-  constructor(private backendSrv, navModelSrv, private $q, private searchSrv: SearchSrv) {
-    this.navModel = navModelSrv.getNav('dashboards', 'dashboards', 0);
-    this.query = {query: '', mode: 'tree', tag: [], starred: false};
+  constructor(private backendSrv, navModelSrv, private $q, private searchSrv: SearchSrv, private $routeParams) {
+    this.navModel = navModelSrv.getNav('dashboards', 'manage-dashboards', 0);
+    this.query = {query: '', mode: 'tree', tag: [], starred: false, skipRecent: true, skipStarred: true};
+
     this.selectedStarredFilter = this.starredFilterOptions[0];
 
-    this.getDashboards().then(() => {
-      this.getTags();
-    });
+    if (this.$routeParams.folderId && this.$routeParams.type && this.$routeParams.slug) {
+      backendSrv.getDashboard(this.$routeParams.type, this.$routeParams.slug).then(result => {
+        this.folderTitle = result.dashboard.title;
+        this.query.folderIds = [result.dashboard.id];
+
+        this.getDashboards().then(() => {
+          this.getTags();
+        });
+      });
+    } else {
+      this.getDashboards().then(() => {
+        this.getTags();
+      });
+    }
   }
 
   getDashboards() {
@@ -137,10 +150,6 @@ export class DashboardListCtrl {
     });
   }
 
-  toggleFolder(section) {
-    return this.searchSrv.toggleSection(section);
-  }
-
   getTags() {
     return this.searchSrv.getDashboardTags().then((results) => {
       this.tagFilterOptions =  [{ term: 'Filter By Tag', disabled: true }].concat(results);
@@ -148,11 +157,9 @@ export class DashboardListCtrl {
     });
   }
 
-  filterByTag(tag, evt) {
-    this.query.tag.push(tag);
-    if (evt) {
-      evt.stopPropagation();
-      evt.preventDefault();
+  filterByTag(tag) {
+    if (_.indexOf(this.query.tag, tag) === -1) {
+      this.query.tag.push(tag);
     }
 
     return this.getDashboards();
@@ -163,9 +170,9 @@ export class DashboardListCtrl {
   }
 
   onTagFilterChange() {
-    this.query.tag.push(this.selectedTagFilter.term);
+    var res = this.filterByTag(this.selectedTagFilter.term);
     this.selectedTagFilter = this.tagFilterOptions[0];
-    return this.getDashboards();
+    return res;
   }
 
   removeTag(tag, evt) {

+ 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);

+ 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>

+ 57 - 67
public/app/features/dashboard/partials/dashboardList.html

@@ -1,17 +1,35 @@
 <page-header model="ctrl.navModel"></page-header>
 
 <div class="page-container page-body">
+  <div class="page-action-bar" ng-show="ctrl.folderTitle">
+      <div class="gf-form gf-form--grow">
+          <h3 class="page-sub-heading">
+            <i class="fa fa-folder-open"></i>&nbsp;{{ctrl.folderTitle}}
+          </h3>
+      </div>
+      <div class="page-action-bar__spacer"></div>
+      <button class="btn btn-inverse" disabled>Permissions</button>
+      <a class="btn btn-success" href="/dashboard/new">
+        <i class="fa fa-plus"></i>
+        Dashboard
+      </a>
+      <a class="btn btn-success" href="/dashboard/new/?editview=new-folder">
+        <i class="fa fa-plus"></i>
+        Folder
+      </a>
+    </div>
+
   <div class="page-action-bar">
     <div class="gf-form gf-form--grow">
       <label class="gf-form-label">Search</label>
       <input type="text" class="gf-form-input max-width-30" placeholder="Find Dashboard by name" tabindex="1" give-focus="true" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.onQueryChange()" />
     </div>
     <div class="page-action-bar__spacer"></div>
-    <a class="btn btn-success" href="/dashboard/new">
+    <a class="btn btn-success" href="/dashboard/new" ng-hide="ctrl.folderTitle">
       <i class="fa fa-plus"></i>
       Dashboard
     </a>
-    <a class="btn btn-success" href="/dashboard/new/?editview=new-folder">
+    <a class="btn btn-success" href="/dashboard/new/?editview=new-folder" ng-hide="ctrl.folderTitle">
       <i class="fa fa-plus"></i>
       Folder
     </a>
@@ -39,29 +57,25 @@
       </div>
   </div>
 
-  <div class="gf-form-group">
-    <div class="gf-form-button-row">
-      <button	type="button"
-          class="btn gf-form-button btn-secondary"
-          ng-disabled="!ctrl.canMove"
-          ng-click="ctrl.moveTo()"
-          bs-tooltip="ctrl.canMove ? '' : 'Select a dashboard to move (cannot move folders)'" data-placement="bottom">
-        <i class="fa fa-exchange"></i>&nbsp;&nbsp;Move to...
-      </button>
-      <button  type="button"
-          class="btn gf-form-button btn-inverse"
-          ng-click="ctrl.delete()"
-          ng-disabled="!ctrl.canDelete">
-          <i class="fa fa-trash"></i>&nbsp;&nbsp;Delete
-      </button>
-    </div>
+  <div ng-if="!ctrl.hasFilters && ctrl.sections.length === 0">
+    <empty-list-cta model="{
+      title: 'This folder doesn\'t have any dashboards yet',
+      buttonIcon: 'gicon gicon-dashboard-new',
+      buttonLink: '/dashboard/new',
+      buttonTitle: 'Create Dashboard',
+      proTip: 'You can bulk move dashboards into this folder from the main dashboard list.',
+      proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
+      proTipLinkTitle: 'Learn more',
+      proTipTarget: '_blank'
+    }" />
   </div>
 
-  <div class="dashboard-list">
+  <div class="dashboard-list" ng-show="ctrl.sections.length > 0">
     <div class="search-results-filter-row">
       <gf-form-switch
         on-change="ctrl.onSelectAllChanged()"
         checked="ctrl.selectAllChecked"
+        switch-class="gf-form-switch--transparent gf-form-switch--search-result-filter-row__checkbox"
       />
       <div class="search-results-filter-row__filters">
         <select
@@ -69,63 +83,39 @@
           ng-model="ctrl.selectedStarredFilter"
           ng-options="t.text disable when t.disabled for t in ctrl.starredFilterOptions"
           ng-change="ctrl.onStarredFilterChange()"
+          ng-show="!(ctrl.canMove || ctrl.canDelete)"
         />
         <select
           class="search-results-filter-row__filters-item gf-form-input"
           ng-model="ctrl.selectedTagFilter"
           ng-options="t.term disable when t.disabled for t in ctrl.tagFilterOptions"
           ng-change="ctrl.onTagFilterChange()"
+          ng-show="!(ctrl.canMove || ctrl.canDelete)"
         />
-      </div>
-    </div>
-    <div class="search-results-container" ng-show="ctrl.sections.length > 0" grafana-scrollbar>
-      <div ng-repeat="section in ctrl.sections" class="search-section">
-
-        <div class="search-section__header__with-checkbox" ng-hide="section.hideHeader">
-          <gf-form-switch
-            on-change="ctrl.selectionChanged()"
-            checked="section.checked">
-          </gf-form-switch>
-          <a  class="search-section__header pointer" ng-click="ctrl.toggleFolder(section)" ng-hide="section.hideHeader">
-            <i class="search-section__header__icon" ng-class="section.icon"></i>
-            <span class="search-section__header__text">{{::section.title}}</span>
-            <i class="fa fa-minus search-section__header__toggle" ng-show="section.expanded"></i>
-            <i class="fa fa-plus search-section__header__toggle" ng-hide="section.expanded"></i>
-          </a>
-        </div>
-
-        <div ng-if="section.expanded">
-          <div ng-repeat="item in section.items" class="search-item__with-checkbox" ng-class="{'selected': item.selected}">
-            <gf-form-switch
-              on-change="ctrl.selectionChanged()"
-              checked="item.checked" />
-            <a ng-href="{{::item.url}}" class="search-item">
-              <span class="search-item__icon">
-                <i class="fa fa-th-large"></i>
-              </span>
-              <span class="search-item__body">
-                <div class="search-item__body-title">{{::item.title}}</div>
-                <div class="search-item__body-sub-title" ng-show="item.folderTitle && section.hideHeader">
-                  <i class="fa fa-folder-o"></i>
-                  {{::item.folderTitle}}
-                </div>
-              </span>
-              <span class="search-item__tags">
-                <span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in item.tags" tag-color-from-name="tag"  class="label label-tag">
-                  {{tag}}
-                </span>
-              </span>
-              <span class="search-item__actions">
-                <i class="fa" ng-class="{'fa-star': item.isStarred, 'fa-star-o': !item.isStarred}"></i>
-              </span>
-            </a>
-          </div>
+        <div class="gf-form-button-row" ng-show="ctrl.canMove || ctrl.canDelete">
+          <button	type="button"
+              class="btn gf-form-button btn-inverse"
+              ng-disabled="!ctrl.canMove"
+              ng-click="ctrl.moveTo()"
+              bs-tooltip="ctrl.canMove ? '' : 'Select a dashboard to move (cannot move folders)'"
+              data-placement="bottom">
+            <i class="fa fa-exchange"></i>&nbsp;&nbsp;Move
+          </button>
+          <button  type="button"
+              class="btn gf-form-button btn-danger"
+              ng-click="ctrl.delete()"
+              ng-disabled="!ctrl.canDelete">
+              <i class="fa fa-trash"></i>&nbsp;&nbsp;Delete
+          </button>
         </div>
       </div>
     </div>
+    <div class="search-results-container">
+      <dashboard-search-results
+        results="ctrl.sections"
+        editable="true"
+        on-selection-changed="ctrl.selectionChanged()"
+        on-tag-selected="ctrl.filterByTag($tag)" />
+    </div>
   </div>
 </div>
-
-<em class="muted" ng-hide="ctrl.sections.length > 0">
-  No Dashboards or Folders found.
-</em>

+ 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');
     });
   });
 
 });
-
-

+ 1 - 4
public/app/features/dashboard/specs/dashboard_list_ctrl.jest.ts

@@ -537,13 +537,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
     search: (options: any) => {
       return q.resolve(searchResponse);
     },
-    toggleSection: (section) => {
-      return;
-    },
     getDashboardTags: () => {
       return q.resolve(tags || []);
     }
   };
 
-  return new DashboardListCtrl({}, { getNav: () => { } }, q, <SearchSrv>searchSrvStub);
+  return new DashboardListCtrl({}, { getNav: () => { } }, q, <SearchSrv>searchSrvStub, {});
 }

+ 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;
 }
 

+ 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>

+ 13 - 1
public/app/features/plugins/ds_list_ctrl.ts

@@ -1,6 +1,7 @@
 ///<reference path="../../headers/common.d.ts" />
 
 import coreModule from '../../core/core_module';
+import {appEvents} from 'app/core/core';
 
 export class DataSourcesCtrl {
   datasources: any;
@@ -11,13 +12,24 @@ export class DataSourcesCtrl {
     private $scope,
     private backendSrv,
     private datasourceSrv,
+    private $location,
     private navModelSrv) {
 
     this.navModel = this.navModelSrv.getNav('cfg', 'datasources', 0);
-
+    this.navigateToUrl = this.navigateToUrl.bind(this);
     backendSrv.get('/api/datasources').then(result => {
       this.datasources = result;
     });
+
+    appEvents.on('location-change', payload => {
+      this.navigateToUrl(payload.href);
+    });
+  }
+
+  navigateToUrl(url) {
+    // debugger;
+    this.$location.path(url);
+    this.$location.replace();
   }
 
   removeDataSourceConfirmed(ds) {

+ 1 - 1
public/app/features/plugins/partials/ds_list.html

@@ -49,7 +49,7 @@
 			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',
+			proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
 			proTipLinkTitle: 'Learn more',
 			proTipTarget: '_blank'
 		}" />

+ 57 - 52
public/app/partials/error.html

@@ -1,55 +1,60 @@
-<navbar model="navModel"></navbar>
-
 <div class="page-container">
-
   <div class="page-header">
-    <h1>
-		Page not found (404)
-	</h1>
-	</div>
-	<div class="error-row">
-		<div class="dash-row-menu-grip"><i class="fa fa-ellipsis-v"></i></div>
-		<div class="panel-container error-row">
-			<div class="error-column graph-box">
-				<div class="error-row">
-					<div class="error-column error-space-between graph-percentage">
-						<p>100%</p>
-						<p>80%</p>
-						<p>60%</p>
-						<p>40%</p>
-						<p>20%</p>
-						<p>0%</p>
-					</div>
-					<div class="error-column image-box">
-						<img src="public/img/graph404.svg" width="100%">
-						<div class="error-row error-space-between">
-							<p class="graph-text">Then</p>
-							<p class="graph-text">Now</p>
-						</div>
-					</div>
-				</div>
-			</div>
-			<div class="error-column info-box">
-				<div class="error-row current-box">
-					<p class="current-text">current</p>
-				</div>
-				<div class="error-row" style="flex: 1">
-					<i class="fa fa-minus error-minus"></i>
-					<div class="error-column error-space-between">
-						<div class="error-row error-space-between">
-							<p>Chances you are on the page you are looking for.</p>
-							<p class="left-margin">0%</p>
-						</div>
-						<div>
-							<h3>Sorry for the inconvenience</h3>
-							<p>Please go back to your <a href="{{appSubUrl}}/" class="error-link">home dashboard</a> and try again.</p>
-							<p>If the error persists, seek help on the <a href="https://community.grafana.com" target="_blank" class="error-link">community site</a>.</p>
-						</div>
-					</div>
-				</div>
-			</div>
-			<span class="resize-panel-handle icon-gf icon-gf-grabber" style="cursor: default"></span>
-		</div>
-	</div>
-
+    <div class="page-header__inner">
+      <span class="page-header__logo">
+        <i class="page-header__icon fa fa-fw fa-exclamation-triangle"></i>
+      </span>
+      <div class="page-header__info-block">
+        <h1 class="page-header__title">
+          Page not found
+        </h1>
+        <div class="page-header__sub-title">
+          404 Error
+        </div>
+      </div>
+    </div>
+  </div>
+    <div class="panel-container error-container">
+      <div class="error-column graph-box">
+        <div class="error-row">
+          <div class="error-column error-space-between graph-percentage">
+            <p>100%</p>
+            <p>80%</p>
+            <p>60%</p>
+            <p>40%</p>
+            <p>20%</p>
+            <p>0%</p>
+          </div>
+          <div class="error-column image-box">
+            <img src="public/img/graph404.svg" width="100%">
+            <div class="error-row error-space-between">
+              <p class="graph-text">Then</p>
+              <p class="graph-text">Now</p>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="error-column info-box">
+        <div class="error-row current-box">
+          <p class="current-text">current</p>
+        </div>
+        <div class="error-row" style="flex: 1">
+          <i class="fa fa-minus error-minus"></i>
+          <div class="error-column error-space-between error-full-width">
+            <div class="error-row error-space-between">
+              <p>Chances you are on the page you are looking for.</p>
+              <p class="left-margin">0%</p>
+            </div>
+            <div>
+              <h3>Sorry for the inconvenience</h3>
+              <p>Please go back to your
+                <a href="{{appSubUrl}}/" class="error-link">home dashboard</a> and try again.</p>
+              <p>If the error persists, seek help on the
+                <a href="https://community.grafana.com" target="_blank" class="error-link">community site</a>.</p>
+            </div>
+          </div>
+        </div>
+      </div>
+      <span class="react-resizable-handle" style="cursor: default"></span>
+    </div>
 </div>

+ 10 - 1
public/sass/_variables.dark.scss

@@ -139,6 +139,8 @@ $table-bg-accent:             $dark-3; // for striping
 $table-bg-hover:              $dark-4; // for hover
 $table-border:                $dark-3; // table and cell border
 
+$table-bg-odd:               $dark-2;
+
 // Buttons
 // -------------------------
 
@@ -160,6 +162,7 @@ $btn-danger-bg-hl:            darken($red, 8%);
 $btn-inverse-bg:              $dark-3;
 $btn-inverse-bg-hl:     	    lighten($dark-3, 4%);
 $btn-inverse-text-color:      $link-color;
+$btn-inverse-text-shadow:     0px 1px 0 rgba(0,0,0,.1);
 
 $btn-link-color:              $gray-3;
 
@@ -182,7 +185,7 @@ $input-border-focus:             $input-border-color !default;
 $input-box-shadow-focus:         rgba(102,175,233,.6) !default;
 $input-color-placeholder:        $gray-1 !default;
 $input-label-bg:				         $gray-blue;
-$input-label-border-color:       transparent;
+$input-label-border-color:       $gray-blue;
 $input-invalid-border-color:     lighten($red, 5%);
 
 // Search
@@ -241,11 +244,14 @@ $navbarDropdownShadow:            inset 0px 4px 10px -4px $body-bg;
 $navbarButtonBackground:          $navbarBackground;
 $navbarButtonBackgroundHighlight: $body-bg;
 
+$navbar-button-border:             #151515;
+
 // Sidemenu
 // -------------------------
 $side-menu-bg:            $black;
 $side-menu-item-hover-bg: $dark-2;
 $side-menu-shadow:        0 0 20px black;
+$side-menu-link-color:    $link-color;
 $breadcrumb-hover-hl:   #111;
 
 // Menu dropdowns
@@ -261,6 +267,9 @@ $page-nav-bg: $black;
 $page-nav-shadow: 5px 5px 20px -5px $black;
 $page-nav-breadcrumb-color: $gray-3;
 
+// Tabs
+// -------------------------
+$tab-border-color:          $dark-4;
 
 // Pagination
 // -------------------------

+ 54 - 43
public/sass/_variables.light.scss

@@ -14,24 +14,24 @@ $black:                 #000;
 
 // -------------------------
 $black:            #000;
-$dark-1:           #141414;
-$dark-2:           #1d1d1f;
-$dark-3:           #262628;
-$dark-4:           #373737;
-$dark-5:           #444444;
-$gray-1:           #555555;
-$gray-2:           #7B7B7B;
-$gray-3:           #b3b3b3;
-$gray-4:           #D8D9DA;
-$gray-5:           #ECECEC;
-$gray-6:           #f4f5f8;
-$gray-7:           #fbfbfb;
+$dark-1:           #13161d;
+$dark-2:           #1e2028;
+$dark-3:           #303133;
+$dark-4:           #35373f;
+$dark-5:           #41444b;
+$gray-1:           #52545c;
+$gray-2:           #767980;
+$gray-3:           #acb6bf;
+$gray-4:           #c7d0d9;
+$gray-5:           #dde4ed;
+$gray-6:           #e9edf2;
+$gray-7:           #f7f8fa;
 
 $white:            #fff;
 
 // Accent colors
 // -------------------------
-$blue:             	   	#2AB2E4;
+$blue:                  #1ca4d6;
 $blue-dark:             #3CAAD6;
 $green:                 #3aa655;
 $red:                   #d44939;
@@ -39,7 +39,7 @@ $yellow:                #FF851B;
 $orange:                #Ff7941;
 $pink:                  #E671B8;
 $purple:                #9954BB;
-$variable:              #2AB2E4;
+$variable:              $blue;
 
 $brand-primary:         $orange;
 $brand-success:         $green;
@@ -55,22 +55,22 @@ $critical:              #EC2128;
 // Scaffolding
 // -------------------------
 
-$body-bg:  		  		    $white;
-$page-bg:  		  		    $white;
+$body-bg:  		  		    $gray-7;
+$page-bg:  		  		    $gray-7;
 $body-color:    		    $gray-1;
-$text-color:    		    $gray-1;
+$text-color:    		    $dark-4;
 $text-color-strong:	    $white;
-$text-color-weak: 	    $gray-3;
+$text-color-weak: 	    $gray-2;
 $text-color-faint:      $gray-4;
 $text-color-emphasis: 	$dark-5;
 
 $text-shadow-strong: none;
 $text-shadow-faint: none;
+$textShadow: none;
 
 // gradients
 $brand-gradient: linear-gradient(to right, rgba(255,213,0,1.0) 0%, rgba(255,68,0,1.0) 99%, rgba(255,68,0,1.0) 100%);
-$page-gradient: linear-gradient(-60deg, transparent 70%, darken($page-bg, 4%) 98%);
-$page-header-bg: linear-gradient(90deg, #292a2d, black);
+$page-gradient: linear-gradient(-60deg, transparent 70%, $gray-7 98%);
 
 // Links
 // -------------------------
@@ -97,7 +97,7 @@ $component-active-bg:    $brand-primary !default;
 // Panel
 // -------------------------
 
-$panel-bg: 		       $gray-7;
+$panel-bg: 		       $white;
 $panel-border-color: $gray-5;
 $panel-border:       solid 1px $panel-border-color;
 $panel-drop-zone-bg: repeating-linear-gradient(-128deg, $body-bg, $body-bg 10px, $gray-6 10px, $gray-6 20px);
@@ -105,9 +105,9 @@ $panel-header-hover-bg:       $gray-6;
 $panel-header-menu-hover-bg:  $gray-4;
 
 // Page header
-$page-header-bg: linear-gradient(90deg, #292a2d, black);
-$page-header-shadow: inset 0px -4px 14px $dark-2;
-$page-header-border-color: $dark-4;
+$page-header-bg: linear-gradient(90deg, $white, $gray-7);
+$page-header-shadow: inset 0px -3px 10px $gray-6;
+$page-header-border-color: $gray-4;
 
 $divider-border-color:	      $gray-2;
 
@@ -122,12 +122,12 @@ $code-tag-bg:       $gray-6;
 $code-tag-border:   darken($code-tag-bg, 3%);
 
 // cards
-$card-background: linear-gradient(135deg, $gray-5, $gray-6);
-$card-background-hover: linear-gradient(135deg, $gray-6, $gray-7);
+$card-background: linear-gradient(135deg, $gray-6, $gray-5);
+$card-background-hover: linear-gradient(135deg, $gray-5, $gray-6);
 $card-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, .1), 1px 1px 0 0 rgba(0, 0, 0, .1);
 
 // Lists
-$list-item-bg:         $card-background;
+$list-item-bg:         linear-gradient(135deg, $gray-5, $gray-6);//$card-background;
 $list-item-hover-bg:   darken($gray-5, 5%);
 $list-item-link-color: $text-color;
 $list-item-shadow:     $card-shadow;
@@ -140,6 +140,8 @@ $table-bg-hover:             $gray-5; // for hover
 $table-bg-active:            $table-bg-hover !default;
 $table-border:               $gray-3; // table and cell border
 
+$table-bg-odd:               $gray-5;
+
 // Scrollbars
 $scrollbarBackground:   $gray-5;
 $scrollbarBackground2:  $gray-5;
@@ -162,9 +164,10 @@ $btn-warning-bg-hl:     darken($orange, 3%);
 $btn-danger-bg:         lighten($red, 3%);
 $btn-danger-bg-hl:      darken($red, 3%);
 
-$btn-inverse-bg:          $gray-5;
-$btn-inverse-bg-hl:       darken($gray-5, 5%);
-$btn-inverse-text-color:  $dark-4;
+$btn-inverse-bg:          $gray-6;
+$btn-inverse-bg-hl:       darken($gray-6, 5%);
+$btn-inverse-text-color:  $gray-1;
+$btn-inverse-text-shadow: 0 1px 0 rgba(255, 255, 255, .4);
 
 $btn-link-color: $gray-1;
 
@@ -176,7 +179,7 @@ $iconContainerBackground: $white;
 
 // Forms
 // -------------------------
-$input-bg:                       $gray-7;
+$input-bg:                       $white;
 $input-bg-disabled:              $gray-5;
 
 $input-color:                    $dark-3;
@@ -185,33 +188,38 @@ $input-box-shadow:               none;
 $input-border-focus:             $blue !default;
 $input-box-shadow-focus:         $blue !default;
 $input-color-placeholder:        $gray-4 !default;
-$input-label-bg:	        	 #eaebee;
-$input-label-border-color:       #e3e4e7;
+$input-label-bg:	        	     $gray-5;
+$input-label-border-color:       $gray-5;
 $input-invalid-border-color:     lighten($red, 5%);
 
 // Sidemenu
 // -------------------------
-$side-menu-bg:            $body-bg;
-$side-menu-item-hover-bg: $gray-6;
-$side-menu-shadow:        0 0 5px #c2c2c2;
+$side-menu-bg:            $dark-2;
+$side-menu-item-hover-bg: $gray-1;
+$side-menu-shadow:        5px 0px 10px -5px $gray-1;
+$side-menu-link-color:    $gray-6;
 
 // Menu dropdowns
 // -------------------------
-$menu-dropdown-bg:            $white;
+$menu-dropdown-bg:            $gray-7;
 $menu-dropdown-hover-bg:      $gray-6;
 $menu-dropdown-border-color:  $gray-4;
-$menu-dropdown-shadow:        5px 5px 20px -5px $gray-4;
+$menu-dropdown-shadow:        5px 5px 10px -5px $gray-1;
 
 // Breadcrumb
 // -------------------------
-$page-nav-bg:               #eaebee;
+$page-nav-bg:               $gray-5;
 $page-nav-shadow:           5px 5px 20px -5px $gray-4;
 $page-nav-breadcrumb-color: $black;
 $breadcrumb-hover-hl:       #d9dadd;
 
+// Tabs
+// -------------------------
+$tab-border-color:          $gray-5;
+
 // search
 $search-shadow: 0 5px 30px 0 $gray-4;
-$search-filter-box-bg: $gray-4;
+$search-filter-box-bg: $gray-7;
 
 // Dropdowns
 // -------------------------
@@ -257,8 +265,8 @@ $wellBackground:                  $gray-3;
 // -------------------------
 
 $navbarHeight:                    52px;
-$navbarBackgroundHighlight:       #f8f8f8;
-$navbarBackground:                #f2f3f7;
+$navbarBackgroundHighlight:       $white;
+$navbarBackground:                $white;
 $navbarBorder:                    1px solid $gray-4;
 $navbarShadow:                    0 0 3px #c1c1c1;
 
@@ -275,6 +283,8 @@ $navbarBrandColor:                $navbarLinkColor;
 $navbarButtonBackground:          lighten($navbarBackground, 3%);
 $navbarButtonBackgroundHighlight: lighten($navbarBackground, 5%);
 
+$navbar-button-border:            $gray-4;
+
 
 // Pagination
 // -------------------------
@@ -318,7 +328,8 @@ $graph-tooltip-bg:        $gray-5;
 $checkboxImageUrl: '../img/checkbox_white.png';
 
 // info box
-$info-box-background: linear-gradient(135deg, #f1fbff, #d7ebff);
+// $info-box-background: linear-gradient(135deg, #f1fbff, #d7ebff);
+$info-box-background: linear-gradient(135deg, $blue, $blue-dark);
 
 // footer
 $footer-link-color:   $gray-3;

+ 3 - 3
public/sass/_variables.scss

@@ -75,7 +75,7 @@ $container-max-widths: (
 $grid-columns:      12 !default;
 $grid-gutter-width: 30px !default;
 
-$enable-flex: false;
+$enable-flex: true;
 
 // Typography
 // -------------------------
@@ -224,7 +224,7 @@ $btn-padding-y-lg:               11px !default;
 $btn-padding-x-xl:               21px !default;
 $btn-padding-y-xl:               11px !default;
 
-$btn-border-radius:              3px;
+$btn-border-radius:              2px;
 
 // sidemenu
 $side-menu-width:  60px;
@@ -235,5 +235,5 @@ $dashboard-padding: $panel-margin * 2;
 $panel-padding: 0px 10px 5px 10px;
 
 // tabs
-$tabs-padding: 9px 15px 9px;
+$tabs-padding: 10px 15px 9px;
 

+ 15 - 0
public/sass/base/_icons.scss

@@ -15,6 +15,10 @@
   background-image: url('../img/icons_#{$theme-name}_theme/icon_alert.svg');
 }
 
+.gicon-alert-alt {
+  background-image: url('../img/icons_#{$theme-name}_theme/icon_alert_alt.svg');
+}
+
 .gicon-datasources {
   background-image: url('../img/icons_#{$theme-name}_theme/icon_data_sources.svg');
 }
@@ -58,3 +62,14 @@
 .gicon-zoom-out {
   background-image: url('../img/icons_#{$theme-name}_theme/icon_zoom_out.svg');
 }
+
+.sidemenu {
+  .gicon-dashboard {
+    background-image: url('../img/icons_dark_theme/icon_dashboard.svg');
+  }
+  .gicon-alert {
+    background-image: url('../img/icons_dark_theme/icon_alert.svg');
+  }
+}
+
+

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

@@ -106,7 +106,7 @@
 }
 // Inverse appears as dark gray
 .btn-inverse {
-  @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl, $btn-inverse-text-color);
+  @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl, $btn-inverse-text-color, $btn-inverse-text-shadow);
   //background: $card-background;
   box-shadow: $card-shadow;
   //border: 1px solid $tight-form-func-highlight-bg;

+ 2 - 3
public/sass/components/_cards.scss

@@ -200,9 +200,8 @@
   }
 
   .card-item {
-    border-bottom: .2rem solid $page-bg;
-    border-radius: 0;
-    box-shadow: none;
+    border-bottom: 3px solid $page-bg;
+    border-radius: 2px;
   }
 
   .card-item-header {

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

@@ -8,7 +8,7 @@
   }
 
   .react-grid-item {
-    display: none;
+    display: none !important;
     transition-property: none !important;
   }
 

+ 10 - 4
public/sass/components/_dashboard_list.scss

@@ -1,19 +1,25 @@
 .dashboard-list {
-    height: 75%;
-
     .search-results-container {
-        padding-left: 0;
+        padding: 5px 0 0 0;
     }
 }
 
 .search-results-filter-row {
+    height: 35px;
     display: flex;
     justify-content: space-between;
+
+    .gf-form-button-row {
+        padding-top: 0;
+
+        button:last-child {
+            margin-right: 0;
+        }
+    }
 }
 
 .search-results-filter-row__filters {
     display: flex;
-    width: 300px;
 }
 
 .search-results-filter-row__filters-item {

+ 1 - 2
public/sass/components/_filter-table.scss

@@ -17,7 +17,7 @@
 
   tbody {
     tr:nth-child(odd) {
-      background: $dark-2;
+      background: $table-bg-odd;
     }
   }
 
@@ -34,7 +34,6 @@
     padding: $table-cell-padding;
     line-height: 30px;
     height: 30px;
-    border-bottom: 1px solid black;
     white-space: nowrap;
 
     &.filter-table__switch-cell {

+ 3 - 3
public/sass/components/_navbar.scss

@@ -74,15 +74,15 @@
 }
 
 .navbar-button {
-  @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl, $btn-inverse-text-color);
+  @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl, $btn-inverse-text-color, $btn-inverse-text-shadow);
 
   display: inline-block;
   font-weight: $btn-font-weight;
   padding: 8px 11px;
   line-height: 16px;
   color: $text-muted;
-  border: 1px solid #151515;
-  margin-right: 1px;
+  border: 1px solid $navbar-button-border;
+  margin-right: 3px;
   white-space: nowrap;
 
   .gicon {

+ 15 - 0
public/sass/components/_page_header.scss

@@ -72,6 +72,21 @@
   text-transform: uppercase;
 }
 
+.page-header__select_nav {
+  margin-bottom: 10px;
+
+  @include media-breakpoint-up(lg) {
+    display: none;
+  }
+}
+
+.page-header__tabs {
+  display: none;
+  @include media-breakpoint-up(lg) {
+    display: block;
+  }
+}
+
 .page-breadcrumbs {
   display: flex;
   padding: 10px 0;

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

@@ -18,7 +18,7 @@
 }
 
 .pluginlist-image {
-  width: 20px;
+  width: 17px;
 }
 
 .pluginlist-title {

+ 5 - 19
public/sass/components/_search.scss

@@ -120,8 +120,9 @@
   display: flex;
   flex-grow: 1;
 
-  &:hover {
-    color: $text-color-weak;
+  &:hover, &.selected {
+    color: $link-hover-color;
+
     .search-section__header__toggle {
       background: $tight-form-func-bg;
       color: $link-hover-color;
@@ -129,12 +130,8 @@
   }
 }
 
-.search-section__header__with-checkbox {
-  display: flex;
-}
-
 .search-section__header__icon {
-  padding: 5px 10px;
+  padding: 2px 10px;
 }
 
 .search-section__header__toggle {
@@ -145,14 +142,6 @@
   flex-grow: 1;
 }
 
-.search-item__with-checkbox {
-  display: flex;
-
-  .search-item {
-    margin: 1px 3px;
-  }
-}
-
 .search-item {
   @include list-item();
   @include left-brand-border();
@@ -163,11 +152,8 @@
   white-space: nowrap;
   padding: 0px;
 
-  &:hover {
+  &:hover, &.selected {
     @include left-brand-border-gradient();
-  }
-
-  &.selected {
     background: $list-item-hover-bg;
   }
 }

+ 2 - 1
public/sass/components/_sidemenu.scss

@@ -110,7 +110,7 @@
   display: inline-block;
 
   .fa, .icon-gf, .gicon {
-    color: $link-color;
+    color: $side-menu-link-color;
     position: relative;
     opacity: .7;
     font-size: 130%;
@@ -135,6 +135,7 @@
   white-space: nowrap;
   background-color: $side-menu-item-hover-bg;
   font-size: 17px;
+  color: #ebedf2;
 }
 
 li.sidemenu-org-switcher {

+ 45 - 0
public/sass/components/_switch.scss

@@ -102,6 +102,51 @@ $switch-height: 1.5rem;
   }
 }
 
+.gf-form-switch--transparent {
+  input + label {
+    background: transparent;
+  }
+
+  input + label::before, input + label::after {
+    background: transparent;
+  }
+
+  &:hover {
+    input + label::before {
+      background: transparent;
+    }
+
+    input + label::after {
+      background: transparent;
+    }
+  }
+}
+
+.gf-form-switch--search-result__section {
+  min-width: 3.3rem;
+  margin-right: -0.3rem;
+
+  input + label {
+    height: 1.7rem;
+  }
+}
+
+.gf-form-switch--search-result__item {
+  min-width: 2.6rem;
+
+  input + label {
+    height: 2.7rem;
+  }
+}
+
+.gf-form-switch--search-result-filter-row__checkbox {
+  min-width: 4.7rem;
+
+  input + label {
+    height: 2.5rem;
+  }
+}
+
 gf-form-switch[disabled]  {
   .gf-form-label,
   .gf-form-switch input + label {

+ 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;
 }
 

+ 14 - 2
public/sass/components/_tabs.scss

@@ -16,7 +16,7 @@
   position: relative;
   display: block;
   border: solid transparent;
-  border-width: 2px 1px 1px;
+  border-width: 0 1px 1px;
   border-radius: 3px 3px 0 0;
 
   i {
@@ -31,9 +31,21 @@
   &.active,
   &.active:hover,
   &.active:focus {
-    border-color: $orange $dark-4 transparent;
+    border-color: $orange $tab-border-color transparent;
     background: $page-bg;
     color: $link-color;
+    overflow: hidden;
+
+    &::before {
+      display: block;
+      content: ' ';
+      position: absolute;
+      left: 0;
+      right: 0;
+      height: 2px;
+      top: 0;
+      background-image: linear-gradient(to right, #ffd500 0%, #ff4400 99%, #ff4400 100%);
+    }
   }
 }
 

+ 6 - 2
public/sass/layout/_page.scss

@@ -15,8 +15,12 @@
 }
 
 .page-container {
-  @extend .container;
-  padding: 0 $spacer * 2;
+  margin-left: auto;
+  margin-right: auto;
+  padding-left:  $spacer*2;
+  padding-right: $spacer*2;
+  max-width: 980px;
+  @include clearfix();
 }
 
 .scroll-canvas {

+ 34 - 1
public/sass/pages/_errorpage.scss

@@ -3,6 +3,11 @@
 // Layout
 //
 
+.error-container {
+  display: flex;
+  flex-direction: row;
+}
+
 .error-row {
     display: flex;
     flex-direction: row;
@@ -22,7 +27,7 @@
 
 .info-box {
     width: 38%;
-    padding: 2rem 1rem 6rem;
+    padding: 2rem 1rem 2rem;
 }
 
 .graph-percentage {padding: 0 0 1.5rem;}
@@ -58,3 +63,31 @@
 }
 
 .graph-text {margin: 0;}
+
+@include media-breakpoint-down(sm) {
+  .graph-box {
+    width: 50%;
+  }
+
+  .info-box {
+    width: 50%;
+  }
+}
+
+@include media-breakpoint-down(xs) {
+  .error-container {
+    flex-direction: column;
+  }
+
+  .graph-box {
+    width: 100%;
+  }
+
+  .info-box {
+      width: 100%;
+  }
+
+  .error-full-width {
+    width: 100%;
+  }
+}