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

Merge branch 'develop' into develop-settings

Torkel Ödegaard 8 лет назад
Родитель
Сommit
9369a87e93
100 измененных файлов с 2985 добавлено и 1039 удалено
  1. 2 0
      .gitignore
  2. 1 1
      CHANGELOG.md
  3. 198 10
      LICENSE.md
  4. 3 13
      NOTICE.md
  5. 3 3
      build.go
  6. 1 0
      docker/blocks/mysql_tests/docker-compose.yaml
  7. 1 0
      docker/blocks/postgres_tests/docker-compose.yaml
  8. 2 1
      docs/sources/administration/provisioning.md
  9. 6 0
      docs/sources/features/datasources/mysql.md
  10. 6 0
      docs/sources/features/datasources/postgres.md
  11. 4 1
      docs/sources/installation/configuration.md
  12. 0 1
      package.json
  13. 1 1
      packaging/rpm/systemd/grafana-server.service
  14. 19 3
      pkg/api/index.go
  15. 5 1
      pkg/api/render.go
  16. 28 0
      pkg/cmd/grafana-server/server.go
  17. 1 0
      pkg/log/file_test.go
  18. 0 2
      pkg/services/alerting/extractor_test.go
  19. 2 1
      pkg/services/alerting/notifiers/slack.go
  20. 1 1
      pkg/services/sqlstore/user.go
  21. 2 2
      pkg/tsdb/graphite/graphite.go
  22. 2 2
      pkg/tsdb/graphite/graphite_test.go
  23. 0 2
      pkg/tsdb/influxdb/model_parser_test.go
  24. 1 0
      pkg/tsdb/influxdb/response_parser.go
  25. 32 1
      pkg/tsdb/postgres/postgres.go
  26. 8 3
      pkg/util/url.go
  27. 0 1
      public/app/app.ts
  28. 4 4
      public/app/core/angular_wrappers.ts
  29. 22 0
      public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx
  30. 34 0
      public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
  31. 38 0
      public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap
  32. 0 72
      public/app/core/components/PageHeader.tsx
  33. 127 0
      public/app/core/components/PageHeader/PageHeader.tsx
  34. 62 0
      public/app/core/components/ScrollBar/ScrollBar.tsx
  35. 15 8
      public/app/core/components/grafana_app.ts
  36. 98 0
      public/app/core/components/manage_dashboards/manage_dashboards.html
  37. 221 0
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  38. 7 30
      public/app/core/components/search/search.html
  39. 97 17
      public/app/core/components/search/search.ts
  40. 47 0
      public/app/core/components/search/search_results.html
  41. 76 0
      public/app/core/components/search/search_results.ts
  42. 6 1
      public/app/core/components/sidemenu/sidemenu.html
  43. 15 6
      public/app/core/components/sidemenu/sidemenu.ts
  44. 4 0
      public/app/core/config.ts
  45. 0 9
      public/app/core/controllers/all.js
  46. 7 0
      public/app/core/controllers/all.ts
  47. 0 23
      public/app/core/controllers/error_ctrl.js
  48. 24 0
      public/app/core/controllers/error_ctrl.ts
  49. 8 11
      public/app/core/controllers/invited_ctrl.ts
  50. 8 10
      public/app/core/controllers/json_editor_ctrl.ts
  51. 13 16
      public/app/core/controllers/login_ctrl.ts
  52. 9 11
      public/app/core/controllers/reset_password_ctrl.ts
  53. 10 1
      public/app/core/core.ts
  54. 4 10
      public/app/core/nav_model_srv.ts
  55. 26 2
      public/app/core/routes/routes.ts
  56. 1 0
      public/app/core/services/all.js
  57. 3 2
      public/app/core/services/context_srv.ts
  58. 21 0
      public/app/core/services/global_event_srv.ts
  59. 0 2
      public/app/core/services/keybindingSrv.ts
  60. 301 0
      public/app/core/services/ng_react.ts
  61. 22 15
      public/app/core/services/search_srv.ts
  62. 3 6
      public/app/core/specs/manage_dashboards.jest.ts
  63. 335 0
      public/app/core/specs/search.jest.ts
  64. 97 0
      public/app/core/specs/search_results.jest.ts
  65. 40 0
      public/app/core/specs/search_srv.jest.ts
  66. 43 0
      public/app/core/time_series2.ts
  67. 0 3
      public/app/core/utils/react2angular.ts
  68. 91 0
      public/app/core/utils/ticks.ts
  69. 9 1
      public/app/features/dashboard/all.ts
  70. 41 0
      public/app/features/dashboard/create_folder_ctrl.ts
  71. 0 1
      public/app/features/dashboard/dashboard_ctrl.ts
  72. 6 19
      public/app/features/dashboard/dashboard_import_ctrl.ts
  73. 2 200
      public/app/features/dashboard/dashboard_list_ctrl.ts
  74. 3 2
      public/app/features/dashboard/dashboard_migration.ts
  75. 133 38
      public/app/features/dashboard/dashboard_model.ts
  76. 3 2
      public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
  77. 14 3
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  78. 2 7
      public/app/features/dashboard/dashnav/dashnav.ts
  79. 1 1
      public/app/features/dashboard/export/export_modal.ts
  80. 16 0
      public/app/features/dashboard/folder_dashboards_ctrl.ts
  81. 21 0
      public/app/features/dashboard/folder_page_loader.ts
  82. 16 0
      public/app/features/dashboard/folder_permissions_ctrl.ts
  83. 0 138
      public/app/features/dashboard/import/dash_import.html
  84. 43 0
      public/app/features/dashboard/partials/create_folder.html
  85. 126 0
      public/app/features/dashboard/partials/dashboardImport.html
  86. 0 131
      public/app/features/dashboard/partials/dashboardList.html
  87. 5 0
      public/app/features/dashboard/partials/dashboard_list.html
  88. 5 0
      public/app/features/dashboard/partials/folder_dashboards.html
  89. 5 0
      public/app/features/dashboard/partials/folder_permissions.html
  90. 4 1
      public/app/features/dashboard/settings/settings.html
  91. 9 16
      public/app/features/dashboard/shareModalCtrl.ts
  92. 25 28
      public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts
  93. 24 2
      public/app/features/dashboard/specs/dashboard_migration.jest.ts
  94. 134 28
      public/app/features/dashboard/specs/repeat.jest.ts
  95. 0 71
      public/app/features/dashboard/timepicker/dropdown.html
  96. 59 0
      public/app/features/dashboard/timepicker/timepicker.html
  97. 10 26
      public/app/features/dashboard/timepicker/timepicker.ts
  98. 1 1
      public/app/features/dashboard/upload.ts
  99. 0 5
      public/app/features/org/partials/profile.html
  100. 0 9
      public/app/features/org/partials/select_org.html

+ 2 - 0
.gitignore

@@ -39,6 +39,8 @@ conf/custom.ini
 fig.yml
 docker-compose.yml
 docker-compose.yaml
+/conf/dashboards/custom.yaml
+/conf/datasources/custom.yaml
 profile.cov
 /grafana
 .notouch

+ 1 - 1
CHANGELOG.md

@@ -24,7 +24,7 @@
 * **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda)
 * **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871)
 * **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798)
-
+* **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler)
 ## Tech
 * **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
 

+ 198 - 10
LICENSE.md

@@ -1,14 +1,202 @@
-Copyright 2014-2017 Torkel Ödegaard, Raintank Inc.
 
-Licensed under the Apache License, Version 2.0 (the "License"); you
-may not use this file except in compliance with the License. You may
-obtain a copy of the License at
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
 
-    http://www.apache.org/licenses/LICENSE-2.0
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
 
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-implied. See the License for the specific language governing
-permissions and limitations under the License.
+   1. Definitions.
 
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 3 - 13
NOTICE.md

@@ -1,16 +1,6 @@
 
-This software is based on Kibana:
-========================================
-Copyright 2012-2013 Elasticsearch BV
-
-Licensed under the Apache License, Version 2.0 (the "License"); you
-may not use this file except in compliance with the License. You may
-obtain a copy of the License at
+Copyright 2014-2017 Grafana Labs
 
-    http://www.apache.org/licenses/LICENSE-2.0
+This software is based on Kibana: 
+Copyright 2012-2013 Elasticsearch BV
 
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-implied. See the License for the specific language governing
-permissions and limitations under the License.

+ 3 - 3
build.go

@@ -95,9 +95,9 @@ func main() {
 
 		case "package":
 			grunt(gruntBuildArg("release")...)
-      if runtime.GOOS != "windows" {
-			  createLinuxPackages()
-      }
+			if runtime.GOOS != "windows" {
+				createLinuxPackages()
+			}
 
 		case "pkg-rpm":
 			grunt(gruntBuildArg("release")...)

+ 1 - 0
docker/blocks/mysql_tests/docker-compose.yaml

@@ -7,3 +7,4 @@
       MYSQL_PASSWORD: password
     ports:
       - "3306:3306"
+    tmpfs: /var/lib/mysql:rw

+ 1 - 0
docker/blocks/postgres_tests/docker-compose.yaml

@@ -5,3 +5,4 @@
       POSTGRES_PASSWORD: grafanatest
     ports:
       - "5432:5432"
+    tmpfs: /var/lib/postgresql/data:rw

+ 2 - 1
docs/sources/administration/provisioning.md

@@ -65,13 +65,14 @@ Currently we do not provide any scripts/manifests for configuring Grafana. Rathe
 Tool | Project
 -----|------------
 Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
+Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
 Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana)
 Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
 Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
 
 ## Datasources 
 
-> This feature is available from v4.7
+> This feature is available from v5.0
 
 It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`conf/datasources`](/installation/configuration/#datasources) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list.
 

+ 6 - 0
docs/sources/features/datasources/mysql.md

@@ -127,6 +127,12 @@ A query can returns multiple columns and Grafana will automatically create a lis
 SELECT my_host.hostname, my_other_host.hostname2 FROM my_host JOIN my_other_host ON my_host.city = my_other_host.city
 ```
 
+To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
+
+```sql
+SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
+```
+
 Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
 
 ```sql

+ 6 - 0
docs/sources/features/datasources/postgres.md

@@ -139,6 +139,12 @@ A query can return multiple columns and Grafana will automatically create a list
 SELECT host.hostname, other_host.hostname2 FROM host JOIN other_host ON host.city = other_host.city
 ```
 
+To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
+
+```sql
+SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
+```
+
 Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
 
 ```sql

+ 4 - 1
docs/sources/installation/configuration.md

@@ -93,7 +93,10 @@ Directory where grafana will automatically scan and look for plugins
 
 ### datasources
 
-Config files containing datasources that will be configured at startup
+> This feature is available in 5.0+
+
+Config files containing datasources that will be configured at startup. 
+You can read more about the config files at the [provisioning page](/administration/provisioning/#datasources).
 
 ## [server]
 

+ 0 - 1
package.json

@@ -125,7 +125,6 @@
     "lodash": "^4.17.4",
     "moment": "^2.18.1",
     "mousetrap": "^1.6.0",
-    "ngreact": "^0.4.1",
     "perfect-scrollbar": "^1.2.0",
     "prop-types": "^15.6.0",
     "react": "^16.1.1",

+ 1 - 1
packaging/rpm/systemd/grafana-server.service

@@ -9,7 +9,7 @@ After=postgresql.service mariadb.service mysql.service
 EnvironmentFile=/etc/sysconfig/grafana-server
 User=grafana
 Group=grafana
-Type=simple
+Type=notify
 Restart=on-failure
 WorkingDirectory=/usr/share/grafana
 RuntimeDirectory=grafana

+ 19 - 3
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: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"},
+				{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"},
 	}
@@ -117,6 +118,21 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 		Children: dashboardChildNavs,
 	})
 
+	dashboardFolderChildNavs := []*dtos.NavLink{
+		{Text: "Dashboards", Id: "manage-folder-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "fa fa-fw fa-th-large"},
+		{Text: "Permissions", Id: "manage-folder-permissions", Url: setting.AppSubUrl + "/dashboards?1", Icon: "fa fa-fw fa-lock"},
+	}
+
+	data.NavTree = append(data.NavTree, &dtos.NavLink{
+		Text:         "Dashboards",
+		Id:           "manage-folder",
+		SubTitle:     "Manage folder dashboards & permissions",
+		Icon:         "fa fa-folder-open",
+		Url:          setting.AppSubUrl + "/",
+		HideFromMenu: true,
+		Children:     dashboardFolderChildNavs,
+	})
+
 	if c.IsSignedIn {
 		profileNode := &dtos.NavLink{
 			Text:         c.SignedInUser.Name,

+ 5 - 1
pkg/api/render.go

@@ -10,7 +10,11 @@ import (
 )
 
 func RenderToPng(c *middleware.Context) {
-	queryReader := util.NewUrlQueryReader(c.Req.URL)
+	queryReader, err := util.NewUrlQueryReader(c.Req.URL)
+	if err != nil {
+		c.Handle(400, "Render parameters error", err)
+		return
+	}
 	queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery)
 
 	renderOpts := &renderer.RenderOpts{

+ 28 - 0
pkg/cmd/grafana-server/server.go

@@ -3,7 +3,9 @@ package main
 import (
 	"context"
 	"flag"
+	"fmt"
 	"io/ioutil"
+	"net"
 	"os"
 	"path/filepath"
 	"strconv"
@@ -96,6 +98,7 @@ func (g *GrafanaServerImpl) Start() {
 		return
 	}
 
+	SendSystemdNotification("READY=1")
 	g.startHttpServer()
 }
 
@@ -169,3 +172,28 @@ func (g *GrafanaServerImpl) writePIDFile() {
 
 	g.log.Info("Writing PID file", "path", *pidFile, "pid", pid)
 }
+
+func SendSystemdNotification(state string) error {
+	notifySocket := os.Getenv("NOTIFY_SOCKET")
+
+	if notifySocket == "" {
+		return fmt.Errorf("NOTIFY_SOCKET environment variable empty or unset.")
+	}
+
+	socketAddr := &net.UnixAddr{
+		Name: notifySocket,
+		Net:  "unixgram",
+	}
+
+	conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = conn.Write([]byte(state))
+
+	conn.Close()
+
+	return err
+}

+ 1 - 0
pkg/log/file_test.go

@@ -38,6 +38,7 @@ func TestLogFile(t *testing.T) {
 			So(fileLogWrite.maxlines_curlines, ShouldEqual, 3)
 		})
 
+		fileLogWrite.Close()
 		err = os.Remove(fileLogWrite.Filename)
 		So(err, ShouldBeNil)
 	})

+ 0 - 2
pkg/services/alerting/extractor_test.go

@@ -366,7 +366,6 @@ func TestAlertRuleExtraction(t *testing.T) {
 				          "steppedLine": false,
 				          "targets": [
 				            {
-				              "dsType": "influxdb",
 				              "groupBy": [
 				                {
 				                  "params": [
@@ -411,7 +410,6 @@ func TestAlertRuleExtraction(t *testing.T) {
 				              "tags": []
 				            },
 				            {
-				              "dsType": "influxdb",
 				              "groupBy": [
 				                {
 				                  "params": [

+ 2 - 1
pkg/services/alerting/notifiers/slack.go

@@ -6,6 +6,7 @@ import (
 	"io"
 	"mime/multipart"
 	"os"
+	"path/filepath"
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
@@ -176,7 +177,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
 
 func SlackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
 	if evalContext.ImageOnDiskPath == "" {
-		evalContext.ImageOnDiskPath = "public/img/mixed_styles.png"
+		evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png")
 	}
 	log.Info("Uploading to slack via file.upload API")
 	headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient)

+ 1 - 1
pkg/services/sqlstore/user.go

@@ -401,7 +401,7 @@ func SearchUsers(query *m.SearchUsersQuery) error {
 	}
 
 	if query.Query != "" {
-		whereConditions = append(whereConditions, "(email LIKE ? OR name LIKE ? OR login like ?)")
+		whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
 		whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
 	}
 

+ 2 - 2
pkg/tsdb/graphite/graphite.go

@@ -17,7 +17,7 @@ import (
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/tsdb"
-	opentracing "github.com/opentracing/opentracing-go"
+	"github.com/opentracing/opentracing-go"
 )
 
 type GraphiteExecutor struct {
@@ -158,7 +158,7 @@ func formatTimeRange(input string) string {
 	if input == "now" {
 		return input
 	}
-	return strings.Replace(strings.Replace(input, "m", "min", -1), "M", "mon", -1)
+	return strings.Replace(strings.Replace(strings.Replace(input, "now", "", -1), "m", "min", -1), "M", "mon", -1)
 }
 
 func fixIntervalFormat(target string) string {

+ 2 - 2
pkg/tsdb/graphite/graphite_test.go

@@ -18,14 +18,14 @@ func TestGraphiteFunctions(t *testing.T) {
 		Convey("formatting time range for now-1m", func() {
 
 			timeRange := formatTimeRange("now-1m")
-			So(timeRange, ShouldEqual, "now-1min")
+			So(timeRange, ShouldEqual, "-1min")
 
 		})
 
 		Convey("formatting time range for now-1M", func() {
 
 			timeRange := formatTimeRange("now-1M")
-			So(timeRange, ShouldEqual, "now-1mon")
+			So(timeRange, ShouldEqual, "-1mon")
 
 		})
 

+ 0 - 2
pkg/tsdb/influxdb/model_parser_test.go

@@ -20,7 +20,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
 		Convey("can parse influxdb json model", func() {
 			json := `
         {
-        "dsType": "influxdb",
         "groupBy": [
           {
             "params": [
@@ -123,7 +122,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
 		Convey("can part raw query json model", func() {
 			json := `
       {
-        "dsType": "influxdb",
         "groupBy": [
           {
             "params": [

+ 1 - 0
pkg/tsdb/influxdb/response_parser.go

@@ -50,6 +50,7 @@ func (rp *ResponseParser) transformRows(rows []Row, queryResult *tsdb.QueryResul
 			result = append(result, &tsdb.TimeSeries{
 				Name:   rp.formatSerieName(row, column, query),
 				Points: points,
+				Tags:   row.Tags,
 			})
 		}
 	}

+ 32 - 1
pkg/tsdb/postgres/postgres.go

@@ -78,6 +78,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
 
 	rowLimit := 1000000
 	rowCount := 0
+	timeIndex := -1
+
+	// check if there is a column named time
+	for i, col := range columnNames {
+		switch col {
+		case "time":
+			timeIndex = i
+		}
+	}
 
 	for ; rows.Next(); rowCount++ {
 		if rowCount > rowLimit {
@@ -89,6 +98,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
 			return err
 		}
 
+		// convert column named time to unix timestamp to make
+		// native datetime postgres types work in annotation queries
+		if timeIndex != -1 {
+			switch value := values[timeIndex].(type) {
+			case time.Time:
+				values[timeIndex] = float64(value.UnixNano() / 1e9)
+			}
+		}
+
 		table.Rows = append(table.Rows, values)
 	}
 
@@ -142,8 +160,13 @@ func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues,
 func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error {
 	pointsBySeries := make(map[string]*tsdb.TimeSeries)
 	seriesByQueryOrder := list.New()
+
 	columnNames, err := rows.Columns()
+	if err != nil {
+		return err
+	}
 
+	columnTypes, err := rows.ColumnTypes()
 	if err != nil {
 		return err
 	}
@@ -153,13 +176,21 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co
 	timeIndex := -1
 	metricIndex := -1
 
-	// check columns of resultset
+	// check columns of resultset: a column named time is mandatory
+	// the first text column is treated as metric name unless a column named metric is present
 	for i, col := range columnNames {
 		switch col {
 		case "time":
 			timeIndex = i
 		case "metric":
 			metricIndex = i
+		default:
+			if metricIndex == -1 {
+				switch columnTypes[i].DatabaseTypeName() {
+				case "UNKNOWN", "TEXT", "VARCHAR", "CHAR":
+					metricIndex = i
+				}
+			}
 		}
 	}
 

+ 8 - 3
pkg/util/url.go

@@ -9,10 +9,15 @@ type UrlQueryReader struct {
 	values url.Values
 }
 
-func NewUrlQueryReader(url *url.URL) *UrlQueryReader {
-	return &UrlQueryReader{
-		values: url.Query(),
+func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) {
+	u, err := url.ParseQuery(urlInfo.String())
+	if err != nil {
+		return nil, err
 	}
+
+	return &UrlQueryReader{
+		values: u,
+	}, nil
 }
 
 func (r *UrlQueryReader) Get(name string, def string) string {

+ 0 - 1
public/app/app.ts

@@ -9,7 +9,6 @@ import 'angular-native-dragdrop';
 import 'angular-bindonce';
 import 'react';
 import 'react-dom';
-import 'ngreact';
 
 import 'vendor/bootstrap/bootstrap';
 import 'vendor/angular-ui/ui-bootstrap-tpls';

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

@@ -1,10 +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']);
 }

+ 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 - 72
public/app/core/components/PageHeader.tsx

@@ -1,72 +0,0 @@
-import React from 'react';
-import { NavModel, NavModelItem } from '../nav_model_srv';
-import classNames from 'classnames';
-
-export interface IProps {
-  model: NavModel;
-}
-
-function TabItem(tab: NavModelItem) {
-  if (tab.hideFromTabs) {
-    return (null);
-  }
-
-  let tabClasses = classNames({
-    'gf-tabs-link': true,
-    active: tab.active,
-  });
-
-  return (
-    <li className="gf-tabs-item" key={tab.url}>
-      <a className={tabClasses} href={tab.url}>
-        <i className={tab.icon} />
-        {tab.text}
-      </a>
-    </li>
-  );
-}
-
-function Tabs({main}: {main: NavModelItem}) {
-  return <ul className="gf-tabs">{main.children.map(TabItem)}</ul>;
-}
-
-export default class PageHeader extends React.Component<IProps, any> {
-  constructor(props) {
-    super(props);
-  }
-
-  renderHeaderTitle(main) {
-    return (
-      <div className="page-header__inner">
-        <span className="page-header__logo">
-          {main.icon && <i className={`page-header__icon ${main.icon}`} />}
-          {main.img && <img className="page-header__img" src={main.img} />}
-        </span>
-
-        <div className="page-header__info-block">
-          <h1 className="page-header__title">{main.text}</h1>
-          {main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
-          {main.subType && (
-            <div className="page-header__stamps">
-              <i className={main.subType.icon} />
-              {main.subType.text}
-            </div>
-          )}
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    return (
-      <div className="page-header-canvas">
-        <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} />}
-          </div>
-        </div>
-      </div>
-    );
-  }
-}

+ 127 - 0
public/app/core/components/PageHeader/PageHeader.tsx

@@ -0,0 +1,127 @@
+import React from 'react';
+import { NavModel, NavModelItem } from '../../nav_model_srv';
+import classNames from 'classnames';
+import appEvents from 'app/core/app_events';
+
+export interface IProps {
+  model: NavModel;
+}
+
+function TabItem(tab: NavModelItem) {
+  if (tab.hideFromTabs) {
+    return (null);
+  }
+
+  let tabClasses = classNames({
+    'gf-tabs-link': true,
+    active: tab.active,
+  });
+
+  return (
+    <li className="gf-tabs-item" key={tab.url}>
+      <a className={tabClasses} href={tab.url}>
+        <i className={tab.icon} />
+        {tab.text}
+      </a>
+    </li>
+  );
+}
+
+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> {
+  constructor(props) {
+    super(props);
+  }
+
+  renderBreadcrumb(breadcrumbs) {
+    const breadcrumbsResult = [];
+    for (let i = 0; i < breadcrumbs.length; i++) {
+      const bc = breadcrumbs[i];
+      if (bc.uri) {
+        breadcrumbsResult.push(<a className="text-link" key={i} href={bc.uri}>{bc.title}</a>);
+      } else {
+        breadcrumbsResult.push(<span key={i}> / {bc.title}</span>);
+      }
+    }
+    return breadcrumbsResult;
+  }
+
+  renderHeaderTitle(main) {
+    return (
+      <div className="page-header__inner">
+        <span className="page-header__logo">
+          {main.icon && <i className={`page-header__icon ${main.icon}`} />}
+          {main.img && <img className="page-header__img" src={main.img} />}
+        </span>
+
+        <div className="page-header__info-block">
+          {main.text && <h1 className="page-header__title">{main.text}</h1>}
+          {main.breadcrumbs && main.breadcrumbs.length > 0 && (
+            <h1 className="page-header__title">
+              {this.renderBreadcrumb(main.breadcrumbs)}
+            </h1>)
+          }
+          {main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
+          {main.subType && (
+            <div className="page-header__stamps">
+              <i className={main.subType.icon} />
+              {main.subType.text}
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    return (
+      <div className="page-header-canvas">
+        <div className="page-container">
+          <div className="page-header">
+            {this.renderHeaderTitle(this.props.model.main)}
+            {this.props.model.main.children && <Navigation main={this.props.model.main} />}
+          </div>
+        </div>
+      </div>
+    );
+  }
+}

+ 62 - 0
public/app/core/components/ScrollBar/ScrollBar.tsx

@@ -0,0 +1,62 @@
+import React from 'react';
+import PerfectScrollbar from 'perfect-scrollbar';
+
+export interface Props {
+  children: any;
+  className: string;
+}
+
+export default class ScrollBar extends React.Component<Props, any> {
+
+  private container: any;
+  private ps: PerfectScrollbar;
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentDidMount() {
+    this.ps = new PerfectScrollbar(this.container);
+  }
+
+  componentDidUpdate() {
+    this.ps.update();
+  }
+
+  componentWillUnmount() {
+    this.ps.destroy();
+  }
+
+  // methods can be invoked by outside
+  setScrollTop(top) {
+    if (this.container) {
+      this.container.scrollTop = top;
+      this.ps.update();
+
+      return true;
+    }
+    return false;
+  }
+
+  setScrollLeft(left) {
+    if (this.container) {
+      this.container.scrollLeft = left;
+      this.ps.update();
+
+      return true;
+    }
+    return false;
+  }
+
+  handleRef = ref => {
+    this.container = ref;
+  };
+
+  render() {
+    return (
+      <div className={this.props.className} ref={this.handleRef}>
+        {this.props.children}
+      </div>
+    );
+  }
+}

+ 15 - 8
public/app/core/components/grafana_app.ts

@@ -1,5 +1,3 @@
-///<reference path="../../headers/common.d.ts" />
-
 import config from 'app/core/config';
 import _ from 'lodash';
 import $ from 'jquery';
@@ -12,7 +10,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 +21,7 @@ export class GrafanaCtrl {
       profiler.init(config, $rootScope);
       alertSrv.init();
       utilSrv.init();
+      globalEventSrv.init();
 
       $scope.dashAlerts = alertSrv;
     };
@@ -78,11 +77,16 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
       sidemenuOpen = scope.contextSrv.sidemenu;
       body.toggleClass('sidemenu-open', sidemenuOpen);
 
-      scope.$watch('contextSrv.sidemenu', newVal => {
-        if (sidemenuOpen !== scope.contextSrv.sidemenu) {
-          sidemenuOpen = scope.contextSrv.sidemenu;
-          body.toggleClass('sidemenu-open', scope.contextSrv.sidemenu);
-        }
+      appEvents.on('toggle-sidemenu', () => {
+        body.toggleClass('sidemenu-open');
+      });
+
+      appEvents.on('toggle-sidemenu-mobile', () => {
+        body.toggleClass('sidemenu-open--xs');
+      });
+
+      appEvents.on('toggle-sidemenu-hidden', () => {
+        body.toggleClass('sidemenu-hidden');
       });
 
       // tooltip removal fix
@@ -100,6 +104,9 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
           }
         }
 
+        // clear body class sidemenu states
+        body.removeClass('sidemenu-open--xs');
+
         $("#tooltip, .tooltip").remove();
 
         // check for kiosk url param

+ 98 - 0
public/app/core/components/manage_dashboards/manage_dashboards.html

@@ -0,0 +1,98 @@
+<div class="page-action-bar" ng-hide="!ctrl.hasFilters && ctrl.sections.length === 0">
+  <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">
+    <i class="fa fa-plus"></i>
+    Dashboard
+  </a>
+  <a class="btn btn-success" href="/dashboards/folder/new" ng-if="!ctrl.folderId">
+    <i class="fa fa-plus"></i>
+    Folder
+  </a>
+</div>
+
+<div class="gf-form" ng-if="ctrl.query.tag.length">
+  Filters:
+  <span ng-repeat="tagName in ctrl.query.tag">
+      <a ng-click="ctrl.removeTag(tagName, $event)" tag-color-from-name="tagName" class="label label-tag">
+      <i class="fa fa-remove"></i>
+      {{tagName}}
+      </a>
+  </span>
+</div>
+
+<div class="gf-form">
+  <div class="gf-form-button-row" ng-show="ctrl.hasFilters">
+    <button
+      type="button"
+      class="btn gf-form-button btn-inverse btn-small"
+      ng-click="ctrl.clearFilters()">
+      <i class="fa fa-close"></i> Clear current search query and filters
+    </button>
+  </div>
+</div>
+
+<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
+        class="search-results-filter-row__filters-item gf-form-input"
+        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 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 ng-if="ctrl.folderId && !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>

+ 221 - 0
public/app/core/components/manage_dashboards/manage_dashboards.ts

@@ -0,0 +1,221 @@
+import _ from 'lodash';
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import { SearchSrv } from 'app/core/services/search_srv';
+
+export class ManageDashboardsCtrl {
+  public sections: any[];
+  tagFilterOptions: any[];
+  selectedTagFilter: any;
+  query: any;
+  navModel: any;
+  canDelete = false;
+  canMove = false;
+  hasFilters = false;
+  selectAllChecked = false;
+  starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
+  selectedStarredFilter: any;
+  folderId?: number;
+
+  /** @ngInject */
+  constructor(private backendSrv, navModelSrv, private $q, private searchSrv: SearchSrv) {
+    this.query = { query: '', mode: 'tree', tag: [], starred: false, skipRecent: true, skipStarred: true };
+
+    if (this.folderId) {
+      this.query.folderIds = [this.folderId];
+    }
+
+    this.selectedStarredFilter = this.starredFilterOptions[0];
+
+    this.getDashboards().then(() => {
+      this.getTags();
+    });
+  }
+
+  getDashboards() {
+    return this.searchSrv.search(this.query).then((result) => {
+      return this.initDashboardList(result);
+    });
+  }
+
+  initDashboardList(result: any) {
+    this.canMove = false;
+    this.canDelete = false;
+    this.selectAllChecked = false;
+    this.hasFilters = this.query.query.length > 0 || this.query.tag.length > 0 || this.query.starred;
+
+    if (!result) {
+      this.sections = [];
+      return;
+    }
+
+    this.sections = result;
+
+    for (let section of this.sections) {
+      section.checked = false;
+
+      for (let dashboard of section.items) {
+        dashboard.checked = false;
+      }
+    }
+  }
+
+  selectionChanged() {
+
+    let selectedDashboards = 0;
+
+    for (let section of this.sections) {
+      selectedDashboards += _.filter(section.items, { checked: true }).length;
+    }
+
+    const selectedFolders = _.filter(this.sections, { checked: true }).length;
+    this.canMove = selectedDashboards > 0 && selectedFolders === 0;
+    this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
+  }
+
+  getDashboardsToDelete() {
+    let selectedDashboards = [];
+
+    for (const section of this.sections) {
+      if (section.checked) {
+        selectedDashboards.push(section.uri);
+      } else {
+        const selected = _.filter(section.items, { checked: true });
+        selectedDashboards.push(..._.map(selected, 'uri'));
+      }
+    }
+
+    return selectedDashboards;
+  }
+
+  getFolderIds(sections) {
+    const ids = [];
+    for (let s of sections) {
+      if (s.checked) {
+        ids.push(s.id);
+      }
+    }
+    return ids;
+  }
+
+  delete() {
+    const selectedDashboards = this.getDashboardsToDelete();
+
+    appEvents.emit('confirm-modal', {
+      title: 'Delete',
+      text: `Do you want to delete the ${selectedDashboards.length} selected dashboards?`,
+      icon: 'fa-trash',
+      yesText: 'Delete',
+      onConfirm: () => {
+        const promises = [];
+        for (let dash of selectedDashboards) {
+          promises.push(this.backendSrv.delete(`/api/dashboards/${dash}`));
+        }
+
+        this.$q.all(promises).then(() => {
+          this.getDashboards();
+        });
+      }
+    });
+  }
+
+  getDashboardsToMove() {
+    let selectedDashboards = [];
+
+    for (const section of this.sections) {
+      const selected = _.filter(section.items, { checked: true });
+      selectedDashboards.push(..._.map(selected, 'uri'));
+    }
+
+    return selectedDashboards;
+  }
+
+  moveTo() {
+    const selectedDashboards = this.getDashboardsToMove();
+
+    const template = '<move-to-folder-modal dismiss="dismiss()" ' +
+      'dashboards="model.dashboards" after-save="model.afterSave()">' +
+      '</move-to-folder-modal>`';
+    appEvents.emit('show-modal', {
+      templateHtml: template,
+      modalClass: 'modal--narrow',
+      model: { dashboards: selectedDashboards, afterSave: this.getDashboards.bind(this) }
+    });
+  }
+
+  getTags() {
+    return this.searchSrv.getDashboardTags().then((results) => {
+      this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
+      this.selectedTagFilter = this.tagFilterOptions[0];
+    });
+  }
+
+  filterByTag(tag) {
+    if (_.indexOf(this.query.tag, tag) === -1) {
+      this.query.tag.push(tag);
+    }
+
+    return this.getDashboards();
+  }
+
+  onQueryChange() {
+    return this.getDashboards();
+  }
+
+  onTagFilterChange() {
+    var res = this.filterByTag(this.selectedTagFilter.term);
+    this.selectedTagFilter = this.tagFilterOptions[0];
+    return res;
+  }
+
+  removeTag(tag, evt) {
+    this.query.tag = _.without(this.query.tag, tag);
+    this.getDashboards();
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+  }
+
+  onStarredFilterChange() {
+    this.query.starred = this.selectedStarredFilter.text === 'Yes';
+    return this.getDashboards();
+  }
+
+  onSelectAllChanged() {
+    for (let section of this.sections) {
+      if (!section.hideHeader) {
+        section.checked = this.selectAllChecked;
+      }
+
+      section.items = _.map(section.items, (item) => {
+        item.checked = this.selectAllChecked;
+        return item;
+      });
+    }
+
+    this.selectionChanged();
+  }
+
+  clearFilters() {
+    this.query.query = '';
+    this.query.tag = [];
+    this.query.starred = false;
+    this.getDashboards();
+  }
+}
+
+export function manageDashboardsDirective() {
+  return {
+    restrict: 'E',
+    templateUrl: 'public/app/core/components/manage_dashboards/manage_dashboards.html',
+    controller: ManageDashboardsCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      folderId: '='
+    }
+  };
+}
+
+coreModule.directive('manageDashboards', manageDashboardsDirective);

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

@@ -20,37 +20,14 @@
 
 	<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)"
+            on-folder-expanding="ctrl.folderExpanding()"
+            on-folder-expanded="ctrl.folderExpanded($folder)" />
         </div>
-      </div>
     </div>
 
     <div class="search-dropdown__col_2">

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

@@ -64,18 +64,75 @@ 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;
+      }
+    }
+
+    if (direction === 0) {
+      this.selectedIndex = -1;
+      return;
+    }
+
+    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 +141,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 +152,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 +183,36 @@ export class SearchCtrl {
 
   search() {
     this.showImport = false;
-    this.selectedIndex = 0;
+    this.selectedIndex = -1;
     this.searchDashboards();
   }
 
-  toggleFolder(section) {
-    this.searchSrv.toggleSection(section);
+  folderExpanding() {
+    this.moveSelection(0);
+  }
+
+  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" 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>

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

@@ -0,0 +1,76 @@
+// import _ from 'lodash';
+import coreModule from '../../core_module';
+
+export class SearchResultsCtrl {
+  results: any;
+  onSelectionChanged: any;
+  onTagSelected: any;
+  onFolderExpanding: any;
+
+  /** @ngInject */
+  constructor(private $location) {
+
+  }
+
+  toggleFolderExpand(section) {
+    if (section.toggle) {
+      if (!section.expanded && this.onFolderExpanding) {
+        this.onFolderExpanding();
+      }
+
+      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: '&',
+      onFolderExpanding: '&'
+    },
+  };
+}
+
+coreModule.directive('dashboardSearchResults', searchResultsDirective);

+ 6 - 1
public/app/core/components/sidemenu/sidemenu.html

@@ -2,6 +2,11 @@
 	<img src="public/img/grafana_icon.svg"></img>
 </a>
 
+<a class="sidemenu__logo_small_breakpoint" ng-click="ctrl.toggleSideMenuSmallBreakpoint()">
+  <i class="fa fa-bars"></i>
+  <span class="sidemenu__close"><i class="fa fa-times"></i>&nbsp;Close</span>
+</a>
+
 <div class="sidemenu__top">
 	<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
 		<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
@@ -54,7 +59,7 @@
 				</a>
 			</li>
 			<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
-				<a href="{{::child.url}}" target="{{::child.target}}">
+				<a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
 					<i class="{{::child.icon}}" ng-show="::child.icon"></i>
 					{{::child.text}}
 				</a>

+ 15 - 6
public/app/core/components/sidemenu/sidemenu.ts

@@ -1,9 +1,8 @@
-///<reference path="../../../headers/common.d.ts" />
-
 import _ from 'lodash';
 import config from 'app/core/config';
 import $ from 'jquery';
 import coreModule from '../../core_module';
+import appEvents from 'app/core/app_events';
 
 export class SideMenuCtrl {
   user: any;
@@ -11,6 +10,7 @@ export class SideMenuCtrl {
   bottomNav: any;
   loginUrl: string;
   isSignedIn: boolean;
+  isOpenMobile: boolean;
 
   /** @ngInject */
   constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
@@ -34,16 +34,29 @@ export class SideMenuCtrl {
 
   toggleSideMenu() {
     this.contextSrv.toggleSideMenu();
+    appEvents.emit('toggle-sidemenu');
+
     this.$timeout(() => {
       this.$rootScope.$broadcast('render');
     });
   }
 
+  toggleSideMenuSmallBreakpoint() {
+    appEvents.emit('toggle-sidemenu-mobile');
+  }
+
   switchOrg() {
     this.$rootScope.appEvent('show-modal', {
       templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
     });
   }
+
+  itemClicked(item, evt) {
+    if (item.url === '/shortcuts') {
+      appEvents.emit('show-modal', {templateHtml: '<help-modal></help-modal>'});
+      evt.preventDefault();
+    }
+  }
 }
 
 export function sideMenuDirective() {
@@ -65,10 +78,6 @@ export function sideMenuDirective() {
           parent.append(menu);
         }, 100);
       });
-
-      scope.$on("$destory", function() {
-        elem.off('click.dropdown');
-      });
     }
   };
 }

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

@@ -17,6 +17,10 @@ class Settings {
     alertingEnabled: boolean;
     authProxyEnabled: boolean;
     ldapEnabled: boolean;
+    oauth: any;
+    disableUserSignUp: boolean;
+    loginHint: any;
+    loginError: any;
 
     constructor(options) {
         var defaults = {

+ 0 - 9
public/app/core/controllers/all.js

@@ -1,9 +0,0 @@
-define([
-  './inspect_ctrl',
-  './json_editor_ctrl',
-  './login_ctrl',
-  './invited_ctrl',
-  './signup_ctrl',
-  './reset_password_ctrl',
-  './error_ctrl',
-], function () {});

+ 7 - 0
public/app/core/controllers/all.ts

@@ -0,0 +1,7 @@
+import './inspect_ctrl';
+import './json_editor_ctrl';
+import './login_ctrl';
+import './invited_ctrl';
+import './signup_ctrl';
+import './reset_password_ctrl';
+import './error_ctrl';

+ 0 - 23
public/app/core/controllers/error_ctrl.js

@@ -1,23 +0,0 @@
-define([
-  'angular',
-  'app/core/config',
-  '../core_module',
-],
-function (angular, config, coreModule) {
-  'use strict';
-
-  coreModule.default.controller('ErrorCtrl', function($scope, contextSrv, navModelSrv) {
-
-    $scope.navModel = navModelSrv.getNotFoundNav();
-    $scope.appSubUrl = config.appSubUrl;
-
-    var showSideMenu = contextSrv.sidemenu;
-    contextSrv.sidemenu = false;
-
-    $scope.$on('$destroy', function() {
-      contextSrv.sidemenu = showSideMenu;
-    });
-
-  });
-
-});

+ 24 - 0
public/app/core/controllers/error_ctrl.ts

@@ -0,0 +1,24 @@
+import config from 'app/core/config';
+import coreModule from '../core_module';
+import appEvents from 'app/core/app_events';
+
+export class ErrorCtrl {
+
+  /** @ngInject */
+  constructor($scope, contextSrv, navModelSrv) {
+    $scope.navModel = navModelSrv.getNotFoundNav();
+    $scope.appSubUrl = config.appSubUrl;
+
+    if (!contextSrv.isSignedIn) {
+      appEvents.emit('toggle-sidemenu-hidden');
+    }
+
+    $scope.$on("destroy", () => {
+      if (!contextSrv.isSignedIn) {
+        appEvents.emit('toggle-sidemenu-hidden');
+      }
+    });
+  }
+}
+
+coreModule.controller('ErrorCtrl', ErrorCtrl);

+ 8 - 11
public/app/core/controllers/invited_ctrl.js → public/app/core/controllers/invited_ctrl.ts

@@ -1,14 +1,10 @@
-define([
-  'angular',
-  '../core_module',
-  'app/core/config',
-],
-function (angular, coreModule, config) {
-  'use strict';
+import coreModule from '../core_module';
+import config from 'app/core/config';
 
-  config = config.default;
+export class InvitedCtrl {
 
-  coreModule.default.controller('InvitedCtrl', function($scope, $routeParams, contextSrv, backendSrv) {
+  /** @ngInject */
+  constructor($scope, $routeParams, contextSrv, backendSrv) {
     contextSrv.sidemenu = false;
     $scope.formModel = {};
 
@@ -35,6 +31,7 @@ function (angular, coreModule, config) {
     };
 
     $scope.init();
+  }
+}
 
-  });
-});
+coreModule.controller('InvitedCtrl', InvitedCtrl);

+ 8 - 10
public/app/core/controllers/json_editor_ctrl.js → public/app/core/controllers/json_editor_ctrl.ts

@@ -1,12 +1,10 @@
-define([
-  'angular',
-  '../core_module',
-],
-function (angular, coreModule) {
-  'use strict';
+import angular from 'angular';
+import coreModule from '../core_module';
 
-  coreModule.default.controller('JsonEditorCtrl', function($scope) {
+export class JsonEditorCtrl {
 
+  /** @ngInject */
+  constructor($scope) {
     $scope.json = angular.toJson($scope.object, true);
     $scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
 
@@ -14,7 +12,7 @@ function (angular, coreModule) {
       var newObject = angular.fromJson($scope.json);
       $scope.updateHandler(newObject, $scope.object);
     };
+  }
+}
 
-  });
-
-});
+coreModule.controller('JsonEditorCtrl', JsonEditorCtrl);

+ 13 - 16
public/app/core/controllers/login_ctrl.js → public/app/core/controllers/login_ctrl.ts

@@ -1,15 +1,11 @@
-define([
-  'angular',
-  'lodash',
-  '../core_module',
-  'app/core/config',
-],
-function (angular, _, coreModule, config) {
-  'use strict';
-
-  config = config.default;
-
-  coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) {
+import _ from 'lodash';
+import coreModule from '../core_module';
+import config from 'app/core/config';
+
+export class LoginCtrl {
+
+  /** @ngInject */
+  constructor($scope, backendSrv, contextSrv, $location) {
     $scope.formModel = {
       user: '',
       email: '',
@@ -74,8 +70,7 @@ function (angular, _, coreModule, config) {
 
         if (params.redirect && params.redirect[0] === '/') {
           window.location.href = config.appSubUrl + params.redirect;
-        }
-        else if (result.redirectUrl) {
+        } else if (result.redirectUrl) {
           window.location.href = result.redirectUrl;
         } else {
           window.location.href = config.appSubUrl + '/';
@@ -84,5 +79,7 @@ function (angular, _, coreModule, config) {
     };
 
     $scope.init();
-  });
-});
+  }
+}
+
+coreModule.controller('LoginCtrl', LoginCtrl);

+ 9 - 11
public/app/core/controllers/reset_password_ctrl.js → public/app/core/controllers/reset_password_ctrl.ts

@@ -1,11 +1,9 @@
-define([
-  'angular',
-  '../core_module',
-],
-function (angular, coreModule) {
-  'use strict';
-
-  coreModule.default.controller('ResetPasswordCtrl', function($scope, contextSrv, backendSrv, $location) {
+import coreModule from '../core_module';
+
+export class ResetPasswordCtrl {
+
+  /** @ngInject */
+  constructor($scope, contextSrv, backendSrv, $location) {
     contextSrv.sidemenu = false;
     $scope.formModel = {};
     $scope.mode = 'send';
@@ -37,7 +35,7 @@ function (angular, coreModule) {
         $location.path('login');
       });
     };
+  }
+}
 
-  });
-
-});
+coreModule.controller('ResetPasswordCtrl', ResetPasswordCtrl);

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

@@ -18,6 +18,7 @@ import './components/colorpicker/ColorPicker';
 import './components/colorpicker/SeriesColorPicker';
 import './components/colorpicker/spectrum_picker';
 import './services/search_srv';
+import './services/ng_react';
 
 import {grafanaAppDirective} from './components/grafana_app';
 import {sideMenuDirective} from './components/sidemenu/sidemenu';
@@ -52,6 +53,10 @@ import {gfPageDirective} from './components/gf_page';
 import {orgSwitcher} from './components/org_switcher';
 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';
+import {manageDashboardsDirective} from './components/manage_dashboards/manage_dashboards';
 
 export {
   profiler,
@@ -83,5 +88,9 @@ export {
   userGroupPicker,
   geminiScrollbar,
   gfPageDirective,
-  orgSwitcher
+  orgSwitcher,
+  manageDashboardsDirective,
+  TimeSeries,
+  updateLegendValues,
+  searchResultsDirective
 };

+ 4 - 10
public/app/core/nav_model_srv.ts

@@ -70,13 +70,15 @@ export class NavModelSrv {
 
   getNotFoundNav() {
     var node = {
-      text: "Page not found ",
+      text: "Page not found",
       icon: "fa fa-fw fa-warning",
+      subTitle: "404 Error"
     };
 
     return {
       breadcrumbs: [node],
-      node: node
+      node: node,
+      main: node
     };
   }
 
@@ -119,14 +121,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',

+ 26 - 2
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',
@@ -64,10 +69,25 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
     controllerAs: 'ctrl',
   })
   .when('/dashboards', {
-    templateUrl: 'public/app/features/dashboard/partials/dashboardList.html',
+    templateUrl: 'public/app/features/dashboard/partials/dashboard_list.html',
     controller : 'DashboardListCtrl',
     controllerAs: 'ctrl',
   })
+  .when('/dashboards/folder/new', {
+    templateUrl: 'public/app/features/dashboard/partials/create_folder.html',
+    controller : 'CreateFolderCtrl',
+    controllerAs: 'ctrl',
+  })
+  .when('/dashboards/folder/:folderId/:type/:slug/permissions', {
+    templateUrl: 'public/app/features/dashboard/partials/folder_permissions.html',
+    controller : 'FolderPermissionsCtrl',
+    controllerAs: 'ctrl',
+  })
+  .when('/dashboards/folder/:folderId/:type/:slug', {
+    templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html',
+    controller : 'FolderDashboardsCtrl',
+    controllerAs: 'ctrl',
+  })
   .when('/org', {
     templateUrl: 'public/app/features/org/partials/orgDetails.html',
     controller : 'OrgDetailsCtrl',
@@ -168,23 +188,27 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
   .when('/login', {
     templateUrl: 'public/app/partials/login.html',
     controller : 'LoginCtrl',
-    pageClass: 'page-login',
+    pageClass: 'sidemenu-hidden',
   })
   .when('/invite/:code', {
     templateUrl: 'public/app/partials/signup_invited.html',
     controller : 'InvitedCtrl',
+    pageClass: 'sidemenu-hidden',
   })
   .when('/signup', {
     templateUrl: 'public/app/partials/signup_step2.html',
     controller : 'SignUpCtrl',
+    pageClass: 'sidemenu-hidden',
   })
   .when('/user/password/send-reset-email', {
     templateUrl: 'public/app/partials/reset_password.html',
     controller : 'ResetPasswordCtrl',
+    pageClass: 'sidemenu-hidden',
   })
   .when('/user/password/reset', {
     templateUrl: 'public/app/partials/reset_password.html',
     controller : 'ResetPasswordCtrl',
+    pageClass: 'sidemenu-hidden',
   })
   .when('/dashboard/snapshots', {
     templateUrl: 'public/app/features/snapshot/partials/snapshots.html',

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

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

+ 3 - 2
public/app/core/services/context_srv.ts

@@ -27,9 +27,10 @@ export class ContextSrv {
   isGrafanaAdmin: any;
   isEditor: any;
   sidemenu: any;
+  sidemenuSmallBreakpoint = false;
 
   constructor() {
-    this.sidemenu = store.getBool('grafana.sidemenu', false);
+    this.sidemenu = store.getBool('grafana.sidemenu', true);
 
     if (!config.buildInfo) {
       config.buildInfo = {};
@@ -55,7 +56,7 @@ export class ContextSrv {
 
   toggleSideMenu() {
     this.sidemenu = !this.sidemenu;
-    store.set('grafana.sidemenu',this.sidemenu);
+    store.set('grafana.sidemenu', this.sidemenu);
   }
 }
 

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

+ 0 - 2
public/app/core/services/keybindingSrv.ts

@@ -1,5 +1,3 @@
-///<reference path="../../headers/common.d.ts" />
-
 import $ from 'jquery';
 import _ from 'lodash';
 

+ 301 - 0
public/app/core/services/ng_react.ts

@@ -0,0 +1,301 @@
+//
+// This is using ng-react with this PR applied https://github.com/ngReact/ngReact/pull/199
+//
+
+
+// # ngReact
+// ### Use React Components inside of your Angular applications
+//
+// Composed of
+// - reactComponent (generic directive for delegating off to React Components)
+// - reactDirective (factory for creating specific directives that correspond to reactComponent directives)
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import angular from 'angular';
+
+// get a react component from name (components can be an angular injectable e.g. value, factory or
+// available on window
+function getReactComponent(name, $injector) {
+  // if name is a function assume it is component and return it
+  if (angular.isFunction(name)) {
+    return name;
+  }
+
+  // a React component name must be specified
+  if (!name) {
+    throw new Error('ReactComponent name attribute must be specified');
+  }
+
+  // ensure the specified React component is accessible, and fail fast if it's not
+  var reactComponent;
+  try {
+    reactComponent = $injector.get(name);
+  } catch (e) {}
+
+  if (!reactComponent) {
+    try {
+      reactComponent = name.split('.').reduce(function(current, namePart) {
+        return current[namePart];
+      }, window);
+    } catch (e) {}
+  }
+
+  if (!reactComponent) {
+    throw Error('Cannot find react component ' + name);
+  }
+
+  return reactComponent;
+}
+
+// wraps a function with scope.$apply, if already applied just return
+function applied(fn, scope) {
+  if (fn.wrappedInApply) {
+    return fn;
+  }
+  var wrapped: any = function() {
+    var args = arguments;
+    var phase = scope.$root.$$phase;
+    if (phase === '$apply' || phase === '$digest') {
+      return fn.apply(null, args);
+    } else {
+      return scope.$apply(function() {
+        return fn.apply(null, args);
+      });
+    }
+  };
+  wrapped.wrappedInApply = true;
+  return wrapped;
+}
+
+/**
+ * wraps functions on obj in scope.$apply
+ *
+ * keeps backwards compatibility, as if propsConfig is not passed, it will
+ * work as before, wrapping all functions and won't wrap only when specified.
+ *
+ * @version 0.4.1
+ * @param obj react component props
+ * @param scope current scope
+ * @param propsConfig configuration object for all properties
+ * @returns {Object} props with the functions wrapped in scope.$apply
+ */
+function applyFunctions(obj, scope, propsConfig?) {
+  return Object.keys(obj || {}).reduce(function(prev, key) {
+    var value = obj[key];
+    var config = (propsConfig || {})[key] || {};
+    /**
+       * wrap functions in a function that ensures they are scope.$applied
+       * ensures that when function is called from a React component
+       * the Angular digest cycle is run
+       */
+    prev[key] = angular.isFunction(value) && config.wrapApply !== false ? applied(value, scope) : value;
+
+    return prev;
+  }, {});
+}
+
+/**
+   *
+   * @param watchDepth (value of HTML watch-depth attribute)
+   * @param scope (angular scope)
+   *
+   * Uses the watchDepth attribute to determine how to watch props on scope.
+   * If watchDepth attribute is NOT reference or collection, watchDepth defaults to deep watching by value
+   */
+function watchProps(watchDepth, scope, watchExpressions, listener) {
+  var supportsWatchCollection = angular.isFunction(scope.$watchCollection);
+  var supportsWatchGroup = angular.isFunction(scope.$watchGroup);
+
+  var watchGroupExpressions = [];
+
+  watchExpressions.forEach(function(expr) {
+    var actualExpr = getPropExpression(expr);
+    var exprWatchDepth = getPropWatchDepth(watchDepth, expr);
+
+    if (exprWatchDepth === 'collection' && supportsWatchCollection) {
+      scope.$watchCollection(actualExpr, listener);
+    } else if (exprWatchDepth === 'reference' && supportsWatchGroup) {
+      watchGroupExpressions.push(actualExpr);
+    } else if (exprWatchDepth === 'one-time') {
+      //do nothing because we handle our one time bindings after this
+    } else {
+      scope.$watch(actualExpr, listener, exprWatchDepth !== 'reference');
+    }
+  });
+
+  if (watchDepth === 'one-time') {
+    listener();
+  }
+
+  if (watchGroupExpressions.length) {
+    scope.$watchGroup(watchGroupExpressions, listener);
+  }
+}
+
+// render React component, with scope[attrs.props] being passed in as the component props
+function renderComponent(component, props, scope, elem) {
+  scope.$evalAsync(function() {
+    ReactDOM.render(React.createElement(component, props), elem[0]);
+  });
+}
+
+// get prop name from prop (string or array)
+function getPropName(prop) {
+  return Array.isArray(prop) ? prop[0] : prop;
+}
+
+// get prop name from prop (string or array)
+function getPropConfig(prop) {
+  return Array.isArray(prop) ? prop[1] : {};
+}
+
+// get prop expression from prop (string or array)
+function getPropExpression(prop) {
+  return Array.isArray(prop) ? prop[0] : prop;
+}
+
+// find the normalized attribute knowing that React props accept any type of capitalization
+function findAttribute(attrs, propName) {
+  var index = Object.keys(attrs).filter(function(attr) {
+    return attr.toLowerCase() === propName.toLowerCase();
+  })[0];
+  return attrs[index];
+}
+
+// get watch depth of prop (string or array)
+function getPropWatchDepth(defaultWatch, prop) {
+  var customWatchDepth = Array.isArray(prop) && angular.isObject(prop[1]) && prop[1].watchDepth;
+  return customWatchDepth || defaultWatch;
+}
+
+// # reactComponent
+// Directive that allows React components to be used in Angular templates.
+//
+// Usage:
+//     <react-component name="Hello" props="name"/>
+//
+// This requires that there exists an injectable or globally available 'Hello' React component.
+// The 'props' attribute is optional and is passed to the component.
+//
+// The following would would create and register the component:
+//
+//     var module = angular.module('ace.react.components');
+//     module.value('Hello', React.createClass({
+//         render: function() {
+//             return <div>Hello {this.props.name}</div>;
+//         }
+//     }));
+//
+var reactComponent = function($injector) {
+  return {
+    restrict: 'E',
+    replace: true,
+    link: function(scope, elem, attrs) {
+      var reactComponent = getReactComponent(attrs.name, $injector);
+
+      var renderMyComponent = function() {
+        var scopeProps = scope.$eval(attrs.props);
+        var props = applyFunctions(scopeProps, scope);
+
+        renderComponent(reactComponent, props, scope, elem);
+      };
+
+      // If there are props, re-render when they change
+      attrs.props ? watchProps(attrs.watchDepth, scope, [attrs.props], renderMyComponent) : renderMyComponent();
+
+      // cleanup when scope is destroyed
+      scope.$on('$destroy', function() {
+        if (!attrs.onScopeDestroy) {
+          ReactDOM.unmountComponentAtNode(elem[0]);
+        } else {
+          scope.$eval(attrs.onScopeDestroy, {
+            unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0]),
+          });
+        }
+      });
+    },
+  };
+};
+
+// # reactDirective
+// Factory function to create directives for React components.
+//
+// With a component like this:
+//
+//     var module = angular.module('ace.react.components');
+//     module.value('Hello', React.createClass({
+//         render: function() {
+//             return <div>Hello {this.props.name}</div>;
+//         }
+//     }));
+//
+// A directive can be created and registered with:
+//
+//     module.directive('hello', function(reactDirective) {
+//         return reactDirective('Hello', ['name']);
+//     });
+//
+// Where the first argument is the injectable or globally accessible name of the React component
+// and the second argument is an array of property names to be watched and passed to the React component
+// as props.
+//
+// This directive can then be used like this:
+//
+//     <hello name="name"/>
+//
+var reactDirective = function($injector) {
+  return function(reactComponentName, props, conf, injectableProps) {
+    var directive = {
+      restrict: 'E',
+      replace: true,
+      link: function(scope, elem, attrs) {
+        var reactComponent = getReactComponent(reactComponentName, $injector);
+
+        // if props is not defined, fall back to use the React component's propTypes if present
+        props = props || Object.keys(reactComponent.propTypes || {});
+
+        // for each of the properties, get their scope value and set it to scope.props
+        var renderMyComponent = function() {
+          var scopeProps = {},
+            config = {};
+
+          props.forEach(function(prop) {
+            var propName = getPropName(prop);
+            scopeProps[propName] = scope.$eval(findAttribute(attrs, propName));
+            config[propName] = getPropConfig(prop);
+          });
+
+          scopeProps = applyFunctions(scopeProps, scope, config);
+          scopeProps = angular.extend({}, scopeProps, injectableProps);
+          renderComponent(reactComponent, scopeProps, scope, elem);
+        };
+
+        // watch each property name and trigger an update whenever something changes,
+        // to update scope.props with new values
+        var propExpressions = props.map(function(prop) {
+          return Array.isArray(prop) ? [attrs[getPropName(prop)], getPropConfig(prop)] : attrs[prop];
+        });
+
+        // If we don't have any props, then our watch statement won't fire.
+        props.length ? watchProps(attrs.watchDepth, scope, propExpressions, renderMyComponent) : renderMyComponent();
+
+        // cleanup when scope is destroyed
+        scope.$on('$destroy', function() {
+          if (!attrs.onScopeDestroy) {
+            ReactDOM.unmountComponentAtNode(elem[0]);
+          } else {
+            scope.$eval(attrs.onScopeDestroy, {
+              unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0]),
+            });
+          }
+        });
+      },
+    };
+    return angular.extend(directive, conf);
+  };
+};
+
+let ngModule = angular.module('react', []);
+ngModule.directive('reactComponent', ['$injector', reactComponent]);
+ngModule.factory('reactDirective', ['$injector', reactDirective]);

+ 22 - 15
public/app/core/services/search_srv.ts

@@ -51,7 +51,7 @@ export class SearchSrv {
     store.set('search.sections.recent', this.recentIsOpen);
 
     if (!section.expanded || section.items.length) {
-      return;
+      return Promise.resolve();
     }
 
     return this.queryForRecentDashboards().then(result => {
@@ -62,6 +62,7 @@ export class SearchSrv {
   private toggleStarred(section) {
     this.starredIsOpen = section.expanded = !section.expanded;
     store.set('search.sections.starred', this.starredIsOpen);
+    return Promise.resolve();
   }
 
   private getStarred(sections) {
@@ -128,14 +129,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 +155,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: [],
@@ -179,7 +190,7 @@ export class SearchSrv {
     section.icon = section.expanded ? 'fa fa-folder-open' : 'fa fa-folder';
 
     if (section.items.length) {
-      return;
+      return Promise.resolve();
     }
 
     let query = {
@@ -191,10 +202,6 @@ export class SearchSrv {
     });
   }
 
-  toggleSection(section) {
-    section.toggle(section);
-  }
-
   getDashboardTags() {
     return this.backendSrv.get('/api/dashboards/tags');
   }

+ 3 - 6
public/app/features/dashboard/specs/dashboard_list_ctrl.jest.ts → public/app/core/specs/manage_dashboards.jest.ts

@@ -1,8 +1,8 @@
-import { DashboardListCtrl } from '../dashboard_list_ctrl';
+import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards';
 import { SearchSrv } from 'app/core/services/search_srv';
 import q from 'q';
 
-describe('DashboardListCtrl', () => {
+describe('ManageDashboards', () => {
   let ctrl;
 
   describe('when browsing dashboards', () => {
@@ -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 ManageDashboardsCtrl({}, { getNav: () => { } }, q, <SearchSrv>searchSrvStub);
 }

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

@@ -0,0 +1,335 @@
+import { SearchCtrl } from '../components/search/search';
+import { SearchSrv } from '../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();
+      });
+    });
+  });
+});

+ 97 - 0
public/app/core/specs/search_results.jest.ts

@@ -0,0 +1,97 @@
+import { SearchResultsCtrl } from '../components/search/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 collapsed folder', () => {
+    let folderExpanded = false;
+
+    beforeEach(() => {
+      ctrl = new SearchResultsCtrl({});
+      ctrl.onFolderExpanding = () => { folderExpanded = true; };
+
+      let folder = {
+        expanded: false,
+        toggle: () => {}
+      };
+
+      ctrl.toggleFolderExpand(folder);
+    });
+
+    it('should trigger folder expanding callback', () => {
+      expect(folderExpanded).toBeTruthy();
+    });
+  });
+
+  describe('when toggle an expanded folder', () => {
+    let folderExpanded = false;
+
+    beforeEach(() => {
+      ctrl = new SearchResultsCtrl({});
+      ctrl.onFolderExpanding = () => { folderExpanded = true; };
+
+      let folder = {
+        expanded: true,
+        toggle: () => {}
+      };
+
+      ctrl.toggleFolderExpand(folder);
+    });
+
+    it('should not trigger folder expanding callback', () => {
+      expect(folderExpanded).toBeFalsy();
+    });
+  });
+});

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

+ 43 - 0
public/app/core/time_series2.ts

@@ -1,4 +1,5 @@
 import kbn from 'app/core/utils/kbn';
+import {getFlotTickDecimals} from 'app/core/utils/ticks';
 import _ from 'lodash';
 
 function matchSeriesOverride(aliasOrRegex, seriesAlias) {
@@ -16,6 +17,48 @@ function translateFillOption(fill) {
   return fill === 0 ? 0.001 : fill/10;
 }
 
+/**
+ * Calculate decimals for legend and update values for each series.
+ * @param data series data
+ * @param panel
+ */
+export function updateLegendValues(data: TimeSeries[], panel) {
+  for (let i = 0; i < data.length; i++) {
+    let series = data[i];
+    let yaxes = panel.yaxes;
+    let axis = yaxes[series.yaxis - 1];
+    let {tickDecimals, scaledDecimals} = getFlotTickDecimals(data, axis);
+    let formater = kbn.valueFormats[panel.yaxes[series.yaxis - 1].format];
+
+    // decimal override
+    if (_.isNumber(panel.decimals)) {
+      series.updateLegendValues(formater, panel.decimals, null);
+    } else {
+      // auto decimals
+      // legend and tooltip gets one more decimal precision
+      // than graph legend ticks
+      tickDecimals = (tickDecimals || -1) + 1;
+      series.updateLegendValues(formater, tickDecimals, scaledDecimals + 2);
+    }
+  }
+}
+
+export function getDataMinMax(data: TimeSeries[]) {
+  let datamin = null;
+  let datamax = null;
+
+  for (let series of data) {
+    if (datamax === null || datamax < series.stats.max) {
+      datamax = series.stats.max;
+    }
+    if (datamin === null || datamin > series.stats.min) {
+      datamin = series.stats.min;
+    }
+  }
+
+  return {datamin, datamax};
+}
+
 export default class TimeSeries {
   datapoints: any;
   id: string;

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

+ 91 - 0
public/app/core/utils/ticks.ts

@@ -1,3 +1,5 @@
+import {getDataMinMax} from 'app/core/time_series2';
+
 /**
  * Calculate tick step.
  * Implementation from d3-array (ticks.js)
@@ -32,6 +34,7 @@ export function getScaledDecimals(decimals, tick_size) {
 
 /**
  * Calculate tick size based on min and max values, number of ticks and precision.
+ * Implementation from Flot.
  * @param min           Axis minimum
  * @param max           Axis maximum
  * @param noTicks       Number of ticks
@@ -65,3 +68,91 @@ export function getFlotTickSize(min: number, max: number, noTicks: number, tickD
 
   return size;
 }
+
+/**
+ * Calculate axis range (min and max).
+ * Implementation from Flot.
+ */
+export function getFlotRange(panelMin, panelMax, datamin, datamax) {
+  const autoscaleMargin = 0.02;
+
+  let min = +(panelMin != null ? panelMin : datamin);
+  let max = +(panelMax != null ? panelMax : datamax);
+  let delta = max - min;
+
+  if (delta === 0.0) {
+      // Grafana fix: wide Y min and max using increased wideFactor
+      // when all series values are the same
+      var wideFactor = 0.25;
+      var widen = Math.abs(max === 0 ? 1 : max * wideFactor);
+
+      if (panelMin === null) {
+        min -= widen;
+      }
+      // always widen max if we couldn't widen min to ensure we
+      // don't fall into min == max which doesn't work
+      if (panelMax == null || panelMin != null) {
+        max += widen;
+      }
+  } else {
+    // consider autoscaling
+    var margin = autoscaleMargin;
+    if (margin != null) {
+      if (panelMin == null) {
+        min -= delta * margin;
+        // make sure we don't go below zero if all values
+        // are positive
+        if (min < 0 && datamin != null && datamin >= 0) {
+          min = 0;
+        }
+      }
+      if (panelMax == null) {
+        max += delta * margin;
+        if (max > 0 && datamax != null && datamax <= 0) {
+          max = 0;
+        }
+      }
+    }
+  }
+  return {min, max};
+}
+
+/**
+ * Calculate tick decimals.
+ * Implementation from Flot.
+ */
+export function getFlotTickDecimals(data, axis) {
+  let {datamin, datamax} = getDataMinMax(data);
+  let {min, max} = getFlotRange(axis.min, axis.max, datamin, datamax);
+  let noTicks = 3;
+  let tickDecimals, maxDec;
+  let delta = (max - min) / noTicks;
+  let dec = -Math.floor(Math.log(delta) / Math.LN10);
+
+  let magn = Math.pow(10, -dec);
+  // norm is between 1.0 and 10.0
+  let norm = delta / magn;
+  let size;
+
+  if (norm < 1.5) {
+    size = 1;
+  } else if (norm < 3) {
+    size = 2;
+    // special case for 2.5, requires an extra decimal
+    if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) {
+      size = 2.5;
+      ++dec;
+    }
+  } else if (norm < 7.5) {
+    size = 5;
+  } else {
+    size = 10;
+  }
+
+  size *= magn;
+
+  tickDecimals = Math.max(0, maxDec != null ? maxDec : dec);
+  // grafana addition
+  const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10);
+  return {tickDecimals, scaledDecimals};
+}

+ 9 - 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';
@@ -31,4 +30,13 @@ import './settings/settings';
 
 import coreModule from 'app/core/core_module';
 import {DashboardListCtrl} from './dashboard_list_ctrl';
+import {FolderDashboardsCtrl} from './folder_dashboards_ctrl';
+import {FolderPermissionsCtrl} from './folder_permissions_ctrl';
+import {DashboardImportCtrl} from './dashboard_import_ctrl';
+import {CreateFolderCtrl} from './create_folder_ctrl';
+
 coreModule.controller('DashboardListCtrl', DashboardListCtrl);
+coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
+coreModule.controller('FolderPermissionsCtrl', FolderPermissionsCtrl);
+coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
+coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

+ 41 - 0
public/app/features/dashboard/create_folder_ctrl.ts

@@ -0,0 +1,41 @@
+import appEvents from 'app/core/app_events';
+
+export class CreateFolderCtrl {
+  title = '';
+  navModel: any;
+  nameExists = false;
+  titleTouched = false;
+
+  constructor(private backendSrv, private $location, navModelSrv) {
+    this.navModel = navModelSrv.getNav('create', 'folder');
+  }
+
+  create() {
+    if (!this.title || this.title.trim().length === 0) {
+      return;
+    }
+
+    const title = this.title.trim();
+
+    return this.backendSrv.createDashboardFolder(title).then(result => {
+      appEvents.emit('alert-success', ['Folder Created', 'OK']);
+
+      var folderUrl = `/dashboards/folder/${result.id}/db/${result.slug}`;
+      this.$location.url(folderUrl);
+    });
+  }
+
+  titleChanged() {
+    this.titleTouched = true;
+
+    this.backendSrv.search({query: this.title}).then(res => {
+      this.nameExists = false;
+      for (let hit of res) {
+        if (this.title === hit.title) {
+          this.nameExists = true;
+          break;
+        }
+      }
+    });
+  }
+}

+ 0 - 1
public/app/features/dashboard/dashboard_ctrl.ts

@@ -129,7 +129,6 @@ export class DashboardCtrl implements PanelContainer {
     }
 
     getPanelContainer() {
-      console.log('DashboardCtrl:getPanelContainer()');
       return this;
     }
 

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

+ 2 - 200
public/app/features/dashboard/dashboard_list_ctrl.ts

@@ -1,206 +1,8 @@
-import _ from 'lodash';
-import appEvents from 'app/core/app_events';
-import { SearchSrv } from 'app/core/services/search_srv';
-
 export class DashboardListCtrl {
-  public sections: any [];
-  tagFilterOptions: any [];
-  selectedTagFilter: any;
-  query: any;
   navModel: any;
-  canDelete = false;
-  canMove = false;
-  hasFilters = false;
-  selectAllChecked = false;
-  starredFilterOptions = [{text: 'Filter by Starred', disabled: true}, {text: 'Yes'}, {text: 'No'}];
-  selectedStarredFilter: any;
 
   /** @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};
-    this.selectedStarredFilter = this.starredFilterOptions[0];
-
-    this.getDashboards().then(() => {
-      this.getTags();
-    });
-  }
-
-  getDashboards() {
-    return this.searchSrv.search(this.query).then((result) => {
-      return this.initDashboardList(result);
-    });
-  }
-
-  initDashboardList(result: any) {
-    this.canMove = false;
-    this.canDelete = false;
-    this.selectAllChecked = false;
-    this.hasFilters = this.query.query.length > 0 || this.query.tag.length > 0 || this.query.starred;
-
-    if (!result) {
-      this.sections = [];
-      return;
-    }
-
-    this.sections = result;
-
-    for (let section of this.sections) {
-      section.checked = false;
-
-      for (let dashboard of section.items) {
-        dashboard.checked = false;
-      }
-    }
-  }
-
-  selectionChanged() {
-
-    let selectedDashboards = 0;
-
-    for (let section of this.sections) {
-      selectedDashboards += _.filter(section.items, {checked: true}).length;
-    }
-
-    const selectedFolders = _.filter(this.sections, {checked: true}).length;
-    this.canMove = selectedDashboards > 0 && selectedFolders === 0;
-    this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
-  }
-
-  getDashboardsToDelete() {
-    let selectedDashboards = [];
-
-    for (const section of this.sections) {
-      if (section.checked) {
-        selectedDashboards.push(section.uri);
-      } else {
-        const selected = _.filter(section.items, {checked: true});
-        selectedDashboards.push(... _.map(selected, 'uri'));
-      }
-    }
-
-    return selectedDashboards;
-  }
-
-  getFolderIds(sections) {
-    const ids = [];
-    for (let s of sections) {
-      if (s.checked) {
-        ids.push(s.id);
-      }
-    }
-    return ids;
-  }
-
-  delete() {
-    const selectedDashboards =  this.getDashboardsToDelete();
-
-    appEvents.emit('confirm-modal', {
-      title: 'Delete',
-      text: `Do you want to delete the ${selectedDashboards.length} selected dashboards?`,
-      icon: 'fa-trash',
-      yesText: 'Delete',
-      onConfirm: () => {
-        const promises = [];
-        for (let dash of selectedDashboards) {
-          promises.push(this.backendSrv.delete(`/api/dashboards/${dash}`));
-        }
-
-        this.$q.all(promises).then(() => {
-          this.getDashboards();
-        });
-      }
-    });
-  }
-
-  getDashboardsToMove() {
-    let selectedDashboards = [];
-
-    for (const section of this.sections) {
-      const selected = _.filter(section.items, {checked: true});
-      selectedDashboards.push(... _.map(selected, 'uri'));
-    }
-
-    return selectedDashboards;
-  }
-
-  moveTo() {
-    const selectedDashboards = this.getDashboardsToMove();
-
-    const template = '<move-to-folder-modal dismiss="dismiss()" ' +
-      'dashboards="model.dashboards" after-save="model.afterSave()">' +
-      '</move-to-folder-modal>`';
-    appEvents.emit('show-modal', {
-      templateHtml: template,
-      modalClass: 'modal--narrow',
-      model: {dashboards: selectedDashboards, afterSave: this.getDashboards.bind(this)}
-    });
-  }
-
-  toggleFolder(section) {
-    return this.searchSrv.toggleSection(section);
-  }
-
-  getTags() {
-    return this.searchSrv.getDashboardTags().then((results) => {
-      this.tagFilterOptions =  [{ term: 'Filter By Tag', disabled: true }].concat(results);
-      this.selectedTagFilter = this.tagFilterOptions[0];
-    });
-  }
-
-  filterByTag(tag, evt) {
-    this.query.tag.push(tag);
-    if (evt) {
-      evt.stopPropagation();
-      evt.preventDefault();
-    }
-
-    return this.getDashboards();
-  }
-
-  onQueryChange() {
-    return this.getDashboards();
-  }
-
-  onTagFilterChange() {
-    this.query.tag.push(this.selectedTagFilter.term);
-    this.selectedTagFilter = this.tagFilterOptions[0];
-    return this.getDashboards();
-  }
-
-  removeTag(tag, evt) {
-    this.query.tag = _.without(this.query.tag, tag);
-    this.getDashboards();
-    if (evt) {
-      evt.stopPropagation();
-      evt.preventDefault();
-    }
-  }
-
-  onStarredFilterChange() {
-    this.query.starred = this.selectedStarredFilter.text === 'Yes';
-    return this.getDashboards();
-  }
-
-  onSelectAllChanged() {
-    for (let section of this.sections) {
-      if (!section.hideHeader) {
-        section.checked = this.selectAllChecked;
-      }
-
-      section.items = _.map(section.items, (item) => {
-        item.checked = this.selectAllChecked;
-        return item;
-      });
-    }
-
-    this.selectionChanged();
-  }
-
-  clearFilters() {
-    this.query.query = '';
-    this.query.tag = [];
-    this.query.starred = false;
-    this.getDashboards();
+  constructor(navModelSrv) {
+    this.navModel = navModelSrv.getNav('dashboards', 'manage-dashboards', 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);

+ 3 - 2
public/app/features/dashboard/dashgrid/AddPanelPanel.tsx

@@ -4,6 +4,7 @@ import _ from 'lodash';
 import config from 'app/core/config';
 import {PanelModel} from '../panel_model';
 import {PanelContainer} from './PanelContainer';
+import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
 
 export interface AddPanelPanelProps {
   panel: PanelModel;
@@ -78,9 +79,9 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
             <span className="add-panel__title">New Panel</span>
             <span className="add-panel__sub-title">Select a visualization</span>
           </div>
-          <div className="add-panel__items">
+          <ScrollBar className="add-panel__items">
             {this.state.panelPlugins.map(this.renderPanelItem.bind(this))}
-          </div>
+          </ScrollBar>
         </div>
       </div>
     );

+ 14 - 3
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -11,7 +11,7 @@ import sizeMe from 'react-sizeme';
 
 let lastGridWidth = 1200;
 
-function GridWrapper({size, layout, onLayoutChange, children, onResize, onResizeStop, onWidthChange}) {
+function GridWrapper({size, layout, onLayoutChange, children, onResize, onResizeStop, onWidthChange, className}) {
   if (size.width === 0) {
     console.log('size is zero!');
   }
@@ -25,12 +25,12 @@ function GridWrapper({size, layout, onLayoutChange, children, onResize, onResize
   return (
     <ReactGridLayout
       width={lastGridWidth}
-      className="layout"
+      className={className}
       isDraggable={true}
       isResizable={true}
       measureBeforeMount={false}
       containerPadding={[0, 0]}
-      useCSSTransforms={false}
+      useCSSTransforms={true}
       margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
       cols={GRID_COLUMN_COUNT}
       rowHeight={GRID_CELL_HEIGHT}
@@ -64,6 +64,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
     this.onResizeStop = this.onResizeStop.bind(this);
     this.onWidthChange = this.onWidthChange.bind(this);
 
+    this.state = {animated: false};
+
     // subscribe to dashboard events
     this.dashboard = this.panelContainer.getDashboard();
     this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
@@ -134,6 +136,14 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
     this.panelMap[newItem.i].resizeDone();
   }
 
+  componentDidMount() {
+    setTimeout(() => {
+      this.setState(() => {
+        return {animated: true};
+      });
+    });
+  }
+
   renderPanels() {
     const panelElements = [];
 
@@ -152,6 +162,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
   render() {
     return (
       <SizedReactLayoutGrid
+        className={classNames({'layout': true, 'animated': this.state.animated})}
         layout={this.buildLayout()}
         onLayoutChange={this.onLayoutChange}
         onWidthChange={this.onWidthChange}

+ 2 - 7
public/app/features/dashboard/dashnav/dashnav.ts

@@ -16,7 +16,6 @@ export class DashNavCtrl {
     private dashboardSrv,
     private $location,
     private backendSrv,
-    private contextSrv,
     public playlistSrv,
     navModelSrv) {
       this.navModel = navModelSrv.getDashboardNav(this.dashboard, this);
@@ -33,12 +32,8 @@ export class DashNavCtrl {
       }
     }
 
-    toggleSideMenu() {
-      this.contextSrv.toggleSideMenu();
-    }
-
-    openSettings() {
-      var search = _.extend(this.$location.search(), {editview: 'general'});
+    openEditView(editview) {
+      var search = _.extend(this.$location.search(), {editview: editview});
       this.$location.search(search);
     }
 

+ 1 - 1
public/app/features/dashboard/export/export_modal.ts

@@ -26,7 +26,7 @@ export class DashExportCtrl {
   }
 
   saveJson() {
-    var clone = this.dashboardSrv.getCurrent().getSaveModelClone();
+    var clone = this.dash;
 
     this.$scope.$root.appEvent('show-json-editor', {
       object: clone,

+ 16 - 0
public/app/features/dashboard/folder_dashboards_ctrl.ts

@@ -0,0 +1,16 @@
+import {FolderPageLoader} from './folder_page_loader';
+
+export class FolderDashboardsCtrl {
+  navModel: any;
+  folderId: number;
+
+  /** @ngInject */
+  constructor(private backendSrv, navModelSrv, private $routeParams) {
+    if (this.$routeParams.folderId && this.$routeParams.type && this.$routeParams.slug) {
+      this.folderId = $routeParams.folderId;
+      this.navModel = navModelSrv.getNav('manage-folder', 'manage-folder-dashboards', 0);
+
+      new FolderPageLoader(this.backendSrv, this.$routeParams).load(this.navModel, this.folderId);
+    }
+  }
+}

+ 21 - 0
public/app/features/dashboard/folder_page_loader.ts

@@ -0,0 +1,21 @@
+import _ from "lodash";
+
+export class FolderPageLoader {
+  constructor(private backendSrv, private $routeParams) { }
+
+  load(navModel, folderId) {
+    this.backendSrv.getDashboard(this.$routeParams.type, this.$routeParams.slug).then(result => {
+      const folderTitle = result.dashboard.title;
+      navModel.main.text = '';
+      navModel.main.breadcrumbs = [
+        { title: 'Dashboards', uri: '/dashboards' },
+        { title: folderTitle }
+      ];
+      const folderUrl = `/dashboards/folder/${folderId}/${result.meta.type}/${result.meta.slug}`;
+      const dashTab = _.find(navModel.main.children, { id: 'manage-folder-dashboards' });
+      dashTab.url = folderUrl;
+      const permTab = _.find(navModel.main.children, { id: 'manage-folder-permissions' });
+      permTab.url = folderUrl + '/permissions';
+    });
+  }
+}

+ 16 - 0
public/app/features/dashboard/folder_permissions_ctrl.ts

@@ -0,0 +1,16 @@
+import {FolderPageLoader} from './folder_page_loader';
+
+export class FolderPermissionsCtrl {
+  navModel: any;
+  folderId: number;
+
+  /** @ngInject */
+  constructor(private backendSrv, navModelSrv, private $routeParams) {
+    if (this.$routeParams.folderId && this.$routeParams.type && this.$routeParams.slug) {
+      this.folderId = $routeParams.folderId;
+      this.navModel = navModelSrv.getNav('manage-folder', 'manage-folder-permissions', 0);
+
+      new FolderPageLoader(this.backendSrv, this.$routeParams).load(this.navModel, this.folderId);
+    }
+  }
+}

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

+ 43 - 0
public/app/features/dashboard/partials/create_folder.html

@@ -0,0 +1,43 @@
+<page-header model="ctrl.navModel"></page-header>
+
+<div class="page-container page-body" ng-cloak>
+
+  <form name="ctrl.saveForm" ng-submit="ctrl.create()" class="modal-content folder-modal" novalidate>
+    <div class="gf-form-group">
+      <div class="gf-form-inline">
+        <div class="gf-form gf-form--grow">
+          <label class="gf-form-label width-10">Folder name</label>
+          <input type="text" class="gf-form-input" ng-model="ctrl.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-model-options="{ debounce: 400 }" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
+          <label class="gf-form-label text-success" ng-if="!ctrl.nameExists && ctrl.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-10 gf-form--grow">
+          <label class="gf-form-label text-warning gf-form-label--grow">
+            <i class="fa fa-warning"></i>
+            A Folder or Dashboard with the same name already exists
+          </label>
+        </div>
+      </div>
+
+      <div class="gf-form-inline" ng-if="!ctrl.title && ctrl.titleTouched">
+        <div class="gf-form offset-width-10 gf-form--grow">
+          <label class="gf-form-label text-warning gf-form-label--grow">
+            <i class="fa fa-warning"></i>
+            A Folder should have a name
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <div class="gf-form-button-row">
+      <button type="submit" class="btn btn-success width-12" ng-disabled="ctrl.nameExists || ctrl.title.length === 0">
+        <i class="fa fa-save"></i> Create
+      </button>
+    </div>
+  </form>
+
+</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>

+ 0 - 131
public/app/features/dashboard/partials/dashboardList.html

@@ -1,131 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <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">
-      <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="gf-form" ng-if="ctrl.query.tag.length">
-    Filters:
-    <span ng-repeat="tagName in ctrl.query.tag">
-      <a ng-click="ctrl.removeTag(tagName, $event)" tag-color-from-name="tagName" class="label label-tag">
-        <i class="fa fa-remove"></i>
-        {{tagName}}
-      </a>
-    </span>
-  </div>
-
-  <div class="gf-form">
-      <div class="gf-form-button-row"
-        ng-show="ctrl.hasFilters">
-        <button
-          type="button"
-          class="btn gf-form-button btn-inverse btn-small"
-          ng-click="ctrl.clearFilters()">
-          <i class="fa fa-close"></i> Clear current search query and filters
-      </button>
-      </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>
-
-  <div class="dashboard-list">
-    <div class="search-results-filter-row">
-      <gf-form-switch
-        on-change="ctrl.onSelectAllChanged()"
-        checked="ctrl.selectAllChecked"
-      />
-      <div class="search-results-filter-row__filters">
-        <select
-          class="search-results-filter-row__filters-item gf-form-input"
-          ng-model="ctrl.selectedStarredFilter"
-          ng-options="t.text disable when t.disabled for t in ctrl.starredFilterOptions"
-          ng-change="ctrl.onStarredFilterChange()"
-        />
-        <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()"
-        />
-      </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>
-      </div>
-    </div>
-  </div>
-</div>
-
-<em class="muted" ng-hide="ctrl.sections.length > 0">
-  No Dashboards or Folders found.
-</em>

+ 5 - 0
public/app/features/dashboard/partials/dashboard_list.html

@@ -0,0 +1,5 @@
+<page-header model="ctrl.navModel"></page-header>
+
+<div class="page-container page-body">
+  <manage-dashboards />
+</div>

+ 5 - 0
public/app/features/dashboard/partials/folder_dashboards.html

@@ -0,0 +1,5 @@
+<page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
+
+<div class="page-container page-body">
+    <manage-dashboards ng-if="ctrl.folderId" folder-id="ctrl.folderId" />
+</div>

+ 5 - 0
public/app/features/dashboard/partials/folder_permissions.html

@@ -0,0 +1,5 @@
+<page-header model="ctrl.navModel"></page-header>
+
+<div class="page-container page-body">
+    <h1>Coming soon! Permissions will be added in Grafana 5.0 beta.</h1>
+</div>

+ 4 - 1
public/app/features/dashboard/settings/settings.html

@@ -1,5 +1,5 @@
 
-<div class="edit-tab-with-sidemenu">
+<div class="edit-tab-with-sidemenu" ng-if="ctrl.viewId !== 'timepicker'">
 	<aside class="edit-sidemenu-aside">
 		<h2>
 			<i class="fa fa-cog"></i>
@@ -26,6 +26,9 @@
 	<div class="edit-tab-content" ng-if="ctrl.viewId === 'templating'">
 		annotations
 	</div>
+</div>
 
+<div ng-if="ctrl.viewId === 'timepicker'">
+  <gf-time-picker-dropdown dashboard="ctrl.dashboard"></gf-time-picker-dropdown>
 </div>
 

+ 9 - 16
public/app/features/dashboard/shareModalCtrl.js → public/app/features/dashboard/shareModalCtrl.ts

@@ -1,18 +1,11 @@
-define(['angular',
-  'lodash',
-  'jquery',
-  'moment',
-  'app/core/config',
-],
-function (angular, _, $, moment, config) {
-  'use strict';
+import angular from 'angular';
+import moment from 'moment';
+import config from 'app/core/config';
 
-  config = config.default;
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
+export class ShareModalCtrl {
 
+  /** @ngInject */
+  constructor($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
     $scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
     $scope.editor = { index: $scope.tabIndex || 0};
 
@@ -93,7 +86,7 @@ function (angular, _, $, moment, config) {
     $scope.getShareUrl = function() {
       return $scope.shareUrl;
     };
+  }
+}
 
-  });
-
-});
+angular.module('grafana.controllers').controller('ShareModalCtrl', ShareModalCtrl);

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

+ 0 - 71
public/app/features/dashboard/timepicker/dropdown.html

@@ -1,72 +1 @@
-<div class="graph-annotation">
-	<div class="graph-annotation__header">
-		<div class="graph-annotation__user" bs-tooltip="'Created by {{ctrl.login}}'">
-		</div>
-
-		<div class="graph-annotation__title">
-			<span>Time Picker</span>
-		</div>
-	</div>
-
-	<div class="graph-annotation__body">
-		<form name="timeForm" class="gf-timepicker-absolute-section">
-			<h3 class="section-heading">Custom range</h3>
-
-			<label class="small">From:</label>
-			<div class="gf-form-inline">
-				<div class="gf-form max-width-28">
-					<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
-				</div>
-				<div class="gf-form">
-					<button class="btn gf-form-btn btn-primary" type="button" ng-click="openFromPicker=!openFromPicker">
-						<i class="fa fa-calendar"></i>
-					</button>
-				</div>
-			</div>
-
-			<div ng-if="openFromPicker">
-				<datepicker ng-model="ctrl.absolute.fromJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteFromChanged()"></datepicker>
-			</div>
-
-
-			<label class="small">To:</label>
-			<div class="gf-form-inline">
-				<div class="gf-form max-width-28">
-					<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
-				</div>
-				<div class="gf-form">
-					<button class="btn gf-form-btn btn-primary" type="button" ng-click="openToPicker=!openToPicker">
-						<i class="fa fa-calendar"></i>
-					</button>
-				</div>
-			</div>
-
-			<div ng-if="openToPicker">
-				<datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
-			</div>
-
-			<label class="small">Refreshing every:</label>
-			<div class="gf-form-inline">
-				<div class="gf-form max-width-28">
-					<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
-				</div>
-				<div class="gf-form">
-					<button type="submit" class="btn gf-form-btn btn-secondary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
-				</div>
-			</div>
-
-		</form>
-
-		<div class="gf-timepicker-relative-section">
-			<h3 class="section-heading">Quick ranges</h3>
-			<ul ng-repeat="group in ctrl.timeOptions">
-				<li bindonce ng-repeat='option in group' ng-class="{active: option.active}">
-					<a ng-click="ctrl.setRelativeFilter(option)" bo-text="option.display"></a>
-				</li>
-			</ul>
-		</div>
-
-		<div class="clearfix"></div>
-	</div>
-</div>
 

+ 59 - 0
public/app/features/dashboard/timepicker/timepicker.html

@@ -24,3 +24,62 @@
 		<i class="fa fa-refresh"></i>
 	</button>
 </div>
+
+<div ng-if="ctrl.isOpen" class="gf-timepicker-dropdown">
+  <form name="timeForm" class="gf-timepicker-absolute-section">
+    <h3 class="section-heading">Time range</h3>
+
+    <label class="small">From:</label>
+    <div class="gf-form-inline">
+      <div class="gf-form max-width-28">
+        <input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
+      </div>
+      <div class="gf-form">
+        <button class="btn gf-form-btn btn-primary" type="button" ng-click="openFromPicker=!openFromPicker">
+          <i class="fa fa-calendar"></i>
+        </button>
+      </div>
+    </div>
+
+    <div ng-if="openFromPicker">
+      <datepicker ng-model="ctrl.absolute.fromJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteFromChanged()"></datepicker>
+    </div>
+
+
+    <label class="small">To:</label>
+    <div class="gf-form-inline">
+      <div class="gf-form max-width-28">
+        <input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
+      </div>
+      <div class="gf-form">
+        <button class="btn gf-form-btn btn-primary" type="button" ng-click="openToPicker=!openToPicker">
+          <i class="fa fa-calendar"></i>
+        </button>
+      </div>
+    </div>
+
+    <div ng-if="openToPicker">
+      <datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
+    </div>
+
+    <label class="small">Refreshing every:</label>
+    <div class="gf-form-inline">
+      <div class="gf-form max-width-28">
+        <select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
+      </div>
+      <div class="gf-form">
+        <button type="submit" class="btn gf-form-btn btn-secondary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
+      </div>
+    </div>
+  </form>
+
+  <div class="gf-timepicker-relative-section">
+    <h3 class="section-heading">Quick ranges</h3>
+    <ul ng-repeat="group in ctrl.timeOptions">
+      <li bindonce ng-repeat='option in group' ng-class="{active: option.active}">
+        <a ng-click="ctrl.setRelativeFilter(option)" bo-text="option.display"></a>
+      </li>
+    </ul>
+  </div>
+</div>
+

+ 10 - 26
public/app/features/dashboard/timepicker/timepicker.ts

@@ -24,9 +24,10 @@ export class TimePickerCtrl {
   isUtc: boolean;
   firstDayOfWeek: number;
   closeDropdown: any;
+  isOpen: boolean;
 
   /** @ngInject */
-  constructor(private $scope, private $rootScope, private timeSrv, private popoverSrv, private $element) {
+  constructor(private $scope, private $rootScope, private timeSrv) {
     this.$scope.ctrl = this;
 
     $rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
@@ -95,6 +96,11 @@ export class TimePickerCtrl {
   }
 
   openDropdown() {
+    if (this.isOpen) {
+      this.isOpen = false;
+      return;
+    }
+
     this.onRefresh();
     this.editTimeRaw = this.timeRaw;
     this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString);
@@ -106,17 +112,7 @@ export class TimePickerCtrl {
     };
 
     this.refresh.options.unshift({text: 'off'});
-
-    this.closeDropdown = this.popoverSrv.show({
-      element: this.$element[0],
-      position: 'bottom center',
-      template: '<gf-time-picker-dropdown ctrl="ctrl" />',
-      openOn: 'click',
-      classNames: 'drop-popover drop-popover--form',
-      model: {
-        ctrl: this
-      },
-    });
+    this.isOpen = true;
   }
 
   applyCustom() {
@@ -125,7 +121,7 @@ export class TimePickerCtrl {
     }
 
     this.timeSrv.setTime(this.editTimeRaw);
-    this.closeDropdown();
+    this.isOpen = false;
   }
 
   absoluteFromChanged() {
@@ -148,7 +144,7 @@ export class TimePickerCtrl {
     }
 
     this.timeSrv.setTime(range);
-    this.closeDropdown();
+    this.isOpen = false;
   }
 
 }
@@ -179,20 +175,8 @@ export function timePickerDirective() {
   };
 }
 
-export function timePickerDropdown() {
-  return {
-    restrict: 'E',
-    templateUrl: 'public/app/features/dashboard/timepicker/dropdown.html',
-    scope: {
-      ctrl: "="
-    }
-  };
-}
-
-
 angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective);
 angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
-angular.module('grafana.directives').directive('gfTimePickerDropdown', timePickerDropdown);
 
 import {inputDateDirective} from './input_date';
 angular.module("grafana.directives").directive('inputDatetime', inputDateDirective);

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

+ 0 - 5
public/app/features/org/partials/profile.html

@@ -26,11 +26,6 @@
 
 	<prefs-control mode="user"></prefs-control>
 
-	<h3 class="page-heading">Password</h3>
-	<div class="gf-form-group">
-		<a href="profile/password" class="btn btn-inverse">Change Password</a>
-	</div>
-
 	<h3 class="page-heading" ng-show="ctrl.showOrgsList">Organizations</h3>
   <div class="gf-form-group" ng-show="ctrl.showOrgsList">
 		<table class="filter-table form-inline">

+ 0 - 9
public/app/features/org/partials/select_org.html

@@ -35,15 +35,6 @@
 					</tr>
 				</table>
 			</div>
-
-		</div>
-
-
-		<div class="row" style="margin-top: 50px">
-			<div class="version-footer text-center small">
-				Grafana version: {{buildInfo.version}}, commit: {{buildInfo.commit}},
-				build date: {{buildInfo.buildstamp | date: 'yyyy-MM-dd HH:mm:ss' }}
-			</div>
 		</div>
 
 	</div>

Некоторые файлы не были показаны из-за большого количества измененных файлов