فهرست منبع

Merge branch 'master' into panelbase

Torkel Ödegaard 10 سال پیش
والد
کامیت
931d7cd039
100فایلهای تغییر یافته به همراه1486 افزوده شده و 847 حذف شده
  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)
 # 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)
 * **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)
 * **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)
 * **InfluxDB**: Support for policy selection in query editor, closes [#2018](https://github.com/grafana/grafana/issues/2018)
 
 
 ### Breaking changes
 ### 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)
 * **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)
 * **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)
 * **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)
 # 2.6.1 (unrelased, 2.6.x branch)
 
 

+ 9 - 0
circle.yml

@@ -12,6 +12,8 @@ dependencies:
     - mkdir -p ${GOPATH}/src/${ORG_PATH}
     - mkdir -p ${GOPATH}/src/${ORG_PATH}
     - ln -s ~/grafana ${GOPATH}/src/${ORG_PATH}
     - ln -s ~/grafana ${GOPATH}/src/${ORG_PATH}
     - go get github.com/tools/godep
     - go get github.com/tools/godep
+    - rm -rf node_modules
+    - npm install -g npm
     - npm install
     - npm install
 
 
 test:
 test:
@@ -25,3 +27,10 @@ test:
      # js tests
      # js tests
      - ./node_modules/grunt-cli/bin/grunt test
      - ./node_modules/grunt-cli/bin/grunt test
      - npm run coveralls
      - 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/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/carbon.git
 run cd /usr/local/src && git clone https://github.com/graphite-project/whisper.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/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 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 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
 # graphite
-expose	80
+expose  80
 
 
 # Carbon line receiver port
 # Carbon line receiver port
-expose	2003
+expose  2003
 # Carbon cache query port
 # 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 ["/opt/graphite/storage/whisper"]
 VOLUME ["/var/lib/log/supervisor"]
 VOLUME ["/var/lib/log/supervisor"]
 
 
-cmd	["/usr/bin/supervisord"]
+cmd ["/usr/bin/supervisord"]
 
 
 # vim:ts=8:noet:
 # vim:ts=8:noet:

+ 7 - 1
docker/blocks/graphite/fig

@@ -1,4 +1,10 @@
 graphite:
 graphite:
   build: blocks/graphite
   build: blocks/graphite
   ports:
   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
 stderr_logfile = /var/log/supervisor/%(program_name)s.log
 autorestart = true
 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:
 influxdb:
-  build: blocks/influxdb
+  image: tutum/influxdb:latest
   ports:
   ports:
     - "2004:2004"
     - "2004:2004"
     - "8083:8083"
     - "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).
 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 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.
 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
 ### Global Users
 
 
 `POST /api/admin/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.
 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.
 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
 ## 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.
 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.
 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
 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">).
 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 (
 import (
 	"strings"
 	"strings"
 
 
+	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
@@ -27,3 +29,15 @@ func AdminGetSettings(c *middleware.Context) {
 
 
 	c.JSON(200, settings)
 	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/users/edit/:id", reqGrafanaAdmin, Index)
 	r.Get("/admin/orgs", reqGrafanaAdmin, Index)
 	r.Get("/admin/orgs", reqGrafanaAdmin, Index)
 	r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index)
 	r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index)
+	r.Get("/admin/stats", reqGrafanaAdmin, Index)
 
 
 	r.Get("/apps", reqSignedIn, Index)
 	r.Get("/apps", reqSignedIn, Index)
 	r.Get("/apps/edit/*", reqSignedIn, Index)
 	r.Get("/apps/edit/*", reqSignedIn, Index)
@@ -210,12 +211,13 @@ func Register(r *macaron.Macaron) {
 		r.Delete("/users/:id", AdminDeleteUser)
 		r.Delete("/users/:id", AdminDeleteUser)
 		r.Get("/users/:id/quotas", wrap(GetUserQuotas))
 		r.Get("/users/:id/quotas", wrap(GetUserQuotas))
 		r.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota))
 		r.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota))
+		r.Get("/stats", AdminGetStats)
 	}, reqGrafanaAdmin)
 	}, reqGrafanaAdmin)
 
 
 	// rendering
 	// rendering
 	r.Get("/render/*", reqSignedIn, RenderToPng)
 	r.Get("/render/*", reqSignedIn, RenderToPng)
 
 
-	InitApiPluginRoutes(r)
+	InitAppPluginRoutes(r)
 
 
 	r.NotFound(NotFoundHandler)
 	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 := NewReverseProxy(ds, proxyPath, targetUrl)
 		proxy.Transport = dataProxyTransport
 		proxy.Transport = dataProxyTransport
 		proxy.ServeHTTP(c.Resp, c.Req.Request)
 		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.Enabled = data.Enabled
 		dto.Pinned = data.Pinned
 		dto.Pinned = data.Pinned
 		dto.Info = &def.Info
 		dto.Info = &def.Info
+		dto.JsonData = data.JsonData
 	}
 	}
 
 
 	return dto
 	return dto

+ 3 - 39
pkg/api/playlist.go

@@ -1,11 +1,8 @@
 package api
 package api
 
 
 import (
 import (
-	"errors"
-	"strconv"
-
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/log"
+	_ "github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 )
 )
@@ -101,39 +98,6 @@ func LoadPlaylistItems(id int64) ([]m.PlaylistItem, error) {
 	return *itemQuery.Result, nil
 	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 {
 func GetPlaylistItems(c *middleware.Context) Response {
 	id := c.ParamsInt64(":id")
 	id := c.ParamsInt64(":id")
 
 
@@ -147,9 +111,9 @@ func GetPlaylistItems(c *middleware.Context) Response {
 }
 }
 
 
 func GetPlaylistDashboards(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 {
 	if err != nil {
 		return ApiError(500, "Could not load dashboards", err)
 		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.dashboards.count"] = statsQuery.Result.DashboardCount
 	metrics["stats.users.count"] = statsQuery.Result.UserCount
 	metrics["stats.users.count"] = statsQuery.Result.UserCount
 	metrics["stats.orgs.count"] = statsQuery.Result.OrgCount
 	metrics["stats.orgs.count"] = statsQuery.Result.OrgCount
+	metrics["stats.playlist.count"] = statsQuery.Result.PlaylistCount
 
 
 	dsStats := m.GetDataSourceStatsQuery{}
 	dsStats := m.GetDataSourceStatsQuery{}
 	if err := bus.Dispatch(&dsStats); err != nil {
 	if err := bus.Dispatch(&dsStats); err != nil {

+ 38 - 10
pkg/models/app_settings.go

@@ -1,27 +1,49 @@
 package models
 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 {
 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
 	Created time.Time
 	Updated 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
 // COMMANDS
 
 
 // Also acts as api DTO
 // Also acts as api DTO
 type UpdateAppSettingsCmd struct {
 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:"-"`
 	AppId string `json:"-"`
 	OrgId int64  `json:"-"`
 	OrgId int64  `json:"-"`
@@ -33,3 +55,9 @@ type GetAppSettingsQuery struct {
 	OrgId  int64
 	OrgId  int64
 	Result []*AppSettings
 	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
 	OrgId  int64
 	Result []*DashboardTagCloudItem
 	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:"-"`
 	OrgId    int64             `json:"-"`
 	Id       int64             `json:"id" binding:"Required"`
 	Id       int64             `json:"id" binding:"Required"`
 	Name     string            `json:"name" binding:"Required"`
 	Name     string            `json:"name" binding:"Required"`
-	Type     string            `json:"type"`
 	Interval string            `json:"interval"`
 	Interval string            `json:"interval"`
-	Data     []int64           `json:"data"`
 	Items    []PlaylistItemDTO `json:"items"`
 	Items    []PlaylistItemDTO `json:"items"`
 
 
 	Result *PlaylistDTO
 	Result *PlaylistDTO
@@ -86,9 +84,7 @@ type UpdatePlaylistCommand struct {
 
 
 type CreatePlaylistCommand struct {
 type CreatePlaylistCommand struct {
 	Name     string            `json:"name" binding:"Required"`
 	Name     string            `json:"name" binding:"Required"`
-	Type     string            `json:"type"`
 	Interval string            `json:"interval"`
 	Interval string            `json:"interval"`
-	Data     []int64           `json:"data"`
 	Items    []PlaylistItemDTO `json:"items"`
 	Items    []PlaylistItemDTO `json:"items"`
 
 
 	OrgId  int64 `json:"-"`
 	OrgId  int64 `json:"-"`
@@ -121,8 +117,3 @@ type GetPlaylistItemsByIdQuery struct {
 	PlaylistId int64
 	PlaylistId int64
 	Result     *[]PlaylistItem
 	Result     *[]PlaylistItem
 }
 }
-
-type GetPlaylistDashboardsQuery struct {
-	DashboardIds []int64
-	Result       *PlaylistDashboards
-}

+ 17 - 0
pkg/models/stats.go

@@ -4,6 +4,7 @@ type SystemStats struct {
 	DashboardCount int
 	DashboardCount int
 	UserCount      int
 	UserCount      int
 	OrgCount       int
 	OrgCount       int
+	PlaylistCount  int
 }
 }
 
 
 type DataSourceStats struct {
 type DataSourceStats struct {
@@ -18,3 +19,19 @@ type GetSystemStatsQuery struct {
 type GetDataSourceStatsQuery struct {
 type GetDataSourceStatsQuery struct {
 	Result []*DataSourceStats
 	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 {
 type AppPlugin struct {
 	FrontendPluginBase
 	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:"-"`
 	Pinned  bool `json:"-"`
 	Enabled 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 {
 func (app *AppPlugin) Load(decoder *json.Decoder, pluginDir string) error {
 	if err := decoder.Decode(&app); err != nil {
 	if err := decoder.Decode(&app); err != nil {
 		return err
 		return err

+ 0 - 18
pkg/plugins/models.go

@@ -2,8 +2,6 @@ package plugins
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
-
-	"github.com/grafana/grafana/pkg/models"
 )
 )
 
 
 type PluginLoader interface {
 type PluginLoader interface {
@@ -44,24 +42,9 @@ type PluginStaticRoute struct {
 	PluginId  string
 	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 {
 type EnabledPlugins struct {
 	Panels      []*PanelPlugin
 	Panels      []*PanelPlugin
 	DataSources map[string]*DataSourcePlugin
 	DataSources map[string]*DataSourcePlugin
-	ApiList     []*ApiPlugin
 	Apps        []*AppPlugin
 	Apps        []*AppPlugin
 }
 }
 
 
@@ -69,7 +52,6 @@ func NewEnabledPlugins() EnabledPlugins {
 	return EnabledPlugins{
 	return EnabledPlugins{
 		Panels:      make([]*PanelPlugin, 0),
 		Panels:      make([]*PanelPlugin, 0),
 		DataSources: make(map[string]*DataSourcePlugin),
 		DataSources: make(map[string]*DataSourcePlugin),
-		ApiList:     make([]*ApiPlugin, 0),
 		Apps:        make([]*AppPlugin, 0),
 		Apps:        make([]*AppPlugin, 0),
 	}
 	}
 }
 }

+ 0 - 3
pkg/plugins/plugins.go

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

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

@@ -5,10 +5,13 @@ import (
 
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
 func init() {
 func init() {
 	bus.AddHandler("sql", GetAppSettings)
 	bus.AddHandler("sql", GetAppSettings)
+	bus.AddHandler("sql", GetAppSettingByAppId)
 	bus.AddHandler("sql", UpdateAppSettings)
 	bus.AddHandler("sql", UpdateAppSettings)
 }
 }
 
 
@@ -19,6 +22,18 @@ func GetAppSettings(query *m.GetAppSettingsQuery) error {
 	return sess.Find(&query.Result)
 	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 {
 func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error {
 	return inTransaction2(func(sess *session) error {
 	return inTransaction2(func(sess *session) error {
 		var app m.AppSettings
 		var app m.AppSettings
@@ -27,18 +42,27 @@ func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error {
 		sess.UseBool("enabled")
 		sess.UseBool("enabled")
 		sess.UseBool("pinned")
 		sess.UseBool("pinned")
 		if !exists {
 		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{
 			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)
 			_, err = sess.Insert(&app)
 			return err
 			return err
 		} else {
 		} else {
+			for key, data := range cmd.SecureJsonData {
+				app.SecureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey)
+			}
 			app.Updated = time.Now()
 			app.Updated = time.Now()
 			app.Enabled = cmd.Enabled
 			app.Enabled = cmd.Enabled
 			app.JsonData = cmd.JsonData
 			app.JsonData = cmd.JsonData

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

@@ -14,6 +14,7 @@ import (
 func init() {
 func init() {
 	bus.AddHandler("sql", SaveDashboard)
 	bus.AddHandler("sql", SaveDashboard)
 	bus.AddHandler("sql", GetDashboard)
 	bus.AddHandler("sql", GetDashboard)
+	bus.AddHandler("sql", GetDashboards)
 	bus.AddHandler("sql", DeleteDashboard)
 	bus.AddHandler("sql", DeleteDashboard)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardTags)
@@ -223,3 +224,20 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 		return nil
 		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) {
 func addAppSettingsMigration(mg *Migrator) {
 
 
-	appSettingsV1 := Table{
+	appSettingsV2 := Table{
 		Name: "app_settings",
 		Name: "app_settings",
 		Columns: []*Column{
 		Columns: []*Column{
 			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
 			{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: "enabled", Type: DB_Bool, Nullable: false},
 			{Name: "pinned", Type: DB_Bool, Nullable: false},
 			{Name: "pinned", Type: DB_Bool, Nullable: false},
 			{Name: "json_data", Type: DB_Text, Nullable: true},
 			{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: "created", Type: DB_DateTime, Nullable: false},
 			{Name: "updated", 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 ------------------
 	//-------  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", DeletePlaylist)
 	bus.AddHandler("sql", SearchPlaylists)
 	bus.AddHandler("sql", SearchPlaylists)
 	bus.AddHandler("sql", GetPlaylist)
 	bus.AddHandler("sql", GetPlaylist)
-	bus.AddHandler("sql", GetPlaylistDashboards)
 	bus.AddHandler("sql", GetPlaylistItem)
 	bus.AddHandler("sql", GetPlaylistItem)
 }
 }
 
 
@@ -162,20 +161,3 @@ func GetPlaylistItem(query *m.GetPlaylistItemsByIdQuery) error {
 
 
 	return err
 	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() {
 func init() {
 	bus.AddHandler("sql", GetSystemStats)
 	bus.AddHandler("sql", GetSystemStats)
 	bus.AddHandler("sql", GetDataSourceStats)
 	bus.AddHandler("sql", GetDataSourceStats)
+	bus.AddHandler("sql", GetAdminStats)
 }
 }
 
 
 func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error {
 func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error {
@@ -34,7 +35,11 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
       (
       (
         SELECT COUNT(*)
         SELECT COUNT(*)
         FROM ` + dialect.Quote("dashboard") + `
         FROM ` + dialect.Quote("dashboard") + `
-      ) AS dashboard_count
+      ) AS dashboard_count,
+      (
+        SELECT COUNT(*)
+        FROM ` + dialect.Quote("playlist") + `
+      ) AS playlist_count
 			`
 			`
 
 
 	var stats m.SystemStats
 	var stats m.SystemStats
@@ -46,3 +51,54 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
 	query.Result = &stats
 	query.Result = &stats
 	return err
 	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 {
 func JoinUrlFragments(a, b string) string {
 	aslash := strings.HasSuffix(a, "/")
 	aslash := strings.HasSuffix(a, "/")
 	bslash := strings.HasPrefix(b, "/")
 	bslash := strings.HasPrefix(b, "/")
+
+	if len(b) == 0 {
+		return a
+	}
+
 	switch {
 	switch {
 	case aslash && bslash:
 	case aslash && bslash:
 		return a + b[1:]
 		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 => {
       scope.$watch('contextSrv.sidemenu', newVal => {
         if (newVal !== undefined) {
         if (newVal !== undefined) {
           elem.toggleClass('sidemenu-open', scope.contextSrv.sidemenu);
           elem.toggleClass('sidemenu-open', scope.contextSrv.sidemenu);
+          if (!newVal) {
+            scope.contextSrv.setPinnedState(false);
+          }
         }
         }
         if (scope.contextSrv.sidemenu) {
         if (scope.contextSrv.sidemenu) {
           ignoreSideMenuHide = true;
           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
       // tooltip removal fix
       scope.$on("$routeChangeSuccess", function() {
       scope.$on("$routeChangeSuccess", function() {
         $("#tooltip, .tooltip").remove();
         $("#tooltip, .tooltip").remove();
@@ -182,7 +191,7 @@ export function grafanaAppDirective(playlistSrv) {
           }
           }
         }
         }
         // hide sidemenu
         // 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) {
           if (target.parents('.sidemenu').length === 0) {
             scope.$apply(() => scope.contextSrv.toggleSideMenu());
             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="navbar-inner"><div class="container-fluid">
 			<div class="top-nav-btn top-nav-menu-btn">
 			<div class="top-nav-btn top-nav-menu-btn">
 				<a class="pointer" ng-click="ctrl.contextSrv.toggleSideMenu()">
 				<a class="pointer" ng-click="ctrl.contextSrv.toggleSideMenu()">
 					<span class="top-nav-logo-background">
 					<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>
 					</span>
+					<i class="icon-gf icon-gf-grafana_wordmark"></i>
 					<i class="fa fa-caret-down"></i>
 					<i class="fa fa-caret-down"></i>
 				</a>
 				</a>
 			</div>
 			</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">
 	<div class="search-field-wrapper">
 		<span style="position: relative;">
 		<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>
 		</span>
 		<div class="search-switches">
 		<div class="search-switches">
 			<i class="fa fa-filter"></i>
 			<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
 				starred
 			</a> |
 			</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
 				tags
 			</a>
 			</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>
 						<i class="fa fa-remove"></i>
 						{{tagName}}
 						{{tagName}}
 					</a>
 					</a>
@@ -27,12 +25,12 @@
 		</div>
 		</div>
 	</div>
 	</div>
 
 
-	<div class="search-results-container" ng-if="tagsMode">
+	<div class="search-results-container" ng-if="ctrl.tagsMode">
 		<div class="row">
 		<div class="row">
 			<div class="span6 offset1">
 			<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-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">
 					<a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
 						<i class="fa fa-tag"></i>
 						<i class="fa fa-tag"></i>
 						<span>{{tag.term}} &nbsp;({{tag.count}})</span>
 						<span>{{tag.term}} &nbsp;({{tag.count}})</span>
@@ -42,14 +40,14 @@
 		</div>
 		</div>
 	</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}}">
 			ng-class="{'selected': $index == selectedIndex}" ng-href="{{row.url}}">
 
 
 			<span class="search-result-tags">
 			<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}}
 					{{tag}}
 				</span>
 				</span>
 				<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
 				<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
@@ -63,15 +61,14 @@
 	</div>
 	</div>
 
 
 	<div class="search-button-row">
 	<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>
 			<i class="fa fa-plus"></i>
 			New
 			New
 		</button>
 		</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>
 			<i class="fa fa-download"></i>
 			Import
 			Import
 		</a>
 		</a>
 		<div class="clearfix"></div>
 		<div class="clearfix"></div>
 	</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>
 		</a>
 	</li>
 	</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>
 </ul>
 
 

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

@@ -22,8 +22,12 @@ export class SideMenuCtrl {
     this.appSubUrl = config.appSubUrl;
     this.appSubUrl = config.appSubUrl;
     this.showSignout = this.contextSrv.isSignedIn && !config['authProxyEnabled'];
     this.showSignout = this.contextSrv.isSignedIn && !config['authProxyEnabled'];
     this.updateMenu();
     this.updateMenu();
+
     this.$scope.$on('$routeChangeSuccess', () => {
     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);
            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({
    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",
      icon: "fa fa-fw fa-user",
      url: this.getUrl("/admin/users"),
      url: this.getUrl("/admin/users"),
    });
    });
 
 
    this.mainLinks.push({
    this.mainLinks.push({
-     text: "Global Orgs",
+     text: "Organizations",
      icon: "fa fa-fw fa-users",
      icon: "fa fa-fw fa-users",
      url: this.getUrl("/admin/orgs"),
      url: this.getUrl("/admin/orgs"),
    });
    });
+
  }
  }
 
 
  updateMenu() {
  updateMenu() {

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

@@ -1,5 +1,4 @@
 define([
 define([
-  './search_ctrl',
   './inspect_ctrl',
   './inspect_ctrl',
   './json_editor_ctrl',
   './json_editor_ctrl',
   './login_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/password_strenght";
 import "./directives/spectrum_picker";
 import "./directives/spectrum_picker";
 import "./directives/tags";
 import "./directives/tags";
-import "./directives/topnav";
 import "./directives/value_select_dropdown";
 import "./directives/value_select_dropdown";
 import "./directives/give_focus";
 import "./directives/give_focus";
 import './jquery_extended';
 import './jquery_extended';
@@ -23,6 +22,7 @@ import './partials';
 
 
 import {grafanaAppDirective} from './components/grafana_app';
 import {grafanaAppDirective} from './components/grafana_app';
 import {sideMenuDirective} from './components/sidemenu/sidemenu';
 import {sideMenuDirective} from './components/sidemenu/sidemenu';
+import {searchDirective} from './components/search/search';
 import {navbarDirective} from './components/navbar/navbar';
 import {navbarDirective} from './components/navbar/navbar';
 import {arrayJoin} from './directives/array_join';
 import {arrayJoin} from './directives/array_join';
 import 'app/core/controllers/all';
 import 'app/core/controllers/all';
@@ -31,4 +31,4 @@ import 'app/core/routes/all';
 import './filters/filters';
 import './filters/filters';
 import coreModule from './core_module';
 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',
         templateUrl: 'app/features/admin/partials/edit_org.html',
         controller : 'AdminEditOrgCtrl',
         controller : 'AdminEditOrgCtrl',
       })
       })
+      .when('/admin/stats', {
+        templateUrl: 'app/features/admin/partials/stats.html',
+        controller : 'AdminStatsCtrl',
+        controllerAs: 'ctrl',
+      })
       .when('/login', {
       .when('/login', {
         templateUrl: 'app/partials/login.html',
         templateUrl: 'app/partials/login.html',
         controller : 'LoginCtrl',
         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;
       return this.user.orgRole === role;
     };
     };
 
 
+    this.setPinnedState = function(val) {
+      this.pinned = val;
+      store.set('grafana.sidemenu.pinned', val);
+    };
+
     this.toggleSideMenu = function() {
     this.toggleSideMenu = function() {
       this.sidemenu = !this.sidemenu;
       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.version = config.buildInfo.version;
     this.lightTheme = false;
     this.lightTheme = false;
     this.user = new User();
     this.user = new User();

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

@@ -41,6 +41,7 @@ export default class TimeSeries {
   nullPointMode: any;
   nullPointMode: any;
   fillBelowTo: any;
   fillBelowTo: any;
   transform: any;
   transform: any;
+  flotpairs: any;
 
 
   constructor(opts) {
   constructor(opts) {
     this.datapoints = opts.datapoints;
     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,'-');
       .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) {
   kbn.stringToJsRegex = function(str) {
     if (str[0] !== '/') {
     if (str[0] !== '/') {
       return new RegExp('^' + str + '$');
       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',
   './adminEditOrgCtrl',
   './adminEditUserCtrl',
   './adminEditUserCtrl',
   './adminSettingsCtrl',
   './adminSettingsCtrl',
+  './adminStatsCtrl',
 ], function () {});
 ], 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">
 	<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>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page">
 	<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">
 	<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>
 		<li class="active"><a href="admin/users/edit/{{user_id}}">Edit user</a></li>
 	</ul>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page">
 	<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">
 	<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>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page">
 	<div class="page">
 		<h2>
 		<h2>
-			Create a new user
+			Add new user
 		</h2>
 		</h2>
 
 
 		<form name="userForm">
 		<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-container">
 	<div class="page-wide">
 	<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-container">
 	<div class="page">
 	<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-container">
 	<div class="page-wide">
 	<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">
     <table class="filter-table form-inline">
 			<thead>
 			<thead>

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

@@ -24,6 +24,7 @@ export class AppEditCtrl {
       enabled: this.appModel.enabled,
       enabled: this.appModel.enabled,
       pinned: this.appModel.pinned,
       pinned: this.appModel.pinned,
       jsonData: this.appModel.jsonData,
       jsonData: this.appModel.jsonData,
+      secureJsonData: this.appModel.secureJsonData,
     }, options);
     }, options);
 
 
     this.backendSrv.post(`/api/org/apps/${this.$routeParams.appId}/settings`, updateCmd).then(function() {
     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 class="simple-box-body">
 			<div ng-if="ctrl.appModel.appId">
 			<div ng-if="ctrl.appModel.appId">
 				<app-config-view app-model="ctrl.appModel"></app-config-view>
 				<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>
 		</div>
 		</div>
 	</section>
 	</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-container">
   <div class="page-wide" ng-init="ctrl.init()">
   <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 i, j, k;
       var oldVersion = this.schemaVersion;
       var oldVersion = this.schemaVersion;
       var panelUpgrades = [];
       var panelUpgrades = [];
-      this.schemaVersion = 8;
+      this.schemaVersion = 9;
 
 
-      if (oldVersion === 8) {
+      if (oldVersion === this.schemaVersion) {
         return;
         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) {
       if (panelUpgrades.length === 0) {
         return;
         return;
       }
       }

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

@@ -10,7 +10,7 @@
 
 
 <div class="top-nav-snapshot-title" ng-if="dashboardMeta.isSnapshot">
 <div class="top-nav-snapshot-title" ng-if="dashboardMeta.isSnapshot">
 	<a class="pointer" bs-tooltip="titleTooltip" data-placement="bottom">
 	<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">
 		<span class="dashboard-title">
 			{{dashboard.title}}
 			{{dashboard.title}}
 			<em class="small">&nbsp;&nbsp;(snapshot)</em>
 			<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>
 			<i class="fa" ng-class="{'fa-star-o': !dashboardMeta.isStarred, 'fa-star': dashboardMeta.isStarred}" style="color: orange;"></i>
 		</a>
 		</a>
 	</li>
 	</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>
 	<li ng-show="dashboardMeta.canSave">
 	<li ng-show="dashboardMeta.canSave">
 		<a ng-click="saveDashboard()" bs-tooltip="'Save dashboard'" data-placement="bottom"><i class="fa fa-save"></i></a>
 		<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', {
       $scope.appEvent('show-modal', {
         src: './app/features/dashboard/partials/shareModal.html',
         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;
               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);
             elem.append(view);
             $compile(elem.contents())(editorScope);
             $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">
 	<ul class="nav">
 		<li class="active"><a href="import/dashboard">Import</a></li>
 		<li class="active"><a href="import/dashboard">Import</a></li>
 	</ul>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page">
 	<div class="page">

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

@@ -25,7 +25,7 @@
 							Title
 							Title
 						</li>
 						</li>
 						<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>
 						<li class="tight-form-item">
 						<li class="tight-form-item">
 							Tags
 							Tags

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

@@ -89,7 +89,7 @@
 
 
 <script type="text/ng-template" id="shareLink.html">
 <script type="text/ng-template" id="shareLink.html">
 	<div class="share-modal-big-icon">
 	<div class="share-modal-big-icon">
-		<i class="fa fa-external-link"></i>
+		<i class="fa fa-link"></i>
 	</div>
 	</div>
 
 
 	<div ng-include src="'shareLinkOptions.html'"></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="ng-cloak" ng-cloak ng-controller="ShareSnapshotCtrl" ng-init="init()">
 		<div class="share-modal-big-icon">
 		<div class="share-modal-big-icon">
 			<i ng-if="loading" class="fa fa-spinner fa-spin"></i>
 			<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>
 
 
 		<div class="share-snapshot-header" ng-if="step === 1">
 		<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) {
   module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, $element, templateSrv, linkSrv) {
 
 
     $scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
     $scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
-    $scope.editor = { index: 0 };
+    $scope.editor = { index: $scope.tabIndex || 0};
 
 
     $scope.init = function() {
     $scope.init = function() {
       $scope.modeSharePanel = $scope.panel ? true : false;
       $scope.modeSharePanel = $scope.panel ? true : false;

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

@@ -7,6 +7,7 @@ export class SubmenuCtrl {
   variables: any;
   variables: any;
   dashboard: any;
   dashboard: any;
 
 
+  /** @ngInject */
   constructor(private $rootScope, private templateValuesSrv, private dynamicDashboardSrv) {
   constructor(private $rootScope, private templateValuesSrv, private dynamicDashboardSrv) {
     this.annotations = this.dashboard.templating.list;
     this.annotations = this.dashboard.templating.list;
     this.variables = 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="row pull-right">
-	<div class="gf-timepicker-absolute-section">
+	<form name="timeForm" class="gf-timepicker-absolute-section">
 		<h3>Time range</h3>
 		<h3>Time range</h3>
 		<label class="small">From:</label>
 		<label class="small">From:</label>
 		<div class="input-prepend">
 		<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 ng-model="ctrl.refresh.value" class='input-medium' ng-options="f.value as f.text for f in ctrl.refresh.options">
 		</select>
 		</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
 			Apply
 		</button>
 		</button>
-	</div>
+	</form>
 
 
 	<div class="gf-timepicker-relative-section">
 	<div class="gf-timepicker-relative-section">
 		<h3>Quick ranges</h3>
 		<h3>Quick ranges</h3>

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

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

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

@@ -159,7 +159,7 @@ function (angular, _) {
     };
     };
 
 
     updateDashLinks();
     updateDashLinks();
-    $rootScope.onAppEvent('dash-links-updated', updateDashLinks, $rootScope);
+    $rootScope.onAppEvent('dash-links-updated', updateDashLinks, $scope);
   });
   });
 
 
   module.controller('DashLinkEditorCtrl', function($scope, $rootScope) {
   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">
 	<ul class="nav">
 		<li ng-class="{active: isNew}" ng-show="isNew"><a href="datasources/new">Add new</a></li>
 		<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>
 		<li class="active" ng-show="!isNew"><a href="datasources/edit/{{current.name}}">{{current.name}}</a></li>
 	</ul>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page">
 	<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-container">
 	<div class="page-wide">
 	<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>
 			<i class="fa fa-plus"></i>
 			Add data source
 			Add data source
 		</a>
 		</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">
 	<ul class="nav">
 		<li class="active"><a href="org/new">New organization</a></li>
 		<li class="active"><a href="org/new">New organization</a></li>
 	</ul>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page">
 	<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">
 	<ul class="nav">
 		<li class="active"><a href="org/apikeys">API Keys</a></li>
 		<li class="active"><a href="org/apikeys">API Keys</a></li>
 	</ul>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page-wide">
 	<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">
 	<ul class="nav">
 		<li class="active"><a href="org">Preferences</a></li>
 		<li class="active"><a href="org">Preferences</a></li>
 	</ul>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page">
 	<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">
 	<ul class="nav">
 		<li class="active"><a href="org/users">Users</a></li>
 		<li class="active"><a href="org/users">Users</a></li>
 	</ul>
 	</ul>
-</topnav>
+</navbar>
 
 
 <div class="page-container">
 <div class="page-container">
 	<div class="page-wide">
 	<div class="page-wide">

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

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

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

@@ -1,5 +1,6 @@
 define([
 define([
   './playlists_ctrl',
   './playlists_ctrl',
+  './playlist_search',
   './playlist_srv',
   './playlist_srv',
   './playlist_edit_ctrl',
   './playlist_edit_ctrl',
   './playlist_routes'
   './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">
 <navbar title="Playlists" title-url="playlists" icon="fa fa-fw fa-list" subnav="true">
 	<ul class="nav">
 	<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>
 	</ul>
 </navbar>
 </navbar>
 
 
 <div class="page-container" ng-form="playlistEditForm">
 <div class="page-container" ng-form="playlistEditForm">
   <div class="page">
   <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>
     <h4>Name and interval</h4>
 
 
@@ -20,7 +20,7 @@
               Name
               Name
             </li>
             </li>
             <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>
             </li>
           </ul>
           </ul>
           <div class="clearfix"></div>
           <div class="clearfix"></div>
@@ -31,7 +31,7 @@
               Interval
               Interval
             </li>
             </li>
             <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>
             </li>
           </ul>
           </ul>
           <div class="clearfix"></div>
           <div class="clearfix"></div>
@@ -39,66 +39,72 @@
       </div>
       </div>
 
 
       <br>
       <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>
   </div>
 
 
   <div class="row">
   <div class="row">
     <div class="span5 pull-left">
     <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">
        <table class="grafana-options-table">
-        <tr ng-repeat="playlistItem in filteredPlaylistItems">
+        <tr ng-repeat="playlistItem in ctrl.filteredDashboards">
           <td style="white-space: nowrap;">
           <td style="white-space: nowrap;">
             {{playlistItem.title}}
             {{playlistItem.title}}
           </td>
           </td>
           <td style="text-align: center">
           <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>
               <i class="fa fa-plus"></i>
               Add to playlist
               Add to playlist
             </button>
             </button>
           </td>
           </td>
         </tr>
         </tr>
-        <tr ng-if="isSearchResultsEmpty()">
-          <td colspan="2">
-            <i class="fa fa-warning"></i> Search results empty
-          </td>
-        </tr>
       </table>
       </table>
     </div>
     </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">
     <div class="span5 pull-left">
       <h5>Added dashboards</h5>
       <h5>Added dashboards</h5>
       <table class="grafana-options-table">
       <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}}
             {{playlistItem.title}}
           </td>
           </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">
           <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>
               <i class="fa fa-arrow-up"></i>
             </button>
             </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>
               <i class="fa fa-arrow-down"></i>
             </button>
             </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>
               <i class="fa fa-remove"></i>
             </button>
             </button>
           </td>
           </td>
@@ -113,11 +119,11 @@
     <!-- <div class="tight-form"> -->
     <!-- <div class="tight-form"> -->
       <button type="button"
       <button type="button"
               class="btn btn-success"
               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"
       <button type="button"
               class="btn btn-inverse"
               class="btn btn-inverse"
-              ng-click="backToList()">Cancel</button>
+              ng-click="ctrl.backToList()">Cancel</button>
     <!-- </div> -->
     <!-- </div> -->
   </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>
         <th style="width: 25px"></th>
 
 
       </thead>
       </thead>
-      <tr ng-repeat="playlist in playlists">
+      <tr ng-repeat="playlist in ctrl.playlists">
         <td>
         <td>
 					<a href="playlists/edit/{{playlist.id}}">{{playlist.name}}</a>
 					<a href="playlists/edit/{{playlist.id}}">{{playlist.name}}</a>
         </td>
         </td>
@@ -39,7 +39,7 @@
           </a>
           </a>
         </td>
         </td>
         <td  class="text-right">
         <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>
             <i class="fa fa-remove"></i>
           </a>
           </a>
         </td>
         </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',
   'angular',
   'app/core/config',
   'app/core/config',
   'lodash'
   'lodash'
@@ -12,14 +12,17 @@ function (angular) {
     $routeProvider
     $routeProvider
       .when('/playlists', {
       .when('/playlists', {
         templateUrl: 'app/features/playlist/partials/playlists.html',
         templateUrl: 'app/features/playlist/partials/playlists.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistsCtrl'
         controller : 'PlaylistsCtrl'
       })
       })
       .when('/playlists/create', {
       .when('/playlists/create', {
         templateUrl: 'app/features/playlist/partials/playlist.html',
         templateUrl: 'app/features/playlist/partials/playlist.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistEditCtrl'
         controller : 'PlaylistEditCtrl'
       })
       })
       .when('/playlists/edit/:id', {
       .when('/playlists/edit/:id', {
         templateUrl: 'app/features/playlist/partials/playlist.html',
         templateUrl: 'app/features/playlist/partials/playlist.html',
+        controllerAs: 'ctrl',
         controller : 'PlaylistEditCtrl'
         controller : 'PlaylistEditCtrl'
       })
       })
       .when('/playlists/play/:id', {
       .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" />
 ///<reference path="../../headers/common.d.ts" />
 
 
 import angular from 'angular';
 import angular from 'angular';
+import config from 'app/core/config';
 import coreModule from '../../core/core_module';
 import coreModule from '../../core/core_module';
 import kbn from 'app/core/utils/kbn';
 import kbn from 'app/core/utils/kbn';
 
 
@@ -20,10 +21,9 @@ class PlaylistSrv {
     var playedAllDashboards = this.index > this.dashboards.length - 1;
     var playedAllDashboards = this.index > this.dashboards.length - 1;
 
 
     if (playedAllDashboards) {
     if (playedAllDashboards) {
-      this.start(this.playlistId);
+      window.location.href = `${config.appSubUrl}/playlists/play/${this.playlistId}`;
     } else {
     } else {
       var dash = this.dashboards[this.index];
       var dash = this.dashboards[this.index];
-
       this.$location.url('dashboard/' + dash.uri);
       this.$location.url('dashboard/' + dash.uri);
 
 
       this.index++;
       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);

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است