Explorar el Código

Merge branch 'master' into panelbase

Torkel Ödegaard hace 10 años
padre
commit
931d7cd039
Se han modificado 100 ficheros con 1486 adiciones y 847 borrados
  1. 9 5
      CHANGELOG.md
  2. 9 0
      circle.yml
  3. 1 0
      docker/blocks/elastic/elasticsearch/config/.placeholder
  4. 6 0
      docker/blocks/elastic/fig
  5. 28 46
      docker/blocks/graphite/Dockerfile
  6. 7 1
      docker/blocks/graphite/fig
  7. 1 0
      docker/blocks/graphite/files/my_htpasswd
  8. 0 7
      docker/blocks/graphite/files/supervisord.conf
  9. 0 16
      docker/blocks/influxdb/Dockerfile
  10. 1 1
      docker/blocks/influxdb/fig
  11. 2 0
      docs/sources/datasources/prometheus.md
  12. 28 0
      docs/sources/reference/http_api.md
  13. 2 2
      docs/sources/reference/playlist.md
  14. 1 1
      docs/sources/reference/singlestat.md
  15. 14 0
      pkg/api/admin.go
  16. 3 1
      pkg/api/api.go
  17. 0 76
      pkg/api/api_plugin.go
  18. 116 0
      pkg/api/app_routes.go
  19. 1 0
      pkg/api/dataproxy.go
  20. 1 0
      pkg/api/dtos/apps.go
  21. 3 39
      pkg/api/playlist.go
  22. 88 0
      pkg/api/playlist_play.go
  23. 1 0
      pkg/metrics/report_usage.go
  24. 38 10
      pkg/models/app_settings.go
  25. 5 0
      pkg/models/dashboards.go
  26. 0 9
      pkg/models/playlist.go
  27. 17 0
      pkg/models/stats.go
  28. 19 3
      pkg/plugins/app_plugin.go
  29. 0 18
      pkg/plugins/models.go
  30. 0 3
      pkg/plugins/plugins.go
  31. 0 6
      pkg/plugins/queries.go
  32. 31 7
      pkg/services/sqlstore/app_settings.go
  33. 18 0
      pkg/services/sqlstore/dashboard.go
  34. 6 3
      pkg/services/sqlstore/migrations/app_settings.go
  35. 0 18
      pkg/services/sqlstore/playlist.go
  36. 44 0
      pkg/services/sqlstore/playlist_test.go
  37. 57 1
      pkg/services/sqlstore/stats.go
  38. 66 0
      pkg/util/encryption.go
  39. 27 0
      pkg/util/encryption_test.go
  40. 5 0
      pkg/util/url.go
  41. 46 0
      pkg/util/url_test.go
  42. 10 1
      public/app/core/components/grafana_app.ts
  43. 3 2
      public/app/core/components/navbar/navbar.html
  44. 18 21
      public/app/core/components/search/search.html
  45. 150 0
      public/app/core/components/search/search.ts
  46. 6 0
      public/app/core/components/sidemenu/sidemenu.html
  47. 18 7
      public/app/core/components/sidemenu/sidemenu.ts
  48. 0 1
      public/app/core/controllers/all.js
  49. 0 127
      public/app/core/controllers/search_ctrl.js
  50. 2 2
      public/app/core/core.ts
  51. 0 50
      public/app/core/directives/topnav.js
  52. 5 0
      public/app/core/routes/all.js
  53. 13 0
      public/app/core/services/context_srv.js
  54. 1 0
      public/app/core/time_series2.ts
  55. 37 0
      public/app/core/utils/file_export.ts
  56. 0 11
      public/app/core/utils/kbn.js
  57. 18 0
      public/app/features/admin/adminStatsCtrl.ts
  58. 1 0
      public/app/features/admin/all.js
  59. 3 4
      public/app/features/admin/partials/edit_org.html
  60. 2 4
      public/app/features/admin/partials/edit_user.html
  61. 4 5
      public/app/features/admin/partials/new_user.html
  62. 2 5
      public/app/features/admin/partials/orgs.html
  63. 2 2
      public/app/features/admin/partials/settings.html
  64. 57 0
      public/app/features/admin/partials/stats.html
  65. 8 9
      public/app/features/admin/partials/users.html
  66. 1 0
      public/app/features/apps/edit_ctrl.ts
  67. 2 0
      public/app/features/apps/partials/edit.html
  68. 2 5
      public/app/features/apps/partials/list.html
  69. 19 2
      public/app/features/dashboard/dashboardSrv.js
  70. 11 3
      public/app/features/dashboard/dashnav/dashnav.html
  71. 5 2
      public/app/features/dashboard/dashnav/dashnav.ts
  72. 1 1
      public/app/features/dashboard/directives/dashSearchView.js
  73. 2 2
      public/app/features/dashboard/partials/import.html
  74. 1 1
      public/app/features/dashboard/partials/settings.html
  75. 2 2
      public/app/features/dashboard/partials/shareModal.html
  76. 1 1
      public/app/features/dashboard/shareModalCtrl.js
  77. 1 0
      public/app/features/dashboard/submenu/submenu.ts
  78. 0 47
      public/app/features/dashboard/timepicker/custom.html
  79. 3 3
      public/app/features/dashboard/timepicker/dropdown.html
  80. 14 1
      public/app/features/dashboard/timepicker/input_date.ts
  81. 1 1
      public/app/features/dashlinks/module.js
  82. 2 2
      public/app/features/datasources/partials/edit.html
  83. 3 3
      public/app/features/datasources/partials/list.html
  84. 2 2
      public/app/features/org/partials/newOrg.html
  85. 2 2
      public/app/features/org/partials/orgApiKeys.html
  86. 2 2
      public/app/features/org/partials/orgDetails.html
  87. 2 2
      public/app/features/org/partials/orgUsers.html
  88. 1 1
      public/app/features/panel/panel_menu.js
  89. 1 0
      public/app/features/playlist/all.js
  90. 0 5
      public/app/features/playlist/partials/playlist-remove.html
  91. 47 41
      public/app/features/playlist/partials/playlist.html
  92. 26 0
      public/app/features/playlist/partials/playlist_search.html
  93. 2 2
      public/app/features/playlist/partials/playlists.html
  94. 0 144
      public/app/features/playlist/playlist_edit_ctrl.js
  95. 136 0
      public/app/features/playlist/playlist_edit_ctrl.ts
  96. 4 1
      public/app/features/playlist/playlist_routes.js
  97. 83 0
      public/app/features/playlist/playlist_search.ts
  98. 2 2
      public/app/features/playlist/playlist_srv.ts
  99. 0 43
      public/app/features/playlist/playlists_ctrl.js
  100. 44 0
      public/app/features/playlist/playlists_ctrl.ts

+ 9 - 5
CHANGELOG.md

@@ -1,19 +1,23 @@
 # 3.0.0 (unrelased master branch)
 
-### New Features ###
+### New Features
 * **Playlists**: Playlists can now be persisted and started from urls, closes [#3655](https://github.com/grafana/grafana/pull/3655)
 * **Metadata**: Settings panel now shows dashboard metadata, closes [#3304](https://github.com/grafana/grafana/issues/3304)
 * **InfluxDB**: Support for policy selection in query editor, closes [#2018](https://github.com/grafana/grafana/issues/2018)
 
 ### Breaking changes
-**Plugin API**: Both datasource and panel plugin api (and plugin.json schema) as been updated, requiring a minor update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) for more info.
-**InfluxDB 0.8.x** The data source for the old version of influxdb (0.8.x) is no longer included in default builds. Can easily be installed via improved plugin system, closes #3523
-**KairosDB** The data source is no longer included in default builds. Can easily be installed via improved plugin system, closes #3524
+* **Plugin API**: Both datasource and panel plugin api (and plugin.json schema) have been updated, requiring a minor update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) for more info.
+* **InfluxDB 0.8.x** The data source for the old version of influxdb (0.8.x) is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3523](https://github.com/grafana/grafana/issues/3523)
+* **KairosDB** The data source is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3524](https://github.com/grafana/grafana/issues/3524)
 
-### Enhancements ###
+### Enhancements
 * **Sessions**: Support for memcached as session storage, closes [#3458](https://github.com/grafana/grafana/pull/3458)
 * **mysql**: Grafana now supports ssl for mysql, closes [#3584](https://github.com/grafana/grafana/pull/3584)
 * **snapshot**: Annotations are now included in snapshots, closes [#3635](https://github.com/grafana/grafana/pull/3635)
+* **Admin**: Admin can now have global overview of Grafana setup, closes [#3812](https://github.com/grafana/grafana/issues/3812)
+
+### Bug fixes
+* **Playlist**: Fix for memory leak when running a playlist, closes [#3794](https://github.com/grafana/grafana/pull/3794)
 
 # 2.6.1 (unrelased, 2.6.x branch)
 

+ 9 - 0
circle.yml

@@ -12,6 +12,8 @@ dependencies:
     - mkdir -p ${GOPATH}/src/${ORG_PATH}
     - ln -s ~/grafana ${GOPATH}/src/${ORG_PATH}
     - go get github.com/tools/godep
+    - rm -rf node_modules
+    - npm install -g npm
     - npm install
 
 test:
@@ -25,3 +27,10 @@ test:
      # js tests
      - ./node_modules/grunt-cli/bin/grunt test
      - npm run coveralls
+
+deployment:
+  master:
+    branch: master
+    owner: grafana
+    commands: 
+      - ./trigger_grafana_packer.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN}

+ 1 - 0
docker/blocks/elastic/elasticsearch/config/.placeholder

@@ -0,0 +1 @@
+Ensure the existence of the parent folder.

+ 6 - 0
docker/blocks/elastic/fig

@@ -0,0 +1,6 @@
+elasticsearch:
+  image: elasticsearch:latest
+  command: elasticsearch -Des.network.host=0.0.0.0
+  ports:
+    - "9200:9200"
+    - "9300:9300"

+ 28 - 46
docker/blocks/graphite/Dockerfile

@@ -1,68 +1,50 @@
-from	ubuntu:14.10
+from  ubuntu:14.04
 
-run	apt-get -y update
+run apt-get -y update
 
-run apt-get -y install software-properties-common
-
-run	apt-get -y install python-software-properties &&\
-	add-apt-repository ppa:chris-lea/node.js &&\
-	apt-get -y update
-
-run apt-get -y install  python-django-tagging python-simplejson python-memcache \
-			    python-ldap python-cairo python-django python-twisted   \
-			    python-pysqlite2 python-support python-pip gunicorn     \
-			    supervisor nginx-light nodejs git wget curl
-
-# Install statsd
-run mkdir /src && git clone https://github.com/etsy/statsd.git /src/statsd
+run apt-get -y install libcairo2-dev libffi-dev pkg-config python-dev python-pip fontconfig apache2 libapache2-mod-wsgi git-core collectd memcached gcc g++ make supervisor nginx-light gunicorn
 
 run cd /usr/local/src && git clone https://github.com/graphite-project/graphite-web.git
 run cd /usr/local/src && git clone https://github.com/graphite-project/carbon.git
 run cd /usr/local/src && git clone https://github.com/graphite-project/whisper.git
 
 run cd /usr/local/src/whisper && git checkout master && python setup.py install
-run cd /usr/local/src/carbon && git checkout 0.9.x && python setup.py install
-run cd /usr/local/src/graphite-web && git checkout 0.9.x && python check-dependencies.py; python setup.py install
-
-# statsd
-add	./files/statsd_config.js /src/statsd/config.js
+run cd /usr/local/src/carbon && git checkout 0.9.x && pip install -r requirements.txt; python setup.py install
+run cd /usr/local/src/graphite-web && git checkout 0.9.x && pip install -r requirements.txt; python check-dependencies.py; python setup.py install
 
 # Add graphite config
-add	./files/initial_data.json /opt/graphite/webapp/graphite/initial_data.json
-add	./files/local_settings.py /opt/graphite/webapp/graphite/local_settings.py
-add	./files/carbon.conf /opt/graphite/conf/carbon.conf
-add	./files/storage-schemas.conf /opt/graphite/conf/storage-schemas.conf
-add	./files/storage-aggregation.conf /opt/graphite/conf/storage-aggregation.conf
-add     ./files/events_views.py /opt/graphite/webapp/graphite/events/views.py
-
-run	mkdir -p /opt/graphite/storage/whisper
-run	touch /opt/graphite/storage/graphite.db /opt/graphite/storage/index
-run	chown -R www-data /opt/graphite/storage
-run	chmod 0775 /opt/graphite/storage /opt/graphite/storage/whisper
-run	chmod 0664 /opt/graphite/storage/graphite.db
-run	cd /opt/graphite/webapp/graphite && python manage.py syncdb --noinput
+add ./files/initial_data.json /opt/graphite/webapp/graphite/initial_data.json
+add ./files/local_settings.py /opt/graphite/webapp/graphite/local_settings.py
+add ./files/carbon.conf /opt/graphite/conf/carbon.conf
+add ./files/storage-schemas.conf /opt/graphite/conf/storage-schemas.conf
+add ./files/storage-aggregation.conf /opt/graphite/conf/storage-aggregation.conf
+add ./files/events_views.py /opt/graphite/webapp/graphite/events/views.py
+
+run mkdir -p /opt/graphite/storage/whisper
+run touch /opt/graphite/storage/graphite.db /opt/graphite/storage/index
+run chown -R www-data /opt/graphite/storage
+run chmod 0775 /opt/graphite/storage /opt/graphite/storage/whisper
+run chmod 0664 /opt/graphite/storage/graphite.db
+run cd /opt/graphite/webapp/graphite && python manage.py syncdb --noinput
+
+add ./files/my_htpasswd /etc/nginx/.htpasswd
 
 # Add system service config
-add	./files/nginx.conf /etc/nginx/nginx.conf
-add	./files/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
-
+add ./files/nginx.conf /etc/nginx/nginx.conf
+add ./files/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
+# Nginx
+#
 # graphite
-expose	80
+expose  80
 
 # Carbon line receiver port
-expose	2003
+expose  2003
 # Carbon cache query port
-expose	7002
-
-# Statsd UDP port
-expose	8125/udp
-# Statsd Management port
-expose	8126
+expose  7002
 
-VOLUME ["/var/lib/elasticsearch"]
 VOLUME ["/opt/graphite/storage/whisper"]
 VOLUME ["/var/lib/log/supervisor"]
 
-cmd	["/usr/bin/supervisord"]
+cmd ["/usr/bin/supervisord"]
 
 # vim:ts=8:noet:

+ 7 - 1
docker/blocks/graphite/fig

@@ -1,4 +1,10 @@
 graphite:
   build: blocks/graphite
   ports:
-    - "8776:80"
+    - "8080:80"
+    - "2003:2003"
+  volumes:
+    - /var/docker/gfdev/graphite:/opt/graphite/storage/whisper
+    - /etc/localtime:/etc/localtime:ro
+    - /etc/timezone:/etc/timezone:ro
+

+ 1 - 0
docker/blocks/graphite/files/my_htpasswd

@@ -0,0 +1 @@
+grafana:$apr1$4R/20xhC$8t37jPP5dbcLr48btdkU//

+ 0 - 7
docker/blocks/graphite/files/supervisord.conf

@@ -24,10 +24,3 @@ stdout_logfile = /var/log/supervisor/%(program_name)s.log
 stderr_logfile = /var/log/supervisor/%(program_name)s.log
 autorestart = true
 
-[program:statsd]
-;user = www-data
-command = /usr/bin/node /src/statsd/stats.js /src/statsd/config.js
-stdout_logfile = /var/log/supervisor/%(program_name)s.log
-stderr_logfile = /var/log/supervisor/%(program_name)s.log
-autorestart = true
-

+ 0 - 16
docker/blocks/influxdb/Dockerfile

@@ -1,16 +0,0 @@
-# influxdb
-
-FROM ubuntu
-
-RUN mkdir -p /opt/influxdb/shared/data
-
-ADD http://s3.amazonaws.com/influxdb/influxdb_0.8.8_amd64.deb /influx88.deb
-RUN dpkg -i /influx88.deb
-RUN rm -rf /opt/influxdb/shared/data
-
-ADD config.toml /opt/influxdb/shared/config.toml
-
-EXPOSE 8083 8086 2004
-
-ENTRYPOINT ["/usr/bin/influxdb"]
-CMD ["-config=/opt/influxdb/shared/config.toml"]

+ 1 - 1
docker/blocks/influxdb/fig

@@ -1,5 +1,5 @@
 influxdb:
-  build: blocks/influxdb
+  image: tutum/influxdb:latest
   ports:
     - "2004:2004"
     - "8083:8083"

+ 2 - 0
docs/sources/datasources/prometheus.md

@@ -51,6 +51,8 @@ Name | Description
 
 For details of `metric names` & `label names`, and `label values`, please refer to the [Prometheus documentation](http://prometheus.io/docs/concepts/data_model/#metric-names-and-labels).
 
+> Note: The part of queries is incompatible with the version before 2.6, if you specify like `foo.*`, please change like `metrics(foo.*)`.
+
 You can create a template variable in Grafana and have that variable filled with values from any Prometheus metric exploration query.
 You can then use this variable in your Prometheus metric queries.
 

+ 28 - 0
docs/sources/reference/http_api.md

@@ -1422,6 +1422,34 @@ Keys:
       }
     }
 
+### Grafana Stats
+
+`GET /api/admin/stats`
+
+**Example Request**:
+
+    GET /api/admin/stats
+    Accept: application/json
+    Content-Type: application/json
+    Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+**Example Response**:
+
+    HTTP/1.1 200
+    Content-Type: application/json
+
+    {
+      "user_count":2,
+      "org_count":1,
+      "dashboard_count":4,
+      "db_snapshot_count":2,
+      "db_tag_count":6,
+      "data_source_count":1,
+      "playlist_count":1,
+      "starred_db_count":2,
+      "grafana_admin_count":2
+    }
+
 ### Global Users
 
 `POST /api/admin/users`

+ 2 - 2
docs/sources/reference/playlist.md

@@ -18,11 +18,11 @@ The Playlist feature can be accessed from Grafana's sidemenu. Click the 'Playlis
 
 Click on "New Playlist" button to create a new playlist. Firstly, name your playlist and configure a time interval for Grafana to wait on a particular Dashboard before advancing to the next one on the Playlist.
 
-You can search Dashboards by name (or use a regular expression), and add them to your Playlist. By default, your starred dashboards will appear as candidates for the Playlist.
+You can search Dashboards by name (or use a regular expression), and add them to your Playlist. Or you could add tags which will include all the dashboards that belongs to a tag when the playlist start playing. By default, your starred dashboards will appear as candidates for the Playlist.
 
 Be sure to click the "Add to dashboard" button next to the Dashboard name to add it to the Playlist. To remove a dashboard from the playlist click on "Remove[x]" button from the playlist.
 
-Since the Playlist is basically a list of Dashboards, ensure that all the Dashboards you want to appear in your Playlist are added here. 
+Since the Playlist is basically a list of Dashboards, ensure that all the Dashboards you want to appear in your Playlist are added here.
 
 ## Saving the playlist
 

+ 1 - 1
docs/sources/reference/singlestat.md

@@ -31,7 +31,7 @@ The coloring options of the Singlestat Panel config allow you to dynamically cha
 
 1. `Background`: This checkbox applies the configured thresholds and colors to the entirety of the Singlestat Panel background.
 2. `Value`: This checkbox applies the configured thresholds and colors to the summary stat.
-3. `Thresholds`: Change the background and value colors dynamically within the panel, depending on the Singlestat value. The threshold field accepts **3 comma-separated** values, corresponding to the three colors directly to the right.
+3. `Thresholds`: Change the background and value colors dynamically within the panel, depending on the Singlestat value. The threshold field accepts **2 comma-separated** values which represent 3 ranges that correspond to the three colors directly to the right. For example: if the thresholds are 70, 90 then the first color represents < 70, the second color represents between 70 and 90 and the third color represents > 90.
 4. `Colors`: Select a color and opacity
 5. `Invert order`: This link toggles the threshold color order.</br>For example: Green, Orange, Red (<img class="no-shadow" src="/img/v1/gyr.png">) will become Red, Orange, Green (<img class="no-shadow" src="/img/v1/ryg.png">).
 

+ 14 - 0
pkg/api/admin_settings.go → pkg/api/admin.go

@@ -3,7 +3,9 @@ package api
 import (
 	"strings"
 
+	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 )
 
@@ -27,3 +29,15 @@ func AdminGetSettings(c *middleware.Context) {
 
 	c.JSON(200, settings)
 }
+
+func AdminGetStats(c *middleware.Context) {
+
+	statsQuery := m.GetAdminStatsQuery{}
+
+	if err := bus.Dispatch(&statsQuery); err != nil {
+		c.JsonApiErr(500, "Failed to get admin stats from database", err)
+		return
+	}
+
+	c.JSON(200, statsQuery.Result)
+}

+ 3 - 1
pkg/api/api.go

@@ -40,6 +40,7 @@ func Register(r *macaron.Macaron) {
 	r.Get("/admin/users/edit/:id", reqGrafanaAdmin, Index)
 	r.Get("/admin/orgs", reqGrafanaAdmin, Index)
 	r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index)
+	r.Get("/admin/stats", reqGrafanaAdmin, Index)
 
 	r.Get("/apps", reqSignedIn, Index)
 	r.Get("/apps/edit/*", reqSignedIn, Index)
@@ -210,12 +211,13 @@ func Register(r *macaron.Macaron) {
 		r.Delete("/users/:id", AdminDeleteUser)
 		r.Get("/users/:id/quotas", wrap(GetUserQuotas))
 		r.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota))
+		r.Get("/stats", AdminGetStats)
 	}, reqGrafanaAdmin)
 
 	// rendering
 	r.Get("/render/*", reqSignedIn, RenderToPng)
 
-	InitApiPluginRoutes(r)
+	InitAppPluginRoutes(r)
 
 	r.NotFound(NotFoundHandler)
 }

+ 0 - 76
pkg/api/api_plugin.go

@@ -1,76 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httputil"
-	"net/url"
-
-	"gopkg.in/macaron.v1"
-
-	"github.com/grafana/grafana/pkg/log"
-	"github.com/grafana/grafana/pkg/middleware"
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/plugins"
-	"github.com/grafana/grafana/pkg/util"
-)
-
-func InitApiPluginRoutes(r *macaron.Macaron) {
-	for _, plugin := range plugins.ApiPlugins {
-		log.Info("Plugin: Adding proxy routes for api plugin")
-		for _, route := range plugin.Routes {
-			url := util.JoinUrlFragments("/api/plugin-proxy/", route.Path)
-			handlers := make([]macaron.Handler, 0)
-			if route.ReqSignedIn {
-				handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}))
-			}
-			if route.ReqGrafanaAdmin {
-				handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}))
-			}
-			if route.ReqSignedIn && route.ReqRole != "" {
-				if route.ReqRole == m.ROLE_ADMIN {
-					handlers = append(handlers, middleware.RoleAuth(m.ROLE_ADMIN))
-				} else if route.ReqRole == m.ROLE_EDITOR {
-					handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN))
-				}
-			}
-			handlers = append(handlers, ApiPlugin(route.Url))
-			r.Route(url, route.Method, handlers...)
-			log.Info("Plugin: Adding route %s", url)
-		}
-	}
-}
-
-func ApiPlugin(routeUrl string) macaron.Handler {
-	return func(c *middleware.Context) {
-		path := c.Params("*")
-
-		//Create a HTTP header with the context in it.
-		ctx, err := json.Marshal(c.SignedInUser)
-		if err != nil {
-			c.JsonApiErr(500, "failed to marshal context to json.", err)
-			return
-		}
-		targetUrl, _ := url.Parse(routeUrl)
-		proxy := NewApiPluginProxy(string(ctx), path, targetUrl)
-		proxy.Transport = dataProxyTransport
-		proxy.ServeHTTP(c.Resp, c.Req.Request)
-	}
-}
-
-func NewApiPluginProxy(ctx string, proxyPath string, targetUrl *url.URL) *httputil.ReverseProxy {
-	director := func(req *http.Request) {
-		req.URL.Scheme = targetUrl.Scheme
-		req.URL.Host = targetUrl.Host
-		req.Host = targetUrl.Host
-
-		req.URL.Path = util.JoinUrlFragments(targetUrl.Path, proxyPath)
-
-		// clear cookie headers
-		req.Header.Del("Cookie")
-		req.Header.Del("Set-Cookie")
-		req.Header.Add("Grafana-Context", ctx)
-	}
-
-	return &httputil.ReverseProxy{Director: director}
-}

+ 116 - 0
pkg/api/app_routes.go

@@ -0,0 +1,116 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"text/template"
+
+	"gopkg.in/macaron.v1"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+func InitAppPluginRoutes(r *macaron.Macaron) {
+	for _, plugin := range plugins.Apps {
+		for _, route := range plugin.Routes {
+			log.Info("Plugin: Adding proxy route for app plugin")
+			url := util.JoinUrlFragments("/api/plugin-proxy/", route.Path)
+			handlers := make([]macaron.Handler, 0)
+			if route.ReqSignedIn {
+				handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}))
+			}
+			if route.ReqGrafanaAdmin {
+				handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}))
+			}
+			if route.ReqSignedIn && route.ReqRole != "" {
+				if route.ReqRole == m.ROLE_ADMIN {
+					handlers = append(handlers, middleware.RoleAuth(m.ROLE_ADMIN))
+				} else if route.ReqRole == m.ROLE_EDITOR {
+					handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN))
+				}
+			}
+			handlers = append(handlers, AppPluginRoute(route, plugin.Id))
+			r.Route(url, route.Method, handlers...)
+			log.Info("Plugin: Adding route %s", url)
+		}
+	}
+}
+
+func AppPluginRoute(route *plugins.AppPluginRoute, appId string) macaron.Handler {
+	return func(c *middleware.Context) {
+		path := c.Params("*")
+
+		proxy := NewApiPluginProxy(c, path, route, appId)
+		proxy.Transport = dataProxyTransport
+		proxy.ServeHTTP(c.Resp, c.Req.Request)
+	}
+}
+
+func NewApiPluginProxy(ctx *middleware.Context, proxyPath string, route *plugins.AppPluginRoute, appId string) *httputil.ReverseProxy {
+	targetUrl, _ := url.Parse(route.Url)
+
+	director := func(req *http.Request) {
+
+		req.URL.Scheme = targetUrl.Scheme
+		req.URL.Host = targetUrl.Host
+		req.Host = targetUrl.Host
+
+		req.URL.Path = util.JoinUrlFragments(targetUrl.Path, proxyPath)
+
+		// clear cookie headers
+		req.Header.Del("Cookie")
+		req.Header.Del("Set-Cookie")
+
+		//Create a HTTP header with the context in it.
+		ctxJson, err := json.Marshal(ctx.SignedInUser)
+		if err != nil {
+			ctx.JsonApiErr(500, "failed to marshal context to json.", err)
+			return
+		}
+
+		req.Header.Add("Grafana-Context", string(ctxJson))
+		// add custom headers defined in the plugin config.
+		for _, header := range route.Headers {
+			var contentBuf bytes.Buffer
+			t, err := template.New("content").Parse(header.Content)
+			if err != nil {
+				ctx.JsonApiErr(500, fmt.Sprintf("could not parse header content template for header %s.", header.Name), err)
+				return
+			}
+
+			//lookup appSettings
+			query := m.GetAppSettingByAppIdQuery{OrgId: ctx.OrgId, AppId: appId}
+
+			if err := bus.Dispatch(&query); err != nil {
+				ctx.JsonApiErr(500, "failed to get AppSettings.", err)
+				return
+			}
+			type templateData struct {
+				JsonData       map[string]interface{}
+				SecureJsonData map[string]string
+			}
+			data := templateData{
+				JsonData:       query.Result.JsonData,
+				SecureJsonData: query.Result.SecureJsonData.Decrypt(),
+			}
+			err = t.Execute(&contentBuf, data)
+			if err != nil {
+				ctx.JsonApiErr(500, fmt.Sprintf("failed to execute header content template for header %s.", header.Name), err)
+				return
+			}
+			log.Debug("Adding header to proxy request. %s: %s", header.Name, contentBuf.String())
+			req.Header.Add(header.Name, contentBuf.String())
+		}
+	}
+
+	return &httputil.ReverseProxy{Director: director}
+}

+ 1 - 0
pkg/api/dataproxy.go

@@ -103,5 +103,6 @@ func ProxyDataSourceRequest(c *middleware.Context) {
 		proxy := NewReverseProxy(ds, proxyPath, targetUrl)
 		proxy.Transport = dataProxyTransport
 		proxy.ServeHTTP(c.Resp, c.Req.Request)
+		c.Resp.Header().Del("Set-Cookie")
 	}
 }

+ 1 - 0
pkg/api/dtos/apps.go

@@ -31,6 +31,7 @@ func NewAppSettingsDto(def *plugins.AppPlugin, data *models.AppSettings) *AppSet
 		dto.Enabled = data.Enabled
 		dto.Pinned = data.Pinned
 		dto.Info = &def.Info
+		dto.JsonData = data.JsonData
 	}
 
 	return dto

+ 3 - 39
pkg/api/playlist.go

@@ -1,11 +1,8 @@
 package api
 
 import (
-	"errors"
-	"strconv"
-
 	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/log"
+	_ "github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 )
@@ -101,39 +98,6 @@ func LoadPlaylistItems(id int64) ([]m.PlaylistItem, error) {
 	return *itemQuery.Result, nil
 }
 
-func LoadPlaylistDashboards(id int64) ([]m.PlaylistDashboardDto, error) {
-	playlistItems, _ := LoadPlaylistItems(id)
-
-	dashboardIds := make([]int64, 0)
-
-	for _, i := range playlistItems {
-		dashboardId, _ := strconv.ParseInt(i.Value, 10, 64)
-		dashboardIds = append(dashboardIds, dashboardId)
-	}
-
-	if len(dashboardIds) == 0 {
-		return make([]m.PlaylistDashboardDto, 0), nil
-	}
-
-	dashboardQuery := m.GetPlaylistDashboardsQuery{DashboardIds: dashboardIds}
-	if err := bus.Dispatch(&dashboardQuery); err != nil {
-		log.Warn("dashboardquery failed: %v", err)
-		return nil, errors.New("Playlist not found")
-	}
-
-	dtos := make([]m.PlaylistDashboardDto, 0)
-	for _, item := range *dashboardQuery.Result {
-		dtos = append(dtos, m.PlaylistDashboardDto{
-			Id:    item.Id,
-			Slug:  item.Slug,
-			Title: item.Title,
-			Uri:   "db/" + item.Slug,
-		})
-	}
-
-	return dtos, nil
-}
-
 func GetPlaylistItems(c *middleware.Context) Response {
 	id := c.ParamsInt64(":id")
 
@@ -147,9 +111,9 @@ func GetPlaylistItems(c *middleware.Context) Response {
 }
 
 func GetPlaylistDashboards(c *middleware.Context) Response {
-	id := c.ParamsInt64(":id")
+	playlistId := c.ParamsInt64(":id")
 
-	playlists, err := LoadPlaylistDashboards(id)
+	playlists, err := LoadPlaylistDashboards(c.OrgId, c.UserId, playlistId)
 	if err != nil {
 		return ApiError(500, "Could not load dashboards", err)
 	}

+ 88 - 0
pkg/api/playlist_play.go

@@ -0,0 +1,88 @@
+package api
+
+import (
+	"errors"
+	"strconv"
+
+	"github.com/grafana/grafana/pkg/bus"
+	_ "github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/search"
+)
+
+func populateDashboardsById(dashboardByIds []int64) ([]m.PlaylistDashboardDto, error) {
+	result := make([]m.PlaylistDashboardDto, 0)
+
+	if len(dashboardByIds) > 0 {
+		dashboardQuery := m.GetDashboardsQuery{DashboardIds: dashboardByIds}
+		if err := bus.Dispatch(&dashboardQuery); err != nil {
+			return result, errors.New("Playlist not found") //TODO: dont swallow error
+		}
+
+		for _, item := range *dashboardQuery.Result {
+			result = append(result, m.PlaylistDashboardDto{
+				Id:    item.Id,
+				Slug:  item.Slug,
+				Title: item.Title,
+				Uri:   "db/" + item.Slug,
+			})
+		}
+	}
+
+	return result, nil
+}
+
+func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string) []m.PlaylistDashboardDto {
+	result := make([]m.PlaylistDashboardDto, 0)
+
+	if len(dashboardByTag) > 0 {
+		for _, tag := range dashboardByTag {
+			searchQuery := search.Query{
+				Title:     "",
+				Tags:      []string{tag},
+				UserId:    userId,
+				Limit:     100,
+				IsStarred: false,
+				OrgId:     orgId,
+			}
+
+			if err := bus.Dispatch(&searchQuery); err == nil {
+				for _, item := range searchQuery.Result {
+					result = append(result, m.PlaylistDashboardDto{
+						Id:    item.Id,
+						Title: item.Title,
+						Uri:   item.Uri,
+					})
+				}
+			}
+		}
+	}
+
+	return result
+}
+
+func LoadPlaylistDashboards(orgId, userId, playlistId int64) ([]m.PlaylistDashboardDto, error) {
+	playlistItems, _ := LoadPlaylistItems(playlistId)
+
+	dashboardByIds := make([]int64, 0)
+	dashboardByTag := make([]string, 0)
+
+	for _, i := range playlistItems {
+		if i.Type == "dashboard_by_id" {
+			dashboardId, _ := strconv.ParseInt(i.Value, 10, 64)
+			dashboardByIds = append(dashboardByIds, dashboardId)
+		}
+
+		if i.Type == "dashboard_by_tag" {
+			dashboardByTag = append(dashboardByTag, i.Value)
+		}
+	}
+
+	result := make([]m.PlaylistDashboardDto, 0)
+
+	var k, _ = populateDashboardsById(dashboardByIds)
+	result = append(result, k...)
+	result = append(result, populateDashboardsByTag(orgId, userId, dashboardByTag)...)
+
+	return result, nil
+}

+ 1 - 0
pkg/metrics/report_usage.go

@@ -55,6 +55,7 @@ func sendUsageStats() {
 	metrics["stats.dashboards.count"] = statsQuery.Result.DashboardCount
 	metrics["stats.users.count"] = statsQuery.Result.UserCount
 	metrics["stats.orgs.count"] = statsQuery.Result.OrgCount
+	metrics["stats.playlist.count"] = statsQuery.Result.PlaylistCount
 
 	dsStats := m.GetDataSourceStatsQuery{}
 	if err := bus.Dispatch(&dsStats); err != nil {

+ 38 - 10
pkg/models/app_settings.go

@@ -1,27 +1,49 @@
 package models
 
-import "time"
+import (
+	"errors"
+	"time"
+
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+var (
+	ErrAppSettingNotFound = errors.New("AppSetting not found")
+)
 
 type AppSettings struct {
-	Id       int64
-	AppId    string
-	OrgId    int64
-	Enabled  bool
-	Pinned   bool
-	JsonData map[string]interface{}
+	Id             int64
+	AppId          string
+	OrgId          int64
+	Enabled        bool
+	Pinned         bool
+	JsonData       map[string]interface{}
+	SecureJsonData SecureJsonData
 
 	Created time.Time
 	Updated time.Time
 }
 
+type SecureJsonData map[string][]byte
+
+func (s SecureJsonData) Decrypt() map[string]string {
+	decrypted := make(map[string]string)
+	for key, data := range s {
+		decrypted[key] = string(util.Decrypt(data, setting.SecretKey))
+	}
+	return decrypted
+}
+
 // ----------------------
 // COMMANDS
 
 // Also acts as api DTO
 type UpdateAppSettingsCmd struct {
-	Enabled  bool                   `json:"enabled"`
-	Pinned   bool                   `json:"pinned"`
-	JsonData map[string]interface{} `json:"jsonData"`
+	Enabled        bool                   `json:"enabled"`
+	Pinned         bool                   `json:"pinned"`
+	JsonData       map[string]interface{} `json:"jsonData"`
+	SecureJsonData map[string]string      `json:"secureJsonData"`
 
 	AppId string `json:"-"`
 	OrgId int64  `json:"-"`
@@ -33,3 +55,9 @@ type GetAppSettingsQuery struct {
 	OrgId  int64
 	Result []*AppSettings
 }
+
+type GetAppSettingByAppIdQuery struct {
+	AppId  string
+	OrgId  int64
+	Result *AppSettings
+}

+ 5 - 0
pkg/models/dashboards.go

@@ -146,3 +146,8 @@ type GetDashboardTagsQuery struct {
 	OrgId  int64
 	Result []*DashboardTagCloudItem
 }
+
+type GetDashboardsQuery struct {
+	DashboardIds []int64
+	Result       *[]Dashboard
+}

+ 0 - 9
pkg/models/playlist.go

@@ -76,9 +76,7 @@ type UpdatePlaylistCommand struct {
 	OrgId    int64             `json:"-"`
 	Id       int64             `json:"id" binding:"Required"`
 	Name     string            `json:"name" binding:"Required"`
-	Type     string            `json:"type"`
 	Interval string            `json:"interval"`
-	Data     []int64           `json:"data"`
 	Items    []PlaylistItemDTO `json:"items"`
 
 	Result *PlaylistDTO
@@ -86,9 +84,7 @@ type UpdatePlaylistCommand struct {
 
 type CreatePlaylistCommand struct {
 	Name     string            `json:"name" binding:"Required"`
-	Type     string            `json:"type"`
 	Interval string            `json:"interval"`
-	Data     []int64           `json:"data"`
 	Items    []PlaylistItemDTO `json:"items"`
 
 	OrgId  int64 `json:"-"`
@@ -121,8 +117,3 @@ type GetPlaylistItemsByIdQuery struct {
 	PlaylistId int64
 	Result     *[]PlaylistItem
 }
-
-type GetPlaylistDashboardsQuery struct {
-	DashboardIds []int64
-	Result       *PlaylistDashboards
-}

+ 17 - 0
pkg/models/stats.go

@@ -4,6 +4,7 @@ type SystemStats struct {
 	DashboardCount int
 	UserCount      int
 	OrgCount       int
+	PlaylistCount  int
 }
 
 type DataSourceStats struct {
@@ -18,3 +19,19 @@ type GetSystemStatsQuery struct {
 type GetDataSourceStatsQuery struct {
 	Result []*DataSourceStats
 }
+
+type AdminStats struct {
+	UserCount         int `json:"user_count"`
+	OrgCount          int `json:"org_count"`
+	DashboardCount    int `json:"dashboard_count"`
+	DbSnapshotCount   int `json:"db_snapshot_count"`
+	DbTagCount        int `json:"db_tag_count"`
+	DataSourceCount   int `json:"data_source_count"`
+	PlaylistCount     int `json:"playlist_count"`
+	StarredDbCount    int `json:"starred_db_count"`
+	GrafanaAdminCount int `json:"grafana_admin_count"`
+}
+
+type GetAdminStatsQuery struct {
+	Result *AdminStats
+}

+ 19 - 3
pkg/plugins/app_plugin.go

@@ -26,14 +26,30 @@ type AppIncludeInfo struct {
 
 type AppPlugin struct {
 	FrontendPluginBase
-	Css      *AppPluginCss    `json:"css"`
-	Pages    []AppPluginPage  `json:"pages"`
-	Includes []AppIncludeInfo `json:"-"`
+	Css      *AppPluginCss     `json:"css"`
+	Pages    []AppPluginPage   `json:"pages"`
+	Routes   []*AppPluginRoute `json:"routes"`
+	Includes []AppIncludeInfo  `json:"-"`
 
 	Pinned  bool `json:"-"`
 	Enabled bool `json:"-"`
 }
 
+type AppPluginRoute struct {
+	Path            string                 `json:"path"`
+	Method          string                 `json:"method"`
+	ReqSignedIn     bool                   `json:"reqSignedIn"`
+	ReqGrafanaAdmin bool                   `json:"reqGrafanaAdmin"`
+	ReqRole         models.RoleType        `json:"reqRole"`
+	Url             string                 `json:"url"`
+	Headers         []AppPluginRouteHeader `json:"headers"`
+}
+
+type AppPluginRouteHeader struct {
+	Name    string `json:"name"`
+	Content string `json:"content"`
+}
+
 func (app *AppPlugin) Load(decoder *json.Decoder, pluginDir string) error {
 	if err := decoder.Decode(&app); err != nil {
 		return err

+ 0 - 18
pkg/plugins/models.go

@@ -2,8 +2,6 @@ package plugins
 
 import (
 	"encoding/json"
-
-	"github.com/grafana/grafana/pkg/models"
 )
 
 type PluginLoader interface {
@@ -44,24 +42,9 @@ type PluginStaticRoute struct {
 	PluginId  string
 }
 
-type ApiPluginRoute struct {
-	Path            string          `json:"path"`
-	Method          string          `json:"method"`
-	ReqSignedIn     bool            `json:"reqSignedIn"`
-	ReqGrafanaAdmin bool            `json:"reqGrafanaAdmin"`
-	ReqRole         models.RoleType `json:"reqRole"`
-	Url             string          `json:"url"`
-}
-
-type ApiPlugin struct {
-	PluginBase
-	Routes []*ApiPluginRoute `json:"routes"`
-}
-
 type EnabledPlugins struct {
 	Panels      []*PanelPlugin
 	DataSources map[string]*DataSourcePlugin
-	ApiList     []*ApiPlugin
 	Apps        []*AppPlugin
 }
 
@@ -69,7 +52,6 @@ func NewEnabledPlugins() EnabledPlugins {
 	return EnabledPlugins{
 		Panels:      make([]*PanelPlugin, 0),
 		DataSources: make(map[string]*DataSourcePlugin),
-		ApiList:     make([]*ApiPlugin, 0),
 		Apps:        make([]*AppPlugin, 0),
 	}
 }

+ 0 - 3
pkg/plugins/plugins.go

@@ -17,7 +17,6 @@ import (
 var (
 	DataSources  map[string]*DataSourcePlugin
 	Panels       map[string]*PanelPlugin
-	ApiPlugins   map[string]*ApiPlugin
 	StaticRoutes []*PluginStaticRoute
 	Apps         map[string]*AppPlugin
 	PluginTypes  map[string]interface{}
@@ -30,14 +29,12 @@ type PluginScanner struct {
 
 func Init() error {
 	DataSources = make(map[string]*DataSourcePlugin)
-	ApiPlugins = make(map[string]*ApiPlugin)
 	StaticRoutes = make([]*PluginStaticRoute, 0)
 	Panels = make(map[string]*PanelPlugin)
 	Apps = make(map[string]*AppPlugin)
 	PluginTypes = map[string]interface{}{
 		"panel":      PanelPlugin{},
 		"datasource": DataSourcePlugin{},
-		"api":        ApiPlugin{},
 		"app":        AppPlugin{},
 	}
 

+ 0 - 6
pkg/plugins/queries.go

@@ -68,11 +68,5 @@ func GetEnabledPlugins(orgId int64) (*EnabledPlugins, error) {
 		}
 	}
 
-	for _, api := range ApiPlugins {
-		if isPluginEnabled(api.IncludedInAppId) {
-			enabledPlugins.ApiList = append(enabledPlugins.ApiList, api)
-		}
-	}
-
 	return &enabledPlugins, nil
 }

+ 31 - 7
pkg/services/sqlstore/app_settings.go

@@ -5,10 +5,13 @@ import (
 
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
 )
 
 func init() {
 	bus.AddHandler("sql", GetAppSettings)
+	bus.AddHandler("sql", GetAppSettingByAppId)
 	bus.AddHandler("sql", UpdateAppSettings)
 }
 
@@ -19,6 +22,18 @@ func GetAppSettings(query *m.GetAppSettingsQuery) error {
 	return sess.Find(&query.Result)
 }
 
+func GetAppSettingByAppId(query *m.GetAppSettingByAppIdQuery) error {
+	appSetting := m.AppSettings{OrgId: query.OrgId, AppId: query.AppId}
+	has, err := x.Get(&appSetting)
+	if err != nil {
+		return err
+	} else if has == false {
+		return m.ErrAppSettingNotFound
+	}
+	query.Result = &appSetting
+	return nil
+}
+
 func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error {
 	return inTransaction2(func(sess *session) error {
 		var app m.AppSettings
@@ -27,18 +42,27 @@ func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error {
 		sess.UseBool("enabled")
 		sess.UseBool("pinned")
 		if !exists {
+			// encrypt secureJsonData
+			secureJsonData := make(map[string][]byte)
+			for key, data := range cmd.SecureJsonData {
+				secureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey)
+			}
 			app = m.AppSettings{
-				AppId:    cmd.AppId,
-				OrgId:    cmd.OrgId,
-				Enabled:  cmd.Enabled,
-				Pinned:   cmd.Pinned,
-				JsonData: cmd.JsonData,
-				Created:  time.Now(),
-				Updated:  time.Now(),
+				AppId:          cmd.AppId,
+				OrgId:          cmd.OrgId,
+				Enabled:        cmd.Enabled,
+				Pinned:         cmd.Pinned,
+				JsonData:       cmd.JsonData,
+				SecureJsonData: secureJsonData,
+				Created:        time.Now(),
+				Updated:        time.Now(),
 			}
 			_, err = sess.Insert(&app)
 			return err
 		} else {
+			for key, data := range cmd.SecureJsonData {
+				app.SecureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey)
+			}
 			app.Updated = time.Now()
 			app.Enabled = cmd.Enabled
 			app.JsonData = cmd.JsonData

+ 18 - 0
pkg/services/sqlstore/dashboard.go

@@ -14,6 +14,7 @@ import (
 func init() {
 	bus.AddHandler("sql", SaveDashboard)
 	bus.AddHandler("sql", GetDashboard)
+	bus.AddHandler("sql", GetDashboards)
 	bus.AddHandler("sql", DeleteDashboard)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", GetDashboardTags)
@@ -223,3 +224,20 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 		return nil
 	})
 }
+
+func GetDashboards(query *m.GetDashboardsQuery) error {
+	if len(query.DashboardIds) == 0 {
+		return m.ErrCommandValidationFailed
+	}
+
+	var dashboards = make([]m.Dashboard, 0)
+
+	err := x.In("id", query.DashboardIds).Find(&dashboards)
+	query.Result = &dashboards
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 6 - 3
pkg/services/sqlstore/migrations/app_settings.go

@@ -4,7 +4,7 @@ import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 
 func addAppSettingsMigration(mg *Migrator) {
 
-	appSettingsV1 := Table{
+	appSettingsV2 := Table{
 		Name: "app_settings",
 		Columns: []*Column{
 			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
@@ -13,6 +13,7 @@ func addAppSettingsMigration(mg *Migrator) {
 			{Name: "enabled", Type: DB_Bool, Nullable: false},
 			{Name: "pinned", Type: DB_Bool, Nullable: false},
 			{Name: "json_data", Type: DB_Text, Nullable: true},
+			{Name: "secure_json_data", Type: DB_Text, Nullable: true},
 			{Name: "created", Type: DB_DateTime, Nullable: false},
 			{Name: "updated", Type: DB_DateTime, Nullable: false},
 		},
@@ -21,8 +22,10 @@ func addAppSettingsMigration(mg *Migrator) {
 		},
 	}
 
-	mg.AddMigration("create app_settings table v1", NewAddTableMigration(appSettingsV1))
+	mg.AddMigration("Drop old table app_settings v1", NewDropTableMigration("app_settings"))
+
+	mg.AddMigration("create app_settings table v2", NewAddTableMigration(appSettingsV2))
 
 	//-------  indexes ------------------
-	addTableIndicesMigrations(mg, "v3", appSettingsV1)
+	addTableIndicesMigrations(mg, "v3", appSettingsV2)
 }

+ 0 - 18
pkg/services/sqlstore/playlist.go

@@ -15,7 +15,6 @@ func init() {
 	bus.AddHandler("sql", DeletePlaylist)
 	bus.AddHandler("sql", SearchPlaylists)
 	bus.AddHandler("sql", GetPlaylist)
-	bus.AddHandler("sql", GetPlaylistDashboards)
 	bus.AddHandler("sql", GetPlaylistItem)
 }
 
@@ -162,20 +161,3 @@ func GetPlaylistItem(query *m.GetPlaylistItemsByIdQuery) error {
 
 	return err
 }
-
-func GetPlaylistDashboards(query *m.GetPlaylistDashboardsQuery) error {
-	if len(query.DashboardIds) == 0 {
-		return m.ErrCommandValidationFailed
-	}
-
-	var dashboards = make(m.PlaylistDashboards, 0)
-
-	err := x.In("id", query.DashboardIds).Find(&dashboards)
-	query.Result = &dashboards
-
-	if err != nil {
-		return err
-	}
-
-	return nil
-}

+ 44 - 0
pkg/services/sqlstore/playlist_test.go

@@ -0,0 +1,44 @@
+package sqlstore
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func TestPlaylistDataAccess(t *testing.T) {
+
+	Convey("Testing Playlist data access", t, func() {
+		InitTestDB(t)
+
+		Convey("Can create playlist", func() {
+			items := []m.PlaylistItemDTO{
+				{Title: "graphite", Value: "graphite", Type: "dashboard_by_tag"},
+				{Title: "Backend response times", Value: "3", Type: "dashboard_by_id"},
+			}
+			cmd := m.CreatePlaylistCommand{Name: "NYC office", Interval: "10m", OrgId: 1, Items: items}
+			err := CreatePlaylist(&cmd)
+			So(err, ShouldBeNil)
+
+			Convey("can update playlist", func() {
+				items := []m.PlaylistItemDTO{
+					{Title: "influxdb", Value: "influxdb", Type: "dashboard_by_tag"},
+					{Title: "Backend response times", Value: "2", Type: "dashboard_by_id"},
+				}
+				query := m.UpdatePlaylistCommand{Name: "NYC office ", OrgId: 1, Id: 1, Interval: "10s", Items: items}
+				err = UpdatePlaylist(&query)
+
+				So(err, ShouldBeNil)
+
+				Convey("can remove playlist", func() {
+					query := m.DeletePlaylistCommand{Id: 1}
+					err = DeletePlaylist(&query)
+
+					So(err, ShouldBeNil)
+				})
+			})
+		})
+	})
+}

+ 57 - 1
pkg/services/sqlstore/stats.go

@@ -8,6 +8,7 @@ import (
 func init() {
 	bus.AddHandler("sql", GetSystemStats)
 	bus.AddHandler("sql", GetDataSourceStats)
+	bus.AddHandler("sql", GetAdminStats)
 }
 
 func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error {
@@ -34,7 +35,11 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
       (
         SELECT COUNT(*)
         FROM ` + dialect.Quote("dashboard") + `
-      ) AS dashboard_count
+      ) AS dashboard_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("playlist") + `
+      ) AS playlist_count
 			`
 
 	var stats m.SystemStats
@@ -46,3 +51,54 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
 	query.Result = &stats
 	return err
 }
+
+func GetAdminStats(query *m.GetAdminStatsQuery) error {
+	var rawSql = `SELECT
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("user") + `
+      ) AS user_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("org") + `
+      ) AS org_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("dashboard") + `
+      ) AS dashboard_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("dashboard_snapshot") + `
+      ) AS db_snapshot_count,
+      (
+        SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` ))
+        FROM ` + dialect.Quote("dashboard_tag") + `
+      ) AS db_tag_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("data_source") + `
+      ) AS data_source_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("playlist") + `
+      ) AS playlist_count,
+      (
+        SELECT COUNT(DISTINCT ` + dialect.Quote("dashboard_id") + ` )
+        FROM ` + dialect.Quote("star") + `
+      ) AS starred_db_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("user") + `
+        WHERE ` + dialect.Quote("is_admin") + ` = 1
+      ) AS grafana_admin_count
+      `
+
+	var stats m.AdminStats
+	_, err := x.Sql(rawSql).Get(&stats)
+	if err != nil {
+		return err
+	}
+
+	query.Result = &stats
+	return err
+}

+ 66 - 0
pkg/util/encryption.go

@@ -0,0 +1,66 @@
+package util
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"crypto/sha256"
+	"io"
+
+	"github.com/grafana/grafana/pkg/log"
+)
+
+const saltLength = 8
+
+func Decrypt(payload []byte, secret string) []byte {
+	salt := payload[:saltLength]
+	key := encryptionKeyToBytes(secret, string(salt))
+
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		log.Fatal(4, err.Error())
+	}
+
+	// The IV needs to be unique, but not secure. Therefore it's common to
+	// include it at the beginning of the ciphertext.
+	if len(payload) < aes.BlockSize {
+		log.Fatal(4, "payload too short")
+	}
+	iv := payload[saltLength : saltLength+aes.BlockSize]
+	payload = payload[saltLength+aes.BlockSize:]
+
+	stream := cipher.NewCFBDecrypter(block, iv)
+
+	// XORKeyStream can work in-place if the two arguments are the same.
+	stream.XORKeyStream(payload, payload)
+	return payload
+}
+
+func Encrypt(payload []byte, secret string) []byte {
+	salt := GetRandomString(saltLength)
+
+	key := encryptionKeyToBytes(secret, salt)
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		log.Fatal(4, err.Error())
+	}
+
+	// The IV needs to be unique, but not secure. Therefore it's common to
+	// include it at the beginning of the ciphertext.
+	ciphertext := make([]byte, saltLength+aes.BlockSize+len(payload))
+	copy(ciphertext[:saltLength], []byte(salt))
+	iv := ciphertext[saltLength : saltLength+aes.BlockSize]
+	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
+		log.Fatal(4, err.Error())
+	}
+
+	stream := cipher.NewCFBEncrypter(block, iv)
+	stream.XORKeyStream(ciphertext[saltLength+aes.BlockSize:], payload)
+
+	return ciphertext
+}
+
+// Key needs to be 32bytes
+func encryptionKeyToBytes(secret, salt string) []byte {
+	return PBKDF2([]byte(secret), []byte(salt), 10000, 32, sha256.New)
+}

+ 27 - 0
pkg/util/encryption_test.go

@@ -0,0 +1,27 @@
+package util
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestEncryption(t *testing.T) {
+
+	Convey("When getting encryption key", t, func() {
+
+		key := encryptionKeyToBytes("secret", "salt")
+		So(len(key), ShouldEqual, 32)
+
+		key = encryptionKeyToBytes("a very long secret key that is larger then 32bytes", "salt")
+		So(len(key), ShouldEqual, 32)
+	})
+
+	Convey("When decrypting basic payload", t, func() {
+		encrypted := Encrypt([]byte("grafana"), "1234")
+		decrypted := Decrypt(encrypted, "1234")
+
+		So(string(decrypted), ShouldEqual, "grafana")
+	})
+
+}

+ 5 - 0
pkg/util/url.go

@@ -27,6 +27,11 @@ func (r *UrlQueryReader) Get(name string, def string) string {
 func JoinUrlFragments(a, b string) string {
 	aslash := strings.HasSuffix(a, "/")
 	bslash := strings.HasPrefix(b, "/")
+
+	if len(b) == 0 {
+		return a
+	}
+
 	switch {
 	case aslash && bslash:
 		return a + b[1:]

+ 46 - 0
pkg/util/url_test.go

@@ -0,0 +1,46 @@
+package util
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUrl(t *testing.T) {
+
+	Convey("When joining two urls where right hand side is empty", t, func() {
+		result := JoinUrlFragments("http://localhost:8080", "")
+
+		So(result, ShouldEqual, "http://localhost:8080")
+	})
+
+	Convey("When joining two urls where right hand side is empty and lefthand side has a trailing slash", t, func() {
+		result := JoinUrlFragments("http://localhost:8080/", "")
+
+		So(result, ShouldEqual, "http://localhost:8080/")
+	})
+
+	Convey("When joining two urls where neither has a trailing slash", t, func() {
+		result := JoinUrlFragments("http://localhost:8080", "api")
+
+		So(result, ShouldEqual, "http://localhost:8080/api")
+	})
+
+	Convey("When joining two urls where lefthand side has a trailing slash", t, func() {
+		result := JoinUrlFragments("http://localhost:8080/", "api")
+
+		So(result, ShouldEqual, "http://localhost:8080/api")
+	})
+
+	Convey("When joining two urls where righthand side has preceding slash", t, func() {
+		result := JoinUrlFragments("http://localhost:8080", "/api")
+
+		So(result, ShouldEqual, "http://localhost:8080/api")
+	})
+
+	Convey("When joining two urls where righthand side has trailing slash", t, func() {
+		result := JoinUrlFragments("http://localhost:8080", "api/")
+
+		So(result, ShouldEqual, "http://localhost:8080/api/")
+	})
+}

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

@@ -150,6 +150,9 @@ export function grafanaAppDirective(playlistSrv) {
       scope.$watch('contextSrv.sidemenu', newVal => {
         if (newVal !== undefined) {
           elem.toggleClass('sidemenu-open', scope.contextSrv.sidemenu);
+          if (!newVal) {
+            scope.contextSrv.setPinnedState(false);
+          }
         }
         if (scope.contextSrv.sidemenu) {
           ignoreSideMenuHide = true;
@@ -159,6 +162,12 @@ export function grafanaAppDirective(playlistSrv) {
         }
       });
 
+      scope.$watch('contextSrv.pinned', newVal => {
+        if (newVal !== undefined) {
+          elem.toggleClass('sidemenu-pinned', newVal);
+        }
+      });
+
       // tooltip removal fix
       scope.$on("$routeChangeSuccess", function() {
         $("#tooltip, .tooltip").remove();
@@ -182,7 +191,7 @@ export function grafanaAppDirective(playlistSrv) {
           }
         }
         // hide sidemenu
-        if (!ignoreSideMenuHide &&  elem.find('.sidemenu').length > 0) {
+        if (!ignoreSideMenuHide && !scope.contextSrv.pinned && elem.find('.sidemenu').length > 0) {
           if (target.parents('.sidemenu').length === 0) {
             scope.$apply(() => scope.contextSrv.toggleSideMenu());
           }

+ 3 - 2
public/app/core/components/navbar/navbar.html

@@ -1,10 +1,11 @@
-<div class="navbar navbar-static-top">
+<div class="navbar">
 	<div class="navbar-inner"><div class="container-fluid">
 			<div class="top-nav-btn top-nav-menu-btn">
 				<a class="pointer" ng-click="ctrl.contextSrv.toggleSideMenu()">
 					<span class="top-nav-logo-background">
-						<img class="logo-icon" src="img/fav32.png"></img>
+						<img class="logo-icon" src="img/grafana_icon.svg"></img>
 					</span>
+					<i class="icon-gf icon-gf-grafana_wordmark"></i>
 					<i class="fa fa-caret-down"></i>
 				</a>
 			</div>

+ 18 - 21
public/app/partials/search.html → public/app/core/components/search/search.html

@@ -1,24 +1,22 @@
-<div ng-controller="SearchCtrl" ng-init="init()" class="search-box">
-
 	<div class="search-field-wrapper">
 		<span style="position: relative;">
-			<input  type="text" placeholder="Find dashboards by name" give-focus="giveSearchFocus" tabindex="1"
-			ng-keydown="keyDown($event)" ng-model="query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="search()" />
+			<input  type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1"
+			ng-keydown="ctrl.keyDown($event)" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.search()" />
 		</span>
 		<div class="search-switches">
 			<i class="fa fa-filter"></i>
-			<a class="pointer" href="javascript:void 0;" ng-click="showStarred()" tabindex="2">
-				<i class="fa fa-remove" ng-show="query.starred"></i>
+			<a class="pointer" href="javascript:void 0;" ng-click="ctrl.showStarred()" tabindex="2">
+				<i class="fa fa-remove" ng-show="ctrl.query.starred"></i>
 				starred
 			</a> |
-			<a class="pointer" href="javascript:void 0;" ng-click="getTags()" tabindex="3">
-				<i class="fa fa-remove" ng-show="tagsMode"></i>
+			<a class="pointer" href="javascript:void 0;" ng-click="ctrl.getTags()" tabindex="3">
+				<i class="fa fa-remove" ng-show="ctrl.tagsMode"></i>
 				tags
 			</a>
-			<span ng-if="query.tag.length">
+			<span ng-if="ctrl.query.tag.length">
 				|
-				<span ng-repeat="tagName in query.tag">
-					<a ng-click="removeTag(tagName, $event)" tag-color-from-name="tagName" class="label label-tag">
+				<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>
@@ -27,12 +25,12 @@
 		</div>
 	</div>
 
-	<div class="search-results-container" ng-if="tagsMode">
+	<div class="search-results-container" ng-if="ctrl.tagsMode">
 		<div class="row">
 			<div class="span6 offset1">
-				<div ng-repeat="tag in results" class="pointer" style="width: 180px; float: left;"
+				<div ng-repeat="tag in ctrl.results" class="pointer" style="width: 180px; float: left;"
 					ng-class="{'selected': $index === selectedIndex }"
-					ng-click="filterByTag(tag.term, $event)">
+					ng-click="ctrl.filterByTag(tag.term, $event)">
 					<a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
 						<i class="fa fa-tag"></i>
 						<span>{{tag.term}} &nbsp;({{tag.count}})</span>
@@ -42,14 +40,14 @@
 		</div>
 	</div>
 
-	<div class="search-results-container" ng-if="!tagsMode">
-		<h6 ng-hide="results.length">No dashboards matching your query were found.</h6>
+	<div class="search-results-container" ng-if="!ctrl.tagsMode">
+		<h6 ng-hide="ctrl.results.length">No dashboards matching your query were found.</h6>
 
-		<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in results"
+		<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in ctrl.results"
 			ng-class="{'selected': $index == selectedIndex}" ng-href="{{row.url}}">
 
 			<span class="search-result-tags">
-				<span ng-click="filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag"  class="label label-tag">
+				<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag"  class="label label-tag">
 					{{tag}}
 				</span>
 				<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
@@ -63,15 +61,14 @@
 	</div>
 
 	<div class="search-button-row">
-		<button class="btn btn-inverse pull-left" ng-click="newDashboard()" ng-show="contextSrv.isEditor">
+		<button class="btn btn-inverse pull-left" ng-click="ctrl.newDashboard()" ng-show="ctrl.contextSrv.isEditor">
 			<i class="fa fa-plus"></i>
 			New
 		</button>
-		<a class="btn btn-inverse pull-left" href="import/dashboard" ng-show="contextSrv.isEditor">
+		<a class="btn btn-inverse pull-left" href="import/dashboard" ng-show="ctrl.contextSrv.isEditor">
 			<i class="fa fa-download"></i>
 			Import
 		</a>
 		<div class="clearfix"></div>
 	</div>
 
-</div>

+ 150 - 0
public/app/core/components/search/search.ts

@@ -0,0 +1,150 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import angular from 'angular';
+import config from 'app/core/config';
+import _ from 'lodash';
+import $ from 'jquery';
+import coreModule from '../../core_module';
+
+export class SearchCtrl {
+  query: any;
+  giveSearchFocus: number;
+  selectedIndex: number;
+  results: any;
+  currentSearchId: number;
+  tagsMode: boolean;
+  showImport: boolean;
+  dismiss: any;
+
+  /** @ngInject */
+  constructor(private $scope, private $location, private $timeout, private backendSrv, private contextSrv) {
+    this.giveSearchFocus = 0;
+    this.selectedIndex = -1;
+    this.results = [];
+    this.query = { query: '', tag: [], starred: false };
+    this.currentSearchId = 0;
+
+    $timeout(() => {
+      this.giveSearchFocus = this.giveSearchFocus + 1;
+      this.query.query = '';
+      this.search();
+    }, 100);
+  }
+
+  keyDown(evt) {
+    if (evt.keyCode === 27) {
+      this.dismiss();
+    }
+    if (evt.keyCode === 40) {
+      this.moveSelection(1);
+    }
+    if (evt.keyCode === 38) {
+      this.moveSelection(-1);
+    }
+    if (evt.keyCode === 13) {
+      if (this.$scope.tagMode) {
+        var tag = this.results[this.selectedIndex];
+        if (tag) {
+          this.filterByTag(tag.term, null);
+        }
+        return;
+      }
+
+      var selectedDash = this.results[this.selectedIndex];
+      if (selectedDash) {
+        this.$location.search({});
+        this.$location.path(selectedDash.url);
+      }
+    }
+  }
+
+  moveSelection(direction) {
+    var max = (this.results || []).length;
+    var newIndex = this.selectedIndex + direction;
+    this.selectedIndex = ((newIndex %= max) < 0) ? newIndex + max : newIndex;
+  }
+
+  searchDashboards() {
+    this.tagsMode = false;
+    this.currentSearchId = this.currentSearchId + 1;
+    var localSearchId = this.currentSearchId;
+
+    return this.backendSrv.search(this.query).then((results) => {
+      if (localSearchId < this.currentSearchId) { return; }
+
+      this.results = _.map(results, function(dash) {
+        dash.url = 'dashboard/' + dash.uri;
+        return dash;
+      });
+
+      if (this.queryHasNoFilters()) {
+        this.results.unshift({ title: 'Home', url: config.appSubUrl + '/', type: 'dash-home' });
+      }
+    });
+  }
+
+  queryHasNoFilters() {
+    var query = this.query;
+    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();
+    }
+  };
+
+  removeTag(tag, evt) {
+    this.query.tag = _.without(this.query.tag, tag);
+    this.search();
+    this.giveSearchFocus = this.giveSearchFocus + 1;
+    evt.stopPropagation();
+    evt.preventDefault();
+  };
+
+  getTags() {
+    return this.backendSrv.get('/api/dashboards/tags').then((results) => {
+      this.tagsMode = !this.tagsMode;
+      this.results = results;
+      this.giveSearchFocus = this.giveSearchFocus + 1;
+      if ( !this.tagsMode ) {
+        this.search();
+      }
+    });
+  };
+
+  showStarred() {
+    this.query.starred = !this.query.starred;
+    this.giveSearchFocus = this.giveSearchFocus + 1;
+    this.search();
+  };
+
+  search() {
+    this.showImport = false;
+    this.selectedIndex = 0;
+    this.searchDashboards();
+  };
+
+  newDashboard() {
+    this.$location.url('dashboard/new');
+  };
+}
+
+export function searchDirective() {
+  return {
+    restrict: 'E',
+    templateUrl: 'app/core/components/search/search.html',
+    controller: SearchCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      dismiss: '&'
+    },
+  };
+}
+
+coreModule.directive('search', searchDirective);

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

@@ -62,5 +62,11 @@
 		</a>
 	</li>
 
+	<li>
+		<a class="sidemenu-item" target="_self" ng-hide="ctrl.contextSrv.pinned" ng-click="ctrl.contextSrv.setPinnedState(true)">
+			<span class="icon-circle sidemenu-icon"><i class="fa fa-fw fa-thumb-tack"></i></span>
+			<span class="sidemenu-item-text">Pin</span>
+		</a>
+	</li>
 </ul>
 

+ 18 - 7
public/app/core/components/sidemenu/sidemenu.ts

@@ -22,8 +22,12 @@ export class SideMenuCtrl {
     this.appSubUrl = config.appSubUrl;
     this.showSignout = this.contextSrv.isSignedIn && !config['authProxyEnabled'];
     this.updateMenu();
+
     this.$scope.$on('$routeChangeSuccess', () => {
-      this.contextSrv.sidemenu = false;
+      this.updateMenu();
+      if (!this.contextSrv.pinned) {
+        this.contextSrv.sidemenu = false;
+      }
     });
   }
 
@@ -83,11 +87,11 @@ export class SideMenuCtrl {
            this.switchOrg(org.orgId);
          }
        });
-
-       if (config.allowOrgCreate) {
-         this.orgMenu.push({text: "New organization", icon: "fa fa-fw fa-plus", url: this.getUrl('/org/new')});
-       }
      });
+
+     if (config.allowOrgCreate) {
+       this.orgMenu.push({text: "New organization", icon: "fa fa-fw fa-plus", url: this.getUrl('/org/new')});
+     }
    });
  }
 
@@ -108,16 +112,23 @@ export class SideMenuCtrl {
    });
 
    this.mainLinks.push({
-     text: "Global Users",
+     text: "Stats",
+     icon: "fa fa-fw fa-bar-chart",
+     url: this.getUrl("/admin/stats"),
+   });
+
+   this.mainLinks.push({
+     text: "Users",
      icon: "fa fa-fw fa-user",
      url: this.getUrl("/admin/users"),
    });
 
    this.mainLinks.push({
-     text: "Global Orgs",
+     text: "Organizations",
      icon: "fa fa-fw fa-users",
      url: this.getUrl("/admin/orgs"),
    });
+
  }
 
  updateMenu() {

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

@@ -1,5 +1,4 @@
 define([
-  './search_ctrl',
   './inspect_ctrl',
   './json_editor_ctrl',
   './login_ctrl',

+ 0 - 127
public/app/core/controllers/search_ctrl.js

@@ -1,127 +0,0 @@
-define([
-  'angular',
-  'lodash',
-  '../core_module',
-  'app/core/config',
-],
-function (angular, _, coreModule, config) {
-  'use strict';
-
-  coreModule.default.controller('SearchCtrl', function($scope, $location, $timeout, backendSrv) {
-
-    $scope.init = function() {
-      $scope.giveSearchFocus = 0;
-      $scope.selectedIndex = -1;
-      $scope.results = [];
-      $scope.query = { query: '', tag: [], starred: false };
-      $scope.currentSearchId = 0;
-
-      $timeout(function() {
-        $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
-        $scope.query.query = '';
-        $scope.search();
-      }, 100);
-    };
-
-    $scope.keyDown = function (evt) {
-      if (evt.keyCode === 27) {
-        $scope.dismiss();
-      }
-      if (evt.keyCode === 40) {
-        $scope.moveSelection(1);
-      }
-      if (evt.keyCode === 38) {
-        $scope.moveSelection(-1);
-      }
-      if (evt.keyCode === 13) {
-        if ($scope.tagMode) {
-          var tag = $scope.results[$scope.selectedIndex];
-          if (tag) {
-            $scope.filterByTag(tag.term);
-          }
-          return;
-        }
-
-        var selectedDash = $scope.results[$scope.selectedIndex];
-        if (selectedDash) {
-          $location.search({});
-          $location.path(selectedDash.url);
-        }
-      }
-    };
-
-    $scope.moveSelection = function(direction) {
-      var max = ($scope.results || []).length;
-      var newIndex = $scope.selectedIndex + direction;
-      $scope.selectedIndex = ((newIndex %= max) < 0) ? newIndex + max : newIndex;
-    };
-
-    $scope.searchDashboards = function() {
-      $scope.tagsMode = false;
-      $scope.currentSearchId = $scope.currentSearchId + 1;
-      var localSearchId = $scope.currentSearchId;
-
-      return backendSrv.search($scope.query).then(function(results) {
-        if (localSearchId < $scope.currentSearchId) { return; }
-
-        $scope.results = _.map(results, function(dash) {
-          dash.url = 'dashboard/' + dash.uri;
-          return dash;
-        });
-
-        if ($scope.queryHasNoFilters()) {
-          $scope.results.unshift({ title: 'Home', url: config.appSubUrl + '/', type: 'dash-home' });
-        }
-      });
-    };
-
-    $scope.queryHasNoFilters = function() {
-      var query = $scope.query;
-      return query.query === '' && query.starred === false && query.tag.length === 0;
-    };
-
-    $scope.filterByTag = function(tag, evt) {
-      $scope.query.tag.push(tag);
-      $scope.search();
-      $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
-      if (evt) {
-        evt.stopPropagation();
-        evt.preventDefault();
-      }
-    };
-
-    $scope.removeTag = function(tag, evt) {
-      $scope.query.tag = _.without($scope.query.tag, tag);
-      $scope.search();
-      $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
-      evt.stopPropagation();
-      evt.preventDefault();
-    };
-
-    $scope.getTags = function() {
-      return backendSrv.get('/api/dashboards/tags').then(function(results) {
-        $scope.tagsMode = true;
-        $scope.results = results;
-        $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
-      });
-    };
-
-    $scope.showStarred = function() {
-      $scope.query.starred = !$scope.query.starred;
-      $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
-      $scope.search();
-    };
-
-    $scope.search = function() {
-      $scope.showImport = false;
-      $scope.selectedIndex = 0;
-      $scope.searchDashboards();
-    };
-
-    $scope.newDashboard = function() {
-      $location.url('dashboard/new');
-    };
-
-  });
-
-});

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

@@ -15,7 +15,6 @@ import "./directives/ng_model_on_blur";
 import "./directives/password_strenght";
 import "./directives/spectrum_picker";
 import "./directives/tags";
-import "./directives/topnav";
 import "./directives/value_select_dropdown";
 import "./directives/give_focus";
 import './jquery_extended';
@@ -23,6 +22,7 @@ import './partials';
 
 import {grafanaAppDirective} from './components/grafana_app';
 import {sideMenuDirective} from './components/sidemenu/sidemenu';
+import {searchDirective} from './components/search/search';
 import {navbarDirective} from './components/navbar/navbar';
 import {arrayJoin} from './directives/array_join';
 import 'app/core/controllers/all';
@@ -31,4 +31,4 @@ import 'app/core/routes/all';
 import './filters/filters';
 import coreModule from './core_module';
 
-export {arrayJoin, coreModule, grafanaAppDirective, sideMenuDirective, navbarDirective};
+export {arrayJoin, coreModule, grafanaAppDirective, sideMenuDirective, navbarDirective, searchDirective};

+ 0 - 50
public/app/core/directives/topnav.js

@@ -1,50 +0,0 @@
-define([
-  '../core_module',
-],
-function (coreModule) {
-  'use strict';
-
-  coreModule.default.directive('topnav', function($rootScope, contextSrv) {
-    return {
-      restrict: 'E',
-      transclude: true,
-      scope: {
-        title: "@",
-        section: "@",
-        titleUrl: "@",
-        subnav: "=",
-      },
-      template:
-        '<div class="navbar navbar-static-top"><div class="navbar-inner"><div class="container-fluid">' +
-        '<div class="top-nav">' +
-				'<div class="top-nav-btn top-nav-menu-btn">' +
-					'<a class="pointer" ng-click="contextSrv.toggleSideMenu()">' +
-						'<span class="top-nav-logo-background">' +
-							'<img class="logo-icon" src="img/fav32.png"></img>' +
-						'</span>' +
-						'<i class="fa fa-caret-down"></i>' +
-					'</a>' +
-				'</div>' +
-
-        '<span class="icon-circle top-nav-icon">' +
-        '<i ng-class="icon"></i>' +
-        '</span>' +
-
-        '<span ng-show="section">' +
-        '<span class="top-nav-title">{{section}}</span>' +
-        '<i class="top-nav-breadcrumb-icon fa fa-angle-right"></i>' +
-        '</span>' +
-
-        '<a ng-href="{{titleUrl}}" class="top-nav-title">' +
-        '{{title}}' +
-        '</a>' +
-        '<i ng-show="subnav" class="top-nav-breadcrumb-icon fa fa-angle-right"></i>' +
-        '</div><div ng-transclude></div></div></div></div>',
-      link: function(scope, elem, attrs) {
-        scope.icon = attrs.icon;
-        scope.contextSrv = contextSrv;
-      }
-    };
-  });
-
-});

+ 5 - 0
public/app/core/routes/all.js

@@ -112,6 +112,11 @@ define([
         templateUrl: 'app/features/admin/partials/edit_org.html',
         controller : 'AdminEditOrgCtrl',
       })
+      .when('/admin/stats', {
+        templateUrl: 'app/features/admin/partials/stats.html',
+        controller : 'AdminStatsCtrl',
+        controllerAs: 'ctrl',
+      })
       .when('/login', {
         templateUrl: 'app/partials/login.html',
         controller : 'LoginCtrl',

+ 13 - 0
public/app/core/services/context_srv.js

@@ -20,10 +20,23 @@ function (angular, _, coreModule, store, config) {
       return this.user.orgRole === role;
     };
 
+    this.setPinnedState = function(val) {
+      this.pinned = val;
+      store.set('grafana.sidemenu.pinned', val);
+    };
+
     this.toggleSideMenu = function() {
       this.sidemenu = !this.sidemenu;
+      if (!this.sidemenu) {
+        this.setPinnedState(false);
+      }
     };
 
+    this.pinned = store.getBool('grafana.sidemenu.pinned', false);
+    if (this.pinned) {
+      this.sidemenu = true;
+    }
+
     this.version = config.buildInfo.version;
     this.lightTheme = false;
     this.user = new User();

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

@@ -41,6 +41,7 @@ export default class TimeSeries {
   nullPointMode: any;
   fillBelowTo: any;
   transform: any;
+  flotpairs: any;
 
   constructor(opts) {
     this.datapoints = opts.datapoints;

+ 37 - 0
public/app/core/utils/file_export.ts

@@ -0,0 +1,37 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+
+declare var window: any;
+
+export function exportSeriesListToCsv(seriesList) {
+    var text = 'Series;Time;Value\n';
+    _.each(seriesList, function(series) {
+        _.each(series.datapoints, function(dp) {
+            text += series.alias + ';' + new Date(dp[1]).toISOString() + ';' + dp[0] + '\n';
+        });
+    });
+    saveSaveBlob(text, 'grafana_data_export.csv');
+};
+
+export function exportTableDataToCsv(table) {
+    var text = '';
+    // add header
+    _.each(table.columns, function(column) {
+        text += column.text + ';';
+    });
+    text += '\n';
+    // process data
+    _.each(table.rows, function(row) {
+        _.each(row, function(value) {
+            text += value + ';';
+        });
+        text += '\n';
+    });
+    saveSaveBlob(text, 'grafana_data_export.csv');
+};
+
+export function saveSaveBlob(payload, fname) {
+    var blob = new Blob([payload], { type: "text/csv;charset=utf-8" });
+    window.saveAs(blob, fname);
+};

+ 0 - 11
public/app/core/utils/kbn.js

@@ -179,17 +179,6 @@ function($, _) {
       .replace(/ +/g,'-');
   };
 
-  kbn.exportSeriesListToCsv = function(seriesList) {
-    var text = 'Series;Time;Value\n';
-    _.each(seriesList, function(series) {
-      _.each(series.datapoints, function(dp) {
-        text += series.alias + ';' + new Date(dp[1]).toISOString() + ';' + dp[0] + '\n';
-      });
-    });
-    var blob = new Blob([text], { type: "text/csv;charset=utf-8" });
-    window.saveAs(blob, 'grafana_data_export.csv');
-  };
-
   kbn.stringToJsRegex = function(str) {
     if (str[0] !== '/') {
       return new RegExp('^' + str + '$');

+ 18 - 0
public/app/features/admin/adminStatsCtrl.ts

@@ -0,0 +1,18 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+
+export class AdminStatsCtrl {
+  stats: any;
+
+  /** @ngInject */
+  constructor(private backendSrv: any) {}
+
+  init() {
+    this.backendSrv.get('/api/admin/stats').then(stats => {
+      this.stats = stats;
+    });
+  }
+}
+
+angular.module('grafana.controllers').controller('AdminStatsCtrl', AdminStatsCtrl);

+ 1 - 0
public/app/features/admin/all.js

@@ -4,4 +4,5 @@ define([
   './adminEditOrgCtrl',
   './adminEditUserCtrl',
   './adminSettingsCtrl',
+  './adminStatsCtrl',
 ], function () {});

+ 3 - 4
public/app/features/admin/partials/edit_org.html

@@ -1,9 +1,8 @@
-<topnav icon="fa fa-fw fa-user" title="Global Users" subnav="true">
+<navbar icon="fa fa-fw fa-user" title="Organizations" title-url="admin/orgs" subnav="true">
 	<ul class="nav">
-		<li><a href="admin/orgs">List</a></li>
-		<li class="active"><a href="admin/orgs/edit/{{org.id}}">Edit Org</a></li>
+		<li class="active"><a href="admin/orgs/edit/{{org.id}}">{{org.name}}</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page">

+ 2 - 4
public/app/features/admin/partials/edit_user.html

@@ -1,10 +1,8 @@
-<topnav icon="fa fa-fw fa-user" title="Global Users" subnav="true">
+<navbar icon="fa fa-fw fa-user" title="Users" title-url="admin/users" subnav="true">
 	<ul class="nav">
-		<li><a href="admin/users">Users</a></li>
-		<li><a href="admin/users/create">Create user</a></li>
 		<li class="active"><a href="admin/users/edit/{{user_id}}">Edit user</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page">

+ 4 - 5
public/app/features/admin/partials/new_user.html

@@ -1,14 +1,13 @@
-<topnav icon="fa fa-fw fa-cogs" title="Global Users" subnav="true">
+<navbar icon="fa fa-fw fa-user" title="Users" title-url="admin/users" subnav="true">
 	<ul class="nav">
-		<li><a href="admin/users">Users</a></li>
-		<li class="active"><a href="admin/users/create">Create user</a></li>
+		<li class="active"><a href="admin/users/create">Add user</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page">
 		<h2>
-			Create a new user
+			Add new user
 		</h2>
 
 		<form name="userForm">

+ 2 - 5
public/app/features/admin/partials/orgs.html

@@ -1,8 +1,5 @@
-<topnav icon="fa fa-fw fa-users" title="Global Orgs" subnav="true">
-	<ul class="nav">
-		<li class="active"><a href="admin/orgs">List</a></li>
-	</ul>
-</topnav>
+<navbar icon="fa fa-fw fa-users" title="Organizations">
+</navbar>
 
 <div class="page-container">
 	<div class="page-wide">

+ 2 - 2
public/app/features/admin/partials/settings.html

@@ -1,5 +1,5 @@
-<topnav icon="fa fa-fw fa-info" title="System info">
-</topnav>
+<navbar icon="fa fa-fw fa-info" title="System info">
+</navbar>
 
 <div class="page-container">
 	<div class="page">

+ 57 - 0
public/app/features/admin/partials/stats.html

@@ -0,0 +1,57 @@
+<navbar icon="fa fa-fw fa-bar-chart" title="Stats">
+</navbar>
+
+<div class="page-container">
+	<div class="page-wide" ng-init="ctrl.init()">
+		<h1>
+			Overview
+		</h1>
+
+    <table class="filter-table form-inline">
+			<thead>
+				<tr>
+					<th>Name</th>
+					<th>Value</th>
+				</tr>
+			</thead>
+			<tbody>
+        <tr>
+					<td>Total dashboards</td>
+					<td>{{ctrl.stats.dashboard_count}}</td>
+				</tr>
+        <tr>
+          <td>Total users</td>
+          <td>{{ctrl.stats.user_count}}</td>
+        </tr>
+        <tr>
+          <td>Total grafana admins</td>
+          <td>{{ctrl.stats.grafana_admin_count}}</td>
+        </tr>
+        <tr>
+          <td>Total organizations</td>
+          <td>{{ctrl.stats.org_count}}</td>
+        </tr>
+        <tr>
+          <td>Total datasources</td>
+          <td>{{ctrl.stats.data_source_count}}</td>
+        </tr>
+        <tr>
+          <td>Total playlists</td>
+          <td>{{ctrl.stats.playlist_count}}</td>
+        </tr>
+        <tr>
+          <td>Total snapshots</td>
+          <td>{{ctrl.stats.db_snapshot_count}}</td>
+        </tr>
+        <tr>
+          <td>Total dashboard tags</td>
+          <td>{{ctrl.stats.db_tag_count}}</td>
+        </tr>
+        <tr>
+          <td>Total starred dashboards</td>
+          <td>{{ctrl.stats.starred_db_count}}</td>
+        </tr>
+			</tbody>
+		</table>
+	</div>
+</div>

+ 8 - 9
public/app/features/admin/partials/users.html

@@ -1,15 +1,14 @@
-<topnav icon="fa fa-fw fa-user" title="Global Users" subnav="true">
-	<ul class="nav">
-		<li class="active"><a href="admin/users">List</a></li>
-		<li><a href="admin/users/create">Create user</a></li>
-	</ul>
-</topnav>
+<navbar icon="fa fa-fw fa-user" title="Users" title-url="admin/users">
+</navbar>
 
 <div class="page-container">
 	<div class="page-wide">
-		<h1>
-			Users
-		</h1>
+		<a class="btn btn-inverse pull-right" href="admin/users/create">
+			<i class="fa fa-plus"></i>
+			Add new user
+		</a>
+
+		<h1>Users</h1>
 
     <table class="filter-table form-inline">
 			<thead>

+ 1 - 0
public/app/features/apps/edit_ctrl.ts

@@ -24,6 +24,7 @@ export class AppEditCtrl {
       enabled: this.appModel.enabled,
       pinned: this.appModel.pinned,
       jsonData: this.appModel.jsonData,
+      secureJsonData: this.appModel.secureJsonData,
     }, options);
 
     this.backendSrv.post(`/api/org/apps/${this.$routeParams.appId}/settings`, updateCmd).then(function() {

+ 2 - 0
public/app/features/apps/partials/edit.html

@@ -98,6 +98,8 @@
 		<div class="simple-box-body">
 			<div ng-if="ctrl.appModel.appId">
 				<app-config-view app-model="ctrl.appModel"></app-config-view>
+				<div class="clearfix"></div>
+				<button type="submit" class="btn btn-success" ng-click="ctrl.update()">Save</button>
 			</div>
 		</div>
 	</section>

+ 2 - 5
public/app/features/apps/partials/list.html

@@ -1,8 +1,5 @@
-<topnav title="Apps" icon="fa fa-fw fa-cubes" subnav="true">
-	<ul class="nav">
-		<li class="active" ><a href="org/apps">Overview</a></li>
-	</ul>
-</topnav>
+<navbar title="Apps" icon="fa fa-fw fa-cubes">
+</navbar>
 
 <div class="page-container">
   <div class="page-wide" ng-init="ctrl.init()">

+ 19 - 2
public/app/features/dashboard/dashboardSrv.js

@@ -234,9 +234,9 @@ function (angular, $, _, moment) {
       var i, j, k;
       var oldVersion = this.schemaVersion;
       var panelUpgrades = [];
-      this.schemaVersion = 8;
+      this.schemaVersion = 9;
 
-      if (oldVersion === 8) {
+      if (oldVersion === this.schemaVersion) {
         return;
       }
 
@@ -390,6 +390,23 @@ function (angular, $, _, moment) {
         });
       }
 
+      // schema version 9 changes
+      if (oldVersion < 9) {
+        // move aliasYAxis changes
+        panelUpgrades.push(function(panel) {
+          if (panel.type !== 'singlestat' && panel.thresholds !== "") { return; }
+
+          if (panel.thresholds) {
+            var k = panel.thresholds.split(",");
+
+            if (k.length >= 3) {
+              k.shift();
+              panel.thresholds = k.join(",");
+            }
+          }
+        });
+      }
+
       if (panelUpgrades.length === 0) {
         return;
       }

+ 11 - 3
public/app/features/dashboard/dashnav/dashnav.html

@@ -10,7 +10,7 @@
 
 <div class="top-nav-snapshot-title" ng-if="dashboardMeta.isSnapshot">
 	<a class="pointer" bs-tooltip="titleTooltip" data-placement="bottom">
-		<i class="gf-icon gf-icon-snap-multi"></i>
+		<i class="icon-gf icon-gf-snapshot"></i>
 		<span class="dashboard-title">
 			{{dashboard.title}}
 			<em class="small">&nbsp;&nbsp;(snapshot)</em>
@@ -24,8 +24,16 @@
 			<i class="fa" ng-class="{'fa-star-o': !dashboardMeta.isStarred, 'fa-star': dashboardMeta.isStarred}" style="color: orange;"></i>
 		</a>
 	</li>
-	<li ng-show="dashboardMeta.canShare">
-		<a class="pointer" ng-click="shareDashboard()" bs-tooltip="'Share dashboard'" data-placement="bottom"><i class="fa fa-share-square-o"></i></a>
+	<li ng-show="dashboardMeta.canShare" class="dropdown">
+		<a class="pointer" ng-click="hideTooltip($event)" bs-tooltip="'Share dashboard'" data-placement="bottom" data-toggle="dropdown"><i class="fa fa-share-square-o"></i></a>
+		<ul class="dropdown-menu">
+			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="shareDashboard(0)">
+				<i class="fa fa-link"></i>
+				Link to Dashboard</a></li>
+			<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="shareDashboard(1)">
+				<i class="gf-icon gf-icon-snap-multi"></i>
+				Snapshot sharing</a></li>
+		</ul>				
 	</li>
 	<li ng-show="dashboardMeta.canSave">
 		<a ng-click="saveDashboard()" bs-tooltip="'Save dashboard'" data-placement="bottom"><i class="fa fa-save"></i></a>

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

@@ -42,10 +42,13 @@ export class DashNavCtrl {
       }
     };
 
-    $scope.shareDashboard = function() {
+    $scope.shareDashboard = function(tabIndex) {
+      var modalScope = $scope.$new();
+      modalScope.tabIndex = tabIndex;
+
       $scope.appEvent('show-modal', {
         src: './app/features/dashboard/partials/shareModal.html',
-        scope: $scope.$new(),
+        scope: modalScope
       });
     };
 

+ 1 - 1
public/app/features/dashboard/directives/dashSearchView.js

@@ -29,7 +29,7 @@ function (angular, $) {
               editorScope = null;
             };
 
-            var view = $('<div class="search-container" ng-include="\'app/partials/search.html\'"></div>');
+            var view = $('<search class="search-container" dismiss="dismiss()"></search>');
 
             elem.append(view);
             $compile(elem.contents())(editorScope);

+ 2 - 2
public/app/features/dashboard/partials/import.html

@@ -1,8 +1,8 @@
-<topnav icon="fa fa-th-large" title="Dashboards" subnav="true">
+<navbar icon="fa fa-th-large" title="Dashboards" subnav="true">
 	<ul class="nav">
 		<li class="active"><a href="import/dashboard">Import</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page">

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

@@ -25,7 +25,7 @@
 							Title
 						</li>
 						<li>
-							<input type="text" class="input-xlarge tight-form-input" ng-model='dashboard.title'></input>
+							<input type="text" class="input-large tight-form-input" ng-model='dashboard.title'></input>
 						</li>
 						<li class="tight-form-item">
 							Tags

+ 2 - 2
public/app/features/dashboard/partials/shareModal.html

@@ -89,7 +89,7 @@
 
 <script type="text/ng-template" id="shareLink.html">
 	<div class="share-modal-big-icon">
-		<i class="fa fa-external-link"></i>
+		<i class="fa fa-link"></i>
 	</div>
 
 	<div ng-include src="'shareLinkOptions.html'"></div>
@@ -110,7 +110,7 @@
 	<div class="ng-cloak" ng-cloak ng-controller="ShareSnapshotCtrl" ng-init="init()">
 		<div class="share-modal-big-icon">
 			<i ng-if="loading" class="fa fa-spinner fa-spin"></i>
-			<i ng-if="!loading" class="gf-icon gf-icon-snap-multi"></i>
+			<i ng-if="!loading" class="icon-gf icon-gf-snapshot"></i>
 		</div>
 
 		<div class="share-snapshot-header" ng-if="step === 1">

+ 1 - 1
public/app/features/dashboard/shareModalCtrl.js

@@ -12,7 +12,7 @@ function (angular, _, require, config) {
   module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, $element, templateSrv, linkSrv) {
 
     $scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
-    $scope.editor = { index: 0 };
+    $scope.editor = { index: $scope.tabIndex || 0};
 
     $scope.init = function() {
       $scope.modeSharePanel = $scope.panel ? true : false;

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

@@ -7,6 +7,7 @@ export class SubmenuCtrl {
   variables: any;
   dashboard: any;
 
+  /** @ngInject */
   constructor(private $rootScope, private templateValuesSrv, private dynamicDashboardSrv) {
     this.annotations = this.dashboard.templating.list;
     this.variables = this.dashboard.templating.list;

+ 0 - 47
public/app/features/dashboard/timepicker/custom.html

@@ -1,47 +0,0 @@
-<div class="gf-box-header">
-	<div class="gf-box-title">
-		<i class="fa fa-clock-o"></i>
-		Custom time range
-	</div>
-	<button class="gf-box-header-close-btn" ng-click="dismiss();">
-		<i class="fa fa-remove"></i>
-	</button>
-</div>
-
-<div class="gf-box-body">
-	<div class="timepicker form-horizontal">
-		<form name="timeForm" style="margin-bottom: 0">
-
-			<div class="timepicker-from-column">
-				<label class="small">From</label>
-				<div class="fake-input timepicker-input">
-					<input class="timepicker-date" type="text" ng-change="validate(temptime)" ng-model="temptime.from.date" data-date-format="yyyy-mm-dd" required bs-datepicker />@
-					<input class="timepicker-hms" type="text" maxlength="2" ng-change="validate(temptime)" ng-model="temptime.from.hour" required ng-pattern="patterns.hour" onClick="this.select();"/>:
-					<input class="timepicker-hms" type="text" maxlength="2" ng-change="validate(temptime)" ng-model="temptime.from.minute" required ng-pattern="patterns.minute" onClick="this.select();"/>:
-					<input class="timepicker-hms" type="text" maxlength="2" ng-change="validate(temptime)" ng-model="temptime.from.second" required ng-pattern="patterns.second" onClick="this.select();"/>.
-					<input class="timepicker-ms" type="text" maxlength="3" ng-change="validate(temptime)" ng-model="temptime.from.millisecond" required ng-pattern="patterns.millisecond"  onClick="this.select();"/>
-				</div>
-			</div>
-
-			<div class="timepicker-to-column">
-
-				<label class="small">To (<a class="link" ng-class="{'strong':temptime.now}" ng-click="ctrl.setNow();temptime.now=true">set now</a>)</label>
-
-				<div class="fake-input timepicker-input">
-					<div ng-hide="temptime.now">
-						<input class="timepicker-date" type="text" ng-change="validate(temptime)" ng-model="temptime.to.date" data-date-format="yyyy-mm-dd" required bs-datepicker />@
-						<input class="timepicker-hms" type="text" maxlength="2" ng-change="validate(temptime)" ng-model="temptime.to.hour" required ng-pattern="patterns.hour" onClick="this.select();"/>:
-						<input class="timepicker-hms" type="text" maxlength="2" ng-change="validate(temptime)" ng-model="temptime.to.minute" required ng-pattern="patterns.minute" onClick="this.select();"/>:
-						<input class="timepicker-hms" type="text" maxlength="2" ng-change="validate(temptime)" ng-model="temptime.to.second" required ng-pattern="patterns.second" onClick="this.select();"/>.
-						<input class="timepicker-ms" type="text" maxlength="3" ng-change="validate(temptime)" ng-model="temptime.to.millisecond" required ng-pattern="patterns.millisecond" onClick="this.select();"/>
-					</div>
-					<span type="text" ng-show="temptime.now" ng-disabled="temptime.now">&nbsp <i class="pointer fa fa-remove" ng-click="ctrl.setNow();temptime.now=false;"></i> Right Now <input type="text" name="dummy" style="visibility:hidden" /></span>
-				</div>
-			</div>
-
-			<br>
-			<button ng-click="ctrl.setAbsoluteTimeFilter(ctrl.validate(temptime));dismiss();" ng-disabled="!timeForm.$valid" class="btn btn-success">Apply</button>
-			<span class="" ng-hide="input.$valid">Invalid date or range</span>
-		</form>
-	</div>
-</div>

+ 3 - 3
public/app/features/dashboard/timepicker/dropdown.html

@@ -1,5 +1,5 @@
 <div class="row pull-right">
-	<div class="gf-timepicker-absolute-section">
+	<form name="timeForm" class="gf-timepicker-absolute-section">
 		<h3>Time range</h3>
 		<label class="small">From:</label>
 		<div class="input-prepend">
@@ -29,10 +29,10 @@
 		<select ng-model="ctrl.refresh.value" class='input-medium' ng-options="f.value as f.text for f in ctrl.refresh.options">
 		</select>
 
-		<button class="btn btn-inverse gf-timepicker-btn-apply" type="button" ng-click="ctrl.applyCustom()">
+		<button type="submit" class="btn btn-primary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">
 			Apply
 		</button>
-	</div>
+	</form>
 
 	<div class="gf-timepicker-relative-section">
 		<h3>Quick ranges</h3>

+ 14 - 1
public/app/features/dashboard/timepicker/input_date.ts

@@ -1,6 +1,7 @@
 ///<reference path="../../../headers/common.d.ts" />
 
 import moment from 'moment';
+import * as dateMath from 'app/core/utils/datemath';
 
 export function inputDateDirective() {
   return {
@@ -11,8 +12,14 @@ export function inputDateDirective() {
 
       var fromUser = function (text) {
         if (text.indexOf('now') !== -1) {
+          if (!dateMath.isValid(text)) {
+            ngModel.$setValidity("error", false);
+            return undefined;
+          }
+          ngModel.$setValidity("error", true);
           return text;
         }
+
         var parsed;
         if ($scope.ctrl.isUtc) {
           parsed = moment.utc(text, format);
@@ -20,7 +27,13 @@ export function inputDateDirective() {
           parsed = moment(text, format);
         }
 
-        return parsed.isValid() ? parsed : undefined;
+        if (!parsed.isValid()) {
+          ngModel.$setValidity("error", false);
+          return undefined;
+        }
+
+        ngModel.$setValidity("error", true);
+        return parsed;
       };
 
       var toUser = function (currentValue) {

+ 1 - 1
public/app/features/dashlinks/module.js

@@ -159,7 +159,7 @@ function (angular, _) {
     };
 
     updateDashLinks();
-    $rootScope.onAppEvent('dash-links-updated', updateDashLinks, $rootScope);
+    $rootScope.onAppEvent('dash-links-updated', updateDashLinks, $scope);
   });
 
   module.controller('DashLinkEditorCtrl', function($scope, $rootScope) {

+ 2 - 2
public/app/features/datasources/partials/edit.html

@@ -1,9 +1,9 @@
-<topnav title="Data sources" title-url="datasources" icon="fa fa-fw fa-database" subnav="true">
+<navbar title="Data sources" title-url="datasources" icon="fa fa-fw fa-database" subnav="true">
 	<ul class="nav">
 		<li ng-class="{active: isNew}" ng-show="isNew"><a href="datasources/new">Add new</a></li>
 		<li class="active" ng-show="!isNew"><a href="datasources/edit/{{current.name}}">{{current.name}}</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page">

+ 3 - 3
public/app/features/datasources/partials/list.html

@@ -1,10 +1,10 @@
-<topnav title="Data sources" icon="fa fa-fw fa-database" subnav="false">
-</topnav>
+<navbar title="Data sources" icon="fa fa-fw fa-database">
+</navbar>
 
 <div class="page-container">
 	<div class="page-wide">
 
-		<a type="submit" class="btn btn-inverse pull-right" href="datasources/new">
+		<a class="btn btn-inverse pull-right" href="datasources/new">
 			<i class="fa fa-plus"></i>
 			Add data source
 		</a>

+ 2 - 2
public/app/features/org/partials/newOrg.html

@@ -1,8 +1,8 @@
-<topnav title="Organization" icon="fa fa-fw fa-users" subnav="true">
+<navbar title="Organization" icon="fa fa-fw fa-users" subnav="true">
 	<ul class="nav">
 		<li class="active"><a href="org/new">New organization</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page">

+ 2 - 2
public/app/features/org/partials/orgApiKeys.html

@@ -1,8 +1,8 @@
-<topnav icon="fa fa-fw fa-users" title="Organization" subnav="true">
+<navbar icon="fa fa-fw fa-users" title="Organization" subnav="true">
 	<ul class="nav">
 		<li class="active"><a href="org/apikeys">API Keys</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page-wide">

+ 2 - 2
public/app/features/org/partials/orgDetails.html

@@ -1,8 +1,8 @@
-<topnav icon="fa fa-fw fa-users" title="Organization" subnav="true">
+<navbar icon="fa fa-fw fa-users" title="Organization">
 	<ul class="nav">
 		<li class="active"><a href="org">Preferences</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page">

+ 2 - 2
public/app/features/org/partials/orgUsers.html

@@ -1,8 +1,8 @@
-<topnav title="Organization" icon="fa fa-fw fa-users" subnav="true">
+<navbar title="Organization" icon="fa fa-fw fa-users" subnav="true">
 	<ul class="nav">
 		<li class="active"><a href="org/users">Users</a></li>
 	</ul>
-</topnav>
+</navbar>
 
 <div class="page-container">
 	<div class="page-wide">

+ 1 - 1
public/app/features/panel/panel_menu.js

@@ -123,7 +123,7 @@ function (angular, $, _) {
             }
 
             var menuTemplate;
-            if ($(e.target).hasClass('fa-external-link')) {
+            if ($(e.target).hasClass('fa-link')) {
               menuTemplate = createExternalLinkMenu($scope);
             } else {
               menuTemplate = createMenuTemplate($scope);

+ 1 - 0
public/app/features/playlist/all.js

@@ -1,5 +1,6 @@
 define([
   './playlists_ctrl',
+  './playlist_search',
   './playlist_srv',
   './playlist_edit_ctrl',
   './playlist_routes'

+ 0 - 5
public/app/features/playlist/partials/playlist-remove.html

@@ -1,5 +0,0 @@
-<p class="text-center">Are you sure want to delete "{{playlist.title}}" playlist?</p>
-<p class="text-center">
-  <button type="button" class="btn btn-danger" ng-click="removePlaylist()">Yes</button>
-  <button type="button" class="btn btn-default" ng-click="dismiss()">No</button>
-</p>

+ 47 - 41
public/app/features/playlist/partials/playlist.html

@@ -1,14 +1,14 @@
 <navbar title="Playlists" title-url="playlists" icon="fa fa-fw fa-list" subnav="true">
 	<ul class="nav">
-		<li ng-class="{active: isNew()}" ng-show="isNew()"><a href="datasources/create">New</a></li>
-		<li class="active" ng-show="!isNew()"><a href="playlists/edit/{{playlist.id}}">{{playlist.name}}</a></li>
+		<li ng-class="{active: ctrl.isNew()}" ng-show="ctrl.isNew()"><a href="datasources/create">New</a></li>
+		<li class="active" ng-show="!ctrl.isNew()"><a href="playlists/edit/{{ctrl.playlist.id}}">{{ctrl.playlist.name}}</a></li>
 	</ul>
 </navbar>
 
 <div class="page-container" ng-form="playlistEditForm">
   <div class="page">
-    <h2 ng-show="isNew()">New playlist</h2>
-    <h2 ng-show="!isNew()">Edit playlist</h2>
+    <h2 ng-show="ctrl.isNew()">New playlist</h2>
+    <h2 ng-show="!ctrl.isNew()">Edit playlist</h2>
 
     <h4>Name and interval</h4>
 
@@ -20,7 +20,7 @@
               Name
             </li>
             <li>
-              <input type="text" required ng-model="playlist.name" class="input-xlarge tight-form-input">
+              <input type="text" required ng-model="ctrl.playlist.name" class="input-xlarge tight-form-input">
             </li>
           </ul>
           <div class="clearfix"></div>
@@ -31,7 +31,7 @@
               Interval
             </li>
             <li>
-              <input type="text" required ng-model="playlist.interval" placeholder="5m" class="input-xlarge tight-form-input">
+              <input type="text" required ng-model="ctrl.playlist.interval" placeholder="5m" class="input-xlarge tight-form-input">
             </li>
           </ul>
           <div class="clearfix"></div>
@@ -39,66 +39,72 @@
       </div>
 
       <br>
-      <h4>Add dashboards</h4>
 
-      <div style="display: inline-block">
-        <div class="tight-form last">
-          <ul class="tight-form-list">
-						<li class="tight-form-item">
-							Search
-						</li>
-            <li>
-              <input type="text"
-                     class="tight-form-input input-xlarge last"
-                     ng-model="searchQuery"
-                     placeholder="dashboard search term"
-                     ng-trim="true"
-                     ng-change="search()">
-            </li>
-          </ul>
-          <div class="clearfix"></div>
-        </div>
-      </div>
     </div>
   </div>
 
   <div class="row">
     <div class="span5 pull-left">
-			<h5>Search results ({{filteredPlaylistItems.length}})</h5>
+      <h5>Add dashboards</h5>
+      <div style="">
+        <playlist-search class="playlist-search-container" search-started="ctrl.searchStarted(promise)"></playlist-search>
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="span5 pull-left" ng-if="ctrl.filteredDashboards.length > 0">
+			<h5>Search results ({{ctrl.filteredDashboards.length}})</h5>
        <table class="grafana-options-table">
-        <tr ng-repeat="playlistItem in filteredPlaylistItems">
+        <tr ng-repeat="playlistItem in ctrl.filteredDashboards">
           <td style="white-space: nowrap;">
             {{playlistItem.title}}
           </td>
           <td style="text-align: center">
-            <button class="btn btn-inverse btn-mini pull-right" ng-click="addPlaylistItem(playlistItem)">
+            <button class="btn btn-inverse btn-mini pull-right" ng-click="ctrl.addPlaylistItem(playlistItem)">
               <i class="fa fa-plus"></i>
               Add to playlist
             </button>
           </td>
         </tr>
-        <tr ng-if="isSearchResultsEmpty()">
-          <td colspan="2">
-            <i class="fa fa-warning"></i> Search results empty
-          </td>
-        </tr>
       </table>
     </div>
+    <div class="playlist-search-results-container" ng-if="ctrl.filteredTags.length > 0">
+      <div class="row">
+        <div class="span6 offset1">
+          <div ng-repeat="tag in ctrl.filteredTags" class="pointer" style="width: 180px; float: left;"
+            ng-class="{'selected': $index === selectedIndex }"
+            ng-click="ctrl.addTagPlaylistItem(tag, $event)">
+            <a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
+              <i class="fa fa-tag"></i>
+              <span>{{tag.term}} &nbsp;({{tag.count}})</span>
+            </a>
+          </div>
+        </div>
+      </div>
+    </div>
     <div class="span5 pull-left">
       <h5>Added dashboards</h5>
       <table class="grafana-options-table">
-        <tr ng-repeat="playlistItem in playlistItems">
-          <td style="white-space: nowrap;">
+        <tr ng-repeat="playlistItem in ctrl.playlistItems">
+          <td style="white-space: nowrap;" ng-if="playlistItem.type === 'dashboard_by_id'">
             {{playlistItem.title}}
           </td>
+          <td style="white-space: nowrap;"  ng-if="playlistItem.type === 'dashboard_by_tag'">
+            <a class="search-result-tag label label-tag" tag-color-from-name="playlistItem.title">
+              <i class="fa fa-tag"></i>
+              <span>{{playlistItem.title}}</span>
+            </a>
+          </td>
+
           <td style="text-align: right">
-            <button class="btn btn-inverse btn-mini" ng-hide="$first" ng-click="movePlaylistItemUp(playlistItem)">
+            <button class="btn btn-inverse btn-mini" ng-hide="$first" ng-click="ctrl.movePlaylistItemUp(playlistItem)">
               <i class="fa fa-arrow-up"></i>
             </button>
-            <button class="btn btn-inverse btn-mini" ng-hide="$last" ng-click="movePlaylistItemDown(playlistItem)">
+            <button class="btn btn-inverse btn-mini" ng-hide="$last" ng-click="ctrl.movePlaylistItemDown(playlistItem)">
               <i class="fa fa-arrow-down"></i>
             </button>
-            <button class="btn btn-inverse btn-mini" ng-click="removePlaylistItem(playlistItem)">
+            <button class="btn btn-inverse btn-mini" ng-click="ctrl.removePlaylistItem(playlistItem)">
               <i class="fa fa-remove"></i>
             </button>
           </td>
@@ -113,11 +119,11 @@
     <!-- <div class="tight-form"> -->
       <button type="button"
               class="btn btn-success"
-              ng-disabled="playlistEditForm.$invalid || isPlaylistEmpty()"
-              ng-click="savePlaylist(playlist, playlistItems)">Save</button>
+              ng-disabled="ctrl.playlistEditForm.$invalid || ctrl.isPlaylistEmpty()"
+              ng-click="ctrl.savePlaylist(ctrl.playlist, ctrl.playlistItems)">Save</button>
       <button type="button"
               class="btn btn-inverse"
-              ng-click="backToList()">Cancel</button>
+              ng-click="ctrl.backToList()">Cancel</button>
     <!-- </div> -->
   </div>
 

+ 26 - 0
public/app/features/playlist/partials/playlist_search.html

@@ -0,0 +1,26 @@
+<div class="playlist-search-field-wrapper">
+  <span style="position: relative;">
+    <input  type="text" placeholder="Find dashboards by name" tabindex="1"
+    ng-keydown="ctrl.keyDown($event)" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.searchDashboards()" />
+  </span>
+  <div class="playlist-search-switches">
+    <i class="fa fa-filter"></i>
+    <a class="pointer" href="javascript:void 0;" ng-click="ctrl.showStarred()" tabindex="2">
+      <i class="fa fa-remove" ng-show="ctrl.query.starred"></i>
+      starred
+    </a> |
+    <a class="pointer" href="javascript:void 0;" ng-click="ctrl.getTags()" tabindex="3">
+      <i class="fa fa-remove" ng-show="ctrl.tagsMode"></i>
+      tags
+    </a>
+    <span ng-if="ctrl.query.tag.length">
+      |
+      <span ng-repeat="tagName in ctrl.query.tag">
+        <a ng-click="ctrl.removeTag(tagName, $event)" tag-color-from-name="ctrl.tagName" class="label label-tag">
+          <i class="fa fa-remove"></i>
+          {{tagName}}
+        </a>
+      </span>
+    </span>
+  </div>
+</div>

+ 2 - 2
public/app/features/playlist/partials/playlists.html

@@ -19,7 +19,7 @@
         <th style="width: 25px"></th>
 
       </thead>
-      <tr ng-repeat="playlist in playlists">
+      <tr ng-repeat="playlist in ctrl.playlists">
         <td>
 					<a href="playlists/edit/{{playlist.id}}">{{playlist.name}}</a>
         </td>
@@ -39,7 +39,7 @@
           </a>
         </td>
         <td  class="text-right">
-          <a ng-click="removePlaylist(playlist)" class="btn btn-danger btn-mini">
+          <a ng-click="ctrl.removePlaylist(playlist)" class="btn btn-danger btn-mini">
             <i class="fa fa-remove"></i>
           </a>
         </td>

+ 0 - 144
public/app/features/playlist/playlist_edit_ctrl.js

@@ -1,144 +0,0 @@
-define([
-  'angular',
-  'app/core/config',
-  'lodash'
-],
-function (angular, config, _) {
-  'use strict';
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('PlaylistEditCtrl', function($scope, playlistSrv, backendSrv, $location, $route) {
-    $scope.filteredPlaylistItems = [];
-    $scope.foundPlaylistItems = [];
-    $scope.searchQuery = '';
-    $scope.loading = false;
-    $scope.playlist = {
-      interval: '10m',
-    };
-    $scope.playlistItems = [];
-
-    $scope.init = function() {
-      if ($route.current.params.id) {
-        var playlistId = $route.current.params.id;
-
-        backendSrv.get('/api/playlists/' + playlistId)
-          .then(function(result) {
-            $scope.playlist = result;
-          });
-
-        backendSrv.get('/api/playlists/' + playlistId + '/items')
-          .then(function(result) {
-            $scope.playlistItems = result;
-          });
-      }
-
-      $scope.search();
-    };
-
-    $scope.search = function() {
-      var query = {limit: 10};
-
-      if ($scope.searchQuery) {
-        query.query = $scope.searchQuery;
-      }
-
-      $scope.loading = true;
-
-      backendSrv.search(query)
-        .then(function(results) {
-          $scope.foundPlaylistItems = results;
-          $scope.filterFoundPlaylistItems();
-        })
-        .finally(function() {
-          $scope.loading = false;
-        });
-    };
-
-    $scope.filterFoundPlaylistItems = function() {
-      $scope.filteredPlaylistItems = _.reject($scope.foundPlaylistItems, function(playlistItem) {
-        return _.findWhere($scope.playlistItems, function(listPlaylistItem) {
-          return parseInt(listPlaylistItem.value) === playlistItem.id;
-        });
-      });
-    };
-
-    $scope.addPlaylistItem = function(playlistItem) {
-      playlistItem.value = playlistItem.id.toString();
-      playlistItem.type = 'dashboard_by_id';
-      playlistItem.order = $scope.playlistItems.length + 1;
-
-      $scope.playlistItems.push(playlistItem);
-      $scope.filterFoundPlaylistItems();
-    };
-
-    $scope.removePlaylistItem = function(playlistItem) {
-      _.remove($scope.playlistItems, function(listedPlaylistItem) {
-        return playlistItem === listedPlaylistItem;
-      });
-      $scope.filterFoundPlaylistItems();
-    };
-
-    $scope.savePlaylist = function(playlist, playlistItems) {
-      var savePromise;
-
-      playlist.items = playlistItems;
-
-      savePromise = playlist.id
-        ? backendSrv.put('/api/playlists/' + playlist.id, playlist)
-        : backendSrv.post('/api/playlists', playlist);
-
-      savePromise
-        .then(function() {
-          $scope.appEvent('alert-success', ['Playlist saved', '']);
-          $location.path('/playlists');
-        }, function() {
-          $scope.appEvent('alert-error', ['Unable to save playlist', '']);
-        });
-    };
-
-    $scope.isNew = function() {
-      return !$scope.playlist.id;
-    };
-
-    $scope.isPlaylistEmpty = function() {
-      return !$scope.playlistItems.length;
-    };
-
-    $scope.isSearchResultsEmpty = function() {
-      return !$scope.foundPlaylistItems.length;
-    };
-
-    $scope.isSearchQueryEmpty = function() {
-      return $scope.searchQuery === '';
-    };
-
-    $scope.backToList = function() {
-      $location.path('/playlists');
-    };
-
-    $scope.isLoading = function() {
-      return $scope.loading;
-    };
-
-    $scope.movePlaylistItem = function(playlistItem, offset) {
-      var currentPosition = $scope.playlistItems.indexOf(playlistItem);
-      var newPosition = currentPosition + offset;
-
-      if (newPosition >= 0 && newPosition < $scope.playlistItems.length) {
-        $scope.playlistItems.splice(currentPosition, 1);
-        $scope.playlistItems.splice(newPosition, 0, playlistItem);
-      }
-    };
-
-    $scope.movePlaylistItemUp = function(playlistItem) {
-      $scope.moveDashboard(playlistItem, -1);
-    };
-
-    $scope.movePlaylistItemDown = function(playlistItem) {
-      $scope.moveDashboard(playlistItem, 1);
-    };
-
-    $scope.init();
-  });
-});

+ 136 - 0
public/app/features/playlist/playlist_edit_ctrl.ts

@@ -0,0 +1,136 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+import _ from 'lodash';
+import coreModule from '../../core/core_module';
+import config from 'app/core/config';
+
+export class PlaylistEditCtrl {
+  filteredDashboards: any = [];
+  filteredTags: any = [];
+  searchQuery: string = '';
+  loading: boolean = false;
+  playlist: any = {
+    interval: '10m',
+  };
+  playlistItems: any = [];
+  dashboardresult: any = [];
+  tagresult: any = [];
+
+  /** @ngInject */
+  constructor(private $scope, private playlistSrv, private backendSrv, private $location, private $route) {
+    if ($route.current.params.id) {
+      var playlistId = $route.current.params.id;
+
+      backendSrv.get('/api/playlists/' + playlistId)
+        .then((result) => {
+          this.playlist = result;
+        });
+
+      backendSrv.get('/api/playlists/' + playlistId + '/items')
+        .then((result) => {
+          this.playlistItems = result;
+        });
+    }
+  }
+
+  filterFoundPlaylistItems() {
+    this.filteredDashboards = _.reject(this.dashboardresult, (playlistItem) => {
+      return _.findWhere(this.playlistItems, (listPlaylistItem) => {
+        return parseInt(listPlaylistItem.value) === playlistItem.id;
+      });
+    });
+
+    this.filteredTags = _.reject(this.tagresult, (tag) => {
+      return _.findWhere(this.playlistItems, (listPlaylistItem) => {
+        return listPlaylistItem.value === tag.term;
+      });
+    });
+  }
+
+  addPlaylistItem(playlistItem) {
+    playlistItem.value = playlistItem.id.toString();
+    playlistItem.type = 'dashboard_by_id';
+    playlistItem.order = this.playlistItems.length + 1;
+
+    this.playlistItems.push(playlistItem);
+    this.filterFoundPlaylistItems();
+  }
+
+  addTagPlaylistItem(tag) {
+    var playlistItem: any = {
+      value: tag.term,
+      type: 'dashboard_by_tag',
+      order: this.playlistItems.length + 1,
+      title: tag.term
+    };
+
+    this.playlistItems.push(playlistItem);
+    this.filterFoundPlaylistItems();
+  }
+
+  removePlaylistItem(playlistItem) {
+    _.remove(this.playlistItems, (listedPlaylistItem) => {
+      return playlistItem === listedPlaylistItem;
+    });
+    this.filterFoundPlaylistItems();
+  };
+
+  savePlaylist(playlist, playlistItems) {
+    var savePromise;
+
+    playlist.items = playlistItems;
+
+    savePromise = playlist.id
+      ? this.backendSrv.put('/api/playlists/' + playlist.id, playlist)
+      : this.backendSrv.post('/api/playlists', playlist);
+
+    savePromise
+      .then(() => {
+        this.$scope.appEvent('alert-success', ['Playlist saved', '']);
+        this.$location.path('/playlists');
+      }, () => {
+        this.$scope.appEvent('alert-error', ['Unable to save playlist', '']);
+      });
+  }
+
+  isNew() {
+    return !this.playlist.id;
+  }
+
+  isPlaylistEmpty() {
+    return !this.playlistItems.length;
+  }
+
+  backToList() {
+    this.$location.path('/playlists');
+  }
+
+  searchStarted(promise) {
+    promise.then((data) => {
+      this.dashboardresult = data.dashboardResult;
+      this.tagresult = data.tagResult;
+      this.filterFoundPlaylistItems();
+    });
+  }
+
+  movePlaylistItem(playlistItem, offset) {
+    var currentPosition = this.playlistItems.indexOf(playlistItem);
+    var newPosition = currentPosition + offset;
+
+    if (newPosition >= 0 && newPosition < this.playlistItems.length) {
+      this.playlistItems.splice(currentPosition, 1);
+      this.playlistItems.splice(newPosition, 0, playlistItem);
+    }
+  }
+
+  movePlaylistItemUp(playlistItem) {
+    this.movePlaylistItem(playlistItem, -1);
+  }
+
+  movePlaylistItemDown(playlistItem) {
+    this.movePlaylistItem(playlistItem, 1);
+  }
+}
+
+coreModule.controller('PlaylistEditCtrl', PlaylistEditCtrl);

+ 4 - 1
public/app/features/playlist/playlist_routes.js

@@ -1,4 +1,4 @@
-define([
+  define([
   'angular',
   'app/core/config',
   'lodash'
@@ -12,14 +12,17 @@ function (angular) {
     $routeProvider
       .when('/playlists', {
         templateUrl: 'app/features/playlist/partials/playlists.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistsCtrl'
       })
       .when('/playlists/create', {
         templateUrl: 'app/features/playlist/partials/playlist.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistEditCtrl'
       })
       .when('/playlists/edit/:id', {
         templateUrl: 'app/features/playlist/partials/playlist.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistEditCtrl'
       })
       .when('/playlists/play/:id', {

+ 83 - 0
public/app/features/playlist/playlist_search.ts

@@ -0,0 +1,83 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+import config from 'app/core/config';
+import _ from 'lodash';
+import $ from 'jquery';
+import coreModule from '../../core/core_module';
+
+export class PlaylistSearchCtrl {
+  query: any;
+  tagsMode: boolean;
+
+  searchStarted: any;
+
+  /** @ngInject */
+  constructor(private $scope, private $location, private $timeout, private backendSrv, private contextSrv) {
+    this.query = { query: '', tag: [], starred: false };
+
+    $timeout(() => {
+      this.query.query = '';
+      this.searchDashboards();
+    }, 100);
+  }
+
+  searchDashboards() {
+    this.tagsMode = false;
+    var prom: any = {};
+
+    prom.promise = this.backendSrv.search(this.query).then((result) => {
+      return {
+        dashboardResult: result,
+        tagResult: []
+      };
+    });
+
+    this.searchStarted(prom);
+  }
+
+  showStarred() {
+    this.query.starred = !this.query.starred;
+    this.searchDashboards();
+  }
+
+  queryHasNoFilters() {
+    return this.query.query === '' && this.query.starred === false && this.query.tag.length === 0;
+  }
+
+  filterByTag(tag, evt) {
+    this.query.tag.push(tag);
+    this.searchDashboards();
+    if (evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+    }
+  }
+
+  getTags() {
+    var prom: any = {};
+    prom.promise = this.backendSrv.get('/api/dashboards/tags').then((result) => {
+      return {
+        dashboardResult: [],
+        tagResult: result
+      };
+    });
+
+    this.searchStarted(prom);
+  }
+}
+
+export function playlistSearchDirective() {
+  return {
+    restrict: 'E',
+    templateUrl: 'app/features/playlist/partials/playlist_search.html',
+    controller: PlaylistSearchCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      searchStarted: '&'
+    },
+  };
+}
+
+coreModule.directive('playlistSearch', playlistSearchDirective);

+ 2 - 2
public/app/features/playlist/playlist_srv.ts

@@ -1,6 +1,7 @@
 ///<reference path="../../headers/common.d.ts" />
 
 import angular from 'angular';
+import config from 'app/core/config';
 import coreModule from '../../core/core_module';
 import kbn from 'app/core/utils/kbn';
 
@@ -20,10 +21,9 @@ class PlaylistSrv {
     var playedAllDashboards = this.index > this.dashboards.length - 1;
 
     if (playedAllDashboards) {
-      this.start(this.playlistId);
+      window.location.href = `${config.appSubUrl}/playlists/play/${this.playlistId}`;
     } else {
       var dash = this.dashboards[this.index];
-
       this.$location.url('dashboard/' + dash.uri);
 
       this.index++;

+ 0 - 43
public/app/features/playlist/playlists_ctrl.js

@@ -1,43 +0,0 @@
-define([
-  'angular',
-  'lodash'
-],
-function (angular, _) {
-  'use strict';
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('PlaylistsCtrl', function($scope, $location, backendSrv) {
-    backendSrv.get('/api/playlists')
-      .then(function(result) {
-        $scope.playlists = result;
-      });
-
-    $scope.removePlaylistConfirmed = function(playlist) {
-      _.remove($scope.playlists, {id: playlist.id});
-
-      backendSrv.delete('/api/playlists/' + playlist.id)
-      .then(function() {
-        $scope.appEvent('alert-success', ['Playlist deleted', '']);
-      }, function() {
-        $scope.appEvent('alert-error', ['Unable to delete playlist', '']);
-        $scope.playlists.push(playlist);
-      });
-    };
-
-    $scope.removePlaylist = function(playlist) {
-
-      $scope.appEvent('confirm-modal', {
-        title: 'Confirm delete playlist',
-        text: 'Are you sure you want to delete playlist ' + playlist.name + '?',
-        yesText: "Delete",
-        icon: "fa-warning",
-        onConfirm: function() {
-          $scope.removePlaylistConfirmed(playlist);
-        }
-      });
-
-    };
-
-  });
-});

+ 44 - 0
public/app/features/playlist/playlists_ctrl.ts

@@ -0,0 +1,44 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+import _ from 'lodash';
+import coreModule from '../../core/core_module';
+
+export class PlaylistsCtrl {
+  playlists: any;
+
+  /** @ngInject */
+  constructor(private $scope, private $location, private backendSrv) {
+    backendSrv.get('/api/playlists')
+      .then((result) => {
+        this.playlists = result;
+      });
+  }
+
+  removePlaylistConfirmed(playlist) {
+    _.remove(this.playlists, { id: playlist.id });
+
+    this.backendSrv.delete('/api/playlists/' + playlist.id)
+      .then(() => {
+        this.$scope.appEvent('alert-success', ['Playlist deleted', '']);
+      }, () => {
+        this.$scope.appEvent('alert-error', ['Unable to delete playlist', '']);
+        this.playlists.push(playlist);
+      });
+  }
+
+  removePlaylist(playlist) {
+
+    this.$scope.appEvent('confirm-modal', {
+      title: 'Confirm delete playlist',
+      text: 'Are you sure you want to delete playlist ' + playlist.name + '?',
+      yesText: "Delete",
+      icon: "fa-warning",
+      onConfirm: () => {
+        this.removePlaylistConfirmed(playlist);
+      }
+    });
+  }
+}
+
+coreModule.controller('PlaylistsCtrl', PlaylistsCtrl);

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio