diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cb01152d58..225e1db96ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,19 +1,23 @@
# 3.0.0 (unrelased master branch)
-### New Features ###
+### New Features
* **Playlists**: Playlists can now be persisted and started from urls, closes [#3655](https://github.com/grafana/grafana/pull/3655)
* **Metadata**: Settings panel now shows dashboard metadata, closes [#3304](https://github.com/grafana/grafana/issues/3304)
* **InfluxDB**: Support for policy selection in query editor, closes [#2018](https://github.com/grafana/grafana/issues/2018)
### Breaking changes
-**Plugin API**: Both datasource and panel plugin api (and plugin.json schema) as been updated, requiring a minor update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) for more info.
-**InfluxDB 0.8.x** The data source for the old version of influxdb (0.8.x) is no longer included in default builds. Can easily be installed via improved plugin system, closes #3523
-**KairosDB** The data source is no longer included in default builds. Can easily be installed via improved plugin system, closes #3524
+* **Plugin API**: Both datasource and panel plugin api (and plugin.json schema) have been updated, requiring a minor update to plugins. See [plugin api](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) for more info.
+* **InfluxDB 0.8.x** The data source for the old version of influxdb (0.8.x) is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3523](https://github.com/grafana/grafana/issues/3523)
+* **KairosDB** The data source is no longer included in default builds, but can easily be installed via improved plugin system, closes [#3524](https://github.com/grafana/grafana/issues/3524)
-### Enhancements ###
+### Enhancements
* **Sessions**: Support for memcached as session storage, closes [#3458](https://github.com/grafana/grafana/pull/3458)
* **mysql**: Grafana now supports ssl for mysql, closes [#3584](https://github.com/grafana/grafana/pull/3584)
* **snapshot**: Annotations are now included in snapshots, closes [#3635](https://github.com/grafana/grafana/pull/3635)
+* **Admin**: Admin can now have global overview of Grafana setup, closes [#3812](https://github.com/grafana/grafana/issues/3812)
+
+### Bug fixes
+* **Playlist**: Fix for memory leak when running a playlist, closes [#3794](https://github.com/grafana/grafana/pull/3794)
# 2.6.1 (unrelased, 2.6.x branch)
diff --git a/circle.yml b/circle.yml
index bb22fcb0a6e..22dab8ab893 100644
--- a/circle.yml
+++ b/circle.yml
@@ -12,6 +12,8 @@ dependencies:
- mkdir -p ${GOPATH}/src/${ORG_PATH}
- ln -s ~/grafana ${GOPATH}/src/${ORG_PATH}
- go get github.com/tools/godep
+ - rm -rf node_modules
+ - npm install -g npm
- npm install
test:
@@ -25,3 +27,10 @@ test:
# js tests
- ./node_modules/grunt-cli/bin/grunt test
- npm run coveralls
+
+deployment:
+ master:
+ branch: master
+ owner: grafana
+ commands:
+ - ./trigger_grafana_packer.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN}
diff --git a/docker/blocks/elastic/elasticsearch/config/.placeholder b/docker/blocks/elastic/elasticsearch/config/.placeholder
new file mode 100644
index 00000000000..9ad266259c2
--- /dev/null
+++ b/docker/blocks/elastic/elasticsearch/config/.placeholder
@@ -0,0 +1 @@
+Ensure the existence of the parent folder.
diff --git a/docker/blocks/elastic/fig b/docker/blocks/elastic/fig
new file mode 100644
index 00000000000..498402ac7b0
--- /dev/null
+++ b/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"
diff --git a/docker/blocks/graphite/Dockerfile b/docker/blocks/graphite/Dockerfile
index 0c85798dd8a..aeac91b7a58 100644
--- a/docker/blocks/graphite/Dockerfile
+++ b/docker/blocks/graphite/Dockerfile
@@ -1,68 +1,50 @@
-from ubuntu:14.10
+from ubuntu:14.04
-run apt-get -y update
+run apt-get -y update
-run apt-get -y install software-properties-common
-
-run apt-get -y install python-software-properties &&\
- add-apt-repository ppa:chris-lea/node.js &&\
- apt-get -y update
-
-run apt-get -y install python-django-tagging python-simplejson python-memcache \
- python-ldap python-cairo python-django python-twisted \
- python-pysqlite2 python-support python-pip gunicorn \
- supervisor nginx-light nodejs git wget curl
-
-# Install statsd
-run mkdir /src && git clone https://github.com/etsy/statsd.git /src/statsd
+run apt-get -y install libcairo2-dev libffi-dev pkg-config python-dev python-pip fontconfig apache2 libapache2-mod-wsgi git-core collectd memcached gcc g++ make supervisor nginx-light gunicorn
run cd /usr/local/src && git clone https://github.com/graphite-project/graphite-web.git
run cd /usr/local/src && git clone https://github.com/graphite-project/carbon.git
run cd /usr/local/src && git clone https://github.com/graphite-project/whisper.git
run cd /usr/local/src/whisper && git checkout master && python setup.py install
-run cd /usr/local/src/carbon && git checkout 0.9.x && python setup.py install
-run cd /usr/local/src/graphite-web && git checkout 0.9.x && python check-dependencies.py; python setup.py install
-
-# statsd
-add ./files/statsd_config.js /src/statsd/config.js
+run cd /usr/local/src/carbon && git checkout 0.9.x && pip install -r requirements.txt; python setup.py install
+run cd /usr/local/src/graphite-web && git checkout 0.9.x && pip install -r requirements.txt; python check-dependencies.py; python setup.py install
# Add graphite config
-add ./files/initial_data.json /opt/graphite/webapp/graphite/initial_data.json
-add ./files/local_settings.py /opt/graphite/webapp/graphite/local_settings.py
-add ./files/carbon.conf /opt/graphite/conf/carbon.conf
-add ./files/storage-schemas.conf /opt/graphite/conf/storage-schemas.conf
-add ./files/storage-aggregation.conf /opt/graphite/conf/storage-aggregation.conf
-add ./files/events_views.py /opt/graphite/webapp/graphite/events/views.py
+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
+run mkdir -p /opt/graphite/storage/whisper
+run touch /opt/graphite/storage/graphite.db /opt/graphite/storage/index
+run chown -R www-data /opt/graphite/storage
+run chmod 0775 /opt/graphite/storage /opt/graphite/storage/whisper
+run chmod 0664 /opt/graphite/storage/graphite.db
+run cd /opt/graphite/webapp/graphite && python manage.py syncdb --noinput
+
+add ./files/my_htpasswd /etc/nginx/.htpasswd
# Add system service config
-add ./files/nginx.conf /etc/nginx/nginx.conf
-add ./files/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
-
+add ./files/nginx.conf /etc/nginx/nginx.conf
+add ./files/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
+# Nginx
+#
# graphite
-expose 80
+expose 80
# Carbon line receiver port
-expose 2003
+expose 2003
# Carbon cache query port
-expose 7002
+expose 7002
-# Statsd UDP port
-expose 8125/udp
-# Statsd Management port
-expose 8126
-
-VOLUME ["/var/lib/elasticsearch"]
VOLUME ["/opt/graphite/storage/whisper"]
VOLUME ["/var/lib/log/supervisor"]
-cmd ["/usr/bin/supervisord"]
+cmd ["/usr/bin/supervisord"]
# vim:ts=8:noet:
diff --git a/docker/blocks/graphite/fig b/docker/blocks/graphite/fig
index 28e7d3c53a2..84da45341e1 100644
--- a/docker/blocks/graphite/fig
+++ b/docker/blocks/graphite/fig
@@ -1,4 +1,10 @@
graphite:
build: blocks/graphite
ports:
- - "8776:80"
+ - "8080:80"
+ - "2003:2003"
+ volumes:
+ - /var/docker/gfdev/graphite:/opt/graphite/storage/whisper
+ - /etc/localtime:/etc/localtime:ro
+ - /etc/timezone:/etc/timezone:ro
+
diff --git a/docker/blocks/graphite/files/my_htpasswd b/docker/blocks/graphite/files/my_htpasswd
new file mode 100644
index 00000000000..52a72d01b4c
--- /dev/null
+++ b/docker/blocks/graphite/files/my_htpasswd
@@ -0,0 +1 @@
+grafana:$apr1$4R/20xhC$8t37jPP5dbcLr48btdkU//
diff --git a/docker/blocks/graphite/files/supervisord.conf b/docker/blocks/graphite/files/supervisord.conf
index 25ba39c8819..c9812bb16dc 100644
--- a/docker/blocks/graphite/files/supervisord.conf
+++ b/docker/blocks/graphite/files/supervisord.conf
@@ -24,10 +24,3 @@ stdout_logfile = /var/log/supervisor/%(program_name)s.log
stderr_logfile = /var/log/supervisor/%(program_name)s.log
autorestart = true
-[program:statsd]
-;user = www-data
-command = /usr/bin/node /src/statsd/stats.js /src/statsd/config.js
-stdout_logfile = /var/log/supervisor/%(program_name)s.log
-stderr_logfile = /var/log/supervisor/%(program_name)s.log
-autorestart = true
-
diff --git a/docker/blocks/influxdb/Dockerfile b/docker/blocks/influxdb/Dockerfile
deleted file mode 100644
index 69d10992464..00000000000
--- a/docker/blocks/influxdb/Dockerfile
+++ /dev/null
@@ -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"]
diff --git a/docker/blocks/influxdb/fig b/docker/blocks/influxdb/fig
index 3f247f756e2..931f8a2640a 100644
--- a/docker/blocks/influxdb/fig
+++ b/docker/blocks/influxdb/fig
@@ -1,5 +1,5 @@
influxdb:
- build: blocks/influxdb
+ image: tutum/influxdb:latest
ports:
- "2004:2004"
- "8083:8083"
diff --git a/docs/sources/datasources/prometheus.md b/docs/sources/datasources/prometheus.md
index 61f6c0d66e8..a7959a41c51 100644
--- a/docs/sources/datasources/prometheus.md
+++ b/docs/sources/datasources/prometheus.md
@@ -51,6 +51,8 @@ Name | Description
For details of `metric names` & `label names`, and `label values`, please refer to the [Prometheus documentation](http://prometheus.io/docs/concepts/data_model/#metric-names-and-labels).
+> Note: The part of queries is incompatible with the version before 2.6, if you specify like `foo.*`, please change like `metrics(foo.*)`.
+
You can create a template variable in Grafana and have that variable filled with values from any Prometheus metric exploration query.
You can then use this variable in your Prometheus metric queries.
diff --git a/docs/sources/reference/http_api.md b/docs/sources/reference/http_api.md
index 7cdd8a872d5..4b0dff6c4b6 100644
--- a/docs/sources/reference/http_api.md
+++ b/docs/sources/reference/http_api.md
@@ -1422,6 +1422,34 @@ Keys:
}
}
+### Grafana Stats
+
+`GET /api/admin/stats`
+
+**Example Request**:
+
+ GET /api/admin/stats
+ Accept: application/json
+ Content-Type: application/json
+ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+**Example Response**:
+
+ HTTP/1.1 200
+ Content-Type: application/json
+
+ {
+ "user_count":2,
+ "org_count":1,
+ "dashboard_count":4,
+ "db_snapshot_count":2,
+ "db_tag_count":6,
+ "data_source_count":1,
+ "playlist_count":1,
+ "starred_db_count":2,
+ "grafana_admin_count":2
+ }
+
### Global Users
`POST /api/admin/users`
diff --git a/docs/sources/reference/playlist.md b/docs/sources/reference/playlist.md
index 3e90f7361a3..5d546b1a3df 100644
--- a/docs/sources/reference/playlist.md
+++ b/docs/sources/reference/playlist.md
@@ -18,11 +18,11 @@ The Playlist feature can be accessed from Grafana's sidemenu. Click the 'Playlis
Click on "New Playlist" button to create a new playlist. Firstly, name your playlist and configure a time interval for Grafana to wait on a particular Dashboard before advancing to the next one on the Playlist.
-You can search Dashboards by name (or use a regular expression), and add them to your Playlist. By default, your starred dashboards will appear as candidates for the Playlist.
+You can search Dashboards by name (or use a regular expression), and add them to your Playlist. Or you could add tags which will include all the dashboards that belongs to a tag when the playlist start playing. By default, your starred dashboards will appear as candidates for the Playlist.
Be sure to click the "Add to dashboard" button next to the Dashboard name to add it to the Playlist. To remove a dashboard from the playlist click on "Remove[x]" button from the playlist.
-Since the Playlist is basically a list of Dashboards, ensure that all the Dashboards you want to appear in your Playlist are added here.
+Since the Playlist is basically a list of Dashboards, ensure that all the Dashboards you want to appear in your Playlist are added here.
## Saving the playlist
diff --git a/docs/sources/reference/singlestat.md b/docs/sources/reference/singlestat.md
index 91724e89882..954bfeed8b7 100644
--- a/docs/sources/reference/singlestat.md
+++ b/docs/sources/reference/singlestat.md
@@ -31,7 +31,7 @@ The coloring options of the Singlestat Panel config allow you to dynamically cha
1. `Background`: This checkbox applies the configured thresholds and colors to the entirety of the Singlestat Panel background.
2. `Value`: This checkbox applies the configured thresholds and colors to the summary stat.
-3. `Thresholds`: Change the background and value colors dynamically within the panel, depending on the Singlestat value. The threshold field accepts **3 comma-separated** values, corresponding to the three colors directly to the right.
+3. `Thresholds`: Change the background and value colors dynamically within the panel, depending on the Singlestat value. The threshold field accepts **2 comma-separated** values which represent 3 ranges that correspond to the three colors directly to the right. For example: if the thresholds are 70, 90 then the first color represents < 70, the second color represents between 70 and 90 and the third color represents > 90.
4. `Colors`: Select a color and opacity
5. `Invert order`: This link toggles the threshold color order.For example: Green, Orange, Red (
) will become Red, Orange, Green (
).
diff --git a/pkg/api/admin_settings.go b/pkg/api/admin.go
similarity index 66%
rename from pkg/api/admin_settings.go
rename to pkg/api/admin.go
index 1f800cfe558..d7f5a240416 100644
--- a/pkg/api/admin_settings.go
+++ b/pkg/api/admin.go
@@ -3,7 +3,9 @@ package api
import (
"strings"
+ "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
+ m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
)
@@ -27,3 +29,15 @@ func AdminGetSettings(c *middleware.Context) {
c.JSON(200, settings)
}
+
+func AdminGetStats(c *middleware.Context) {
+
+ statsQuery := m.GetAdminStatsQuery{}
+
+ if err := bus.Dispatch(&statsQuery); err != nil {
+ c.JsonApiErr(500, "Failed to get admin stats from database", err)
+ return
+ }
+
+ c.JSON(200, statsQuery.Result)
+}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index ea434ed71da..99ab8a51ff2 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -40,6 +40,7 @@ func Register(r *macaron.Macaron) {
r.Get("/admin/users/edit/:id", reqGrafanaAdmin, Index)
r.Get("/admin/orgs", reqGrafanaAdmin, Index)
r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index)
+ r.Get("/admin/stats", reqGrafanaAdmin, Index)
r.Get("/apps", reqSignedIn, Index)
r.Get("/apps/edit/*", reqSignedIn, Index)
@@ -210,12 +211,13 @@ func Register(r *macaron.Macaron) {
r.Delete("/users/:id", AdminDeleteUser)
r.Get("/users/:id/quotas", wrap(GetUserQuotas))
r.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota))
+ r.Get("/stats", AdminGetStats)
}, reqGrafanaAdmin)
// rendering
r.Get("/render/*", reqSignedIn, RenderToPng)
- InitApiPluginRoutes(r)
+ InitAppPluginRoutes(r)
r.NotFound(NotFoundHandler)
}
diff --git a/pkg/api/api_plugin.go b/pkg/api/api_plugin.go
deleted file mode 100644
index b14afec3a62..00000000000
--- a/pkg/api/api_plugin.go
+++ /dev/null
@@ -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}
-}
diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go
new file mode 100644
index 00000000000..169c5c6d15c
--- /dev/null
+++ b/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}
+}
diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go
index 77742a7a9f3..4d4556c40cb 100644
--- a/pkg/api/dataproxy.go
+++ b/pkg/api/dataproxy.go
@@ -103,5 +103,6 @@ func ProxyDataSourceRequest(c *middleware.Context) {
proxy := NewReverseProxy(ds, proxyPath, targetUrl)
proxy.Transport = dataProxyTransport
proxy.ServeHTTP(c.Resp, c.Req.Request)
+ c.Resp.Header().Del("Set-Cookie")
}
}
diff --git a/pkg/api/dtos/apps.go b/pkg/api/dtos/apps.go
index b2b33370135..d10883a26d7 100644
--- a/pkg/api/dtos/apps.go
+++ b/pkg/api/dtos/apps.go
@@ -31,6 +31,7 @@ func NewAppSettingsDto(def *plugins.AppPlugin, data *models.AppSettings) *AppSet
dto.Enabled = data.Enabled
dto.Pinned = data.Pinned
dto.Info = &def.Info
+ dto.JsonData = data.JsonData
}
return dto
diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go
index 0b017d6e2cb..abc092b8021 100644
--- a/pkg/api/playlist.go
+++ b/pkg/api/playlist.go
@@ -1,11 +1,8 @@
package api
import (
- "errors"
- "strconv"
-
"github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/log"
+ _ "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
)
@@ -101,39 +98,6 @@ func LoadPlaylistItems(id int64) ([]m.PlaylistItem, error) {
return *itemQuery.Result, nil
}
-func LoadPlaylistDashboards(id int64) ([]m.PlaylistDashboardDto, error) {
- playlistItems, _ := LoadPlaylistItems(id)
-
- dashboardIds := make([]int64, 0)
-
- for _, i := range playlistItems {
- dashboardId, _ := strconv.ParseInt(i.Value, 10, 64)
- dashboardIds = append(dashboardIds, dashboardId)
- }
-
- if len(dashboardIds) == 0 {
- return make([]m.PlaylistDashboardDto, 0), nil
- }
-
- dashboardQuery := m.GetPlaylistDashboardsQuery{DashboardIds: dashboardIds}
- if err := bus.Dispatch(&dashboardQuery); err != nil {
- log.Warn("dashboardquery failed: %v", err)
- return nil, errors.New("Playlist not found")
- }
-
- dtos := make([]m.PlaylistDashboardDto, 0)
- for _, item := range *dashboardQuery.Result {
- dtos = append(dtos, m.PlaylistDashboardDto{
- Id: item.Id,
- Slug: item.Slug,
- Title: item.Title,
- Uri: "db/" + item.Slug,
- })
- }
-
- return dtos, nil
-}
-
func GetPlaylistItems(c *middleware.Context) Response {
id := c.ParamsInt64(":id")
@@ -147,9 +111,9 @@ func GetPlaylistItems(c *middleware.Context) Response {
}
func GetPlaylistDashboards(c *middleware.Context) Response {
- id := c.ParamsInt64(":id")
+ playlistId := c.ParamsInt64(":id")
- playlists, err := LoadPlaylistDashboards(id)
+ playlists, err := LoadPlaylistDashboards(c.OrgId, c.UserId, playlistId)
if err != nil {
return ApiError(500, "Could not load dashboards", err)
}
diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go
new file mode 100644
index 00000000000..9bfcf1532fa
--- /dev/null
+++ b/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
+}
diff --git a/pkg/metrics/report_usage.go b/pkg/metrics/report_usage.go
index abda18d12b7..c5c2afed7f7 100644
--- a/pkg/metrics/report_usage.go
+++ b/pkg/metrics/report_usage.go
@@ -55,6 +55,7 @@ func sendUsageStats() {
metrics["stats.dashboards.count"] = statsQuery.Result.DashboardCount
metrics["stats.users.count"] = statsQuery.Result.UserCount
metrics["stats.orgs.count"] = statsQuery.Result.OrgCount
+ metrics["stats.playlist.count"] = statsQuery.Result.PlaylistCount
dsStats := m.GetDataSourceStatsQuery{}
if err := bus.Dispatch(&dsStats); err != nil {
diff --git a/pkg/models/app_settings.go b/pkg/models/app_settings.go
index 558946d0277..78d4c483f2b 100644
--- a/pkg/models/app_settings.go
+++ b/pkg/models/app_settings.go
@@ -1,27 +1,49 @@
package models
-import "time"
+import (
+ "errors"
+ "time"
+
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+var (
+ ErrAppSettingNotFound = errors.New("AppSetting not found")
+)
type AppSettings struct {
- Id int64
- AppId string
- OrgId int64
- Enabled bool
- Pinned bool
- JsonData map[string]interface{}
+ Id int64
+ AppId string
+ OrgId int64
+ Enabled bool
+ Pinned bool
+ JsonData map[string]interface{}
+ SecureJsonData SecureJsonData
Created time.Time
Updated time.Time
}
+type SecureJsonData map[string][]byte
+
+func (s SecureJsonData) Decrypt() map[string]string {
+ decrypted := make(map[string]string)
+ for key, data := range s {
+ decrypted[key] = string(util.Decrypt(data, setting.SecretKey))
+ }
+ return decrypted
+}
+
// ----------------------
// COMMANDS
// Also acts as api DTO
type UpdateAppSettingsCmd struct {
- Enabled bool `json:"enabled"`
- Pinned bool `json:"pinned"`
- JsonData map[string]interface{} `json:"jsonData"`
+ Enabled bool `json:"enabled"`
+ Pinned bool `json:"pinned"`
+ JsonData map[string]interface{} `json:"jsonData"`
+ SecureJsonData map[string]string `json:"secureJsonData"`
AppId string `json:"-"`
OrgId int64 `json:"-"`
@@ -33,3 +55,9 @@ type GetAppSettingsQuery struct {
OrgId int64
Result []*AppSettings
}
+
+type GetAppSettingByAppIdQuery struct {
+ AppId string
+ OrgId int64
+ Result *AppSettings
+}
diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go
index ddf5dd244f5..63ed1f5c006 100644
--- a/pkg/models/dashboards.go
+++ b/pkg/models/dashboards.go
@@ -146,3 +146,8 @@ type GetDashboardTagsQuery struct {
OrgId int64
Result []*DashboardTagCloudItem
}
+
+type GetDashboardsQuery struct {
+ DashboardIds []int64
+ Result *[]Dashboard
+}
diff --git a/pkg/models/playlist.go b/pkg/models/playlist.go
index 4c37c369b3e..4c6eacbb6a6 100644
--- a/pkg/models/playlist.go
+++ b/pkg/models/playlist.go
@@ -76,9 +76,7 @@ type UpdatePlaylistCommand struct {
OrgId int64 `json:"-"`
Id int64 `json:"id" binding:"Required"`
Name string `json:"name" binding:"Required"`
- Type string `json:"type"`
Interval string `json:"interval"`
- Data []int64 `json:"data"`
Items []PlaylistItemDTO `json:"items"`
Result *PlaylistDTO
@@ -86,9 +84,7 @@ type UpdatePlaylistCommand struct {
type CreatePlaylistCommand struct {
Name string `json:"name" binding:"Required"`
- Type string `json:"type"`
Interval string `json:"interval"`
- Data []int64 `json:"data"`
Items []PlaylistItemDTO `json:"items"`
OrgId int64 `json:"-"`
@@ -121,8 +117,3 @@ type GetPlaylistItemsByIdQuery struct {
PlaylistId int64
Result *[]PlaylistItem
}
-
-type GetPlaylistDashboardsQuery struct {
- DashboardIds []int64
- Result *PlaylistDashboards
-}
diff --git a/pkg/models/stats.go b/pkg/models/stats.go
index 6a060137ac7..63f946b956b 100644
--- a/pkg/models/stats.go
+++ b/pkg/models/stats.go
@@ -4,6 +4,7 @@ type SystemStats struct {
DashboardCount int
UserCount int
OrgCount int
+ PlaylistCount int
}
type DataSourceStats struct {
@@ -18,3 +19,19 @@ type GetSystemStatsQuery struct {
type GetDataSourceStatsQuery struct {
Result []*DataSourceStats
}
+
+type AdminStats struct {
+ UserCount int `json:"user_count"`
+ OrgCount int `json:"org_count"`
+ DashboardCount int `json:"dashboard_count"`
+ DbSnapshotCount int `json:"db_snapshot_count"`
+ DbTagCount int `json:"db_tag_count"`
+ DataSourceCount int `json:"data_source_count"`
+ PlaylistCount int `json:"playlist_count"`
+ StarredDbCount int `json:"starred_db_count"`
+ GrafanaAdminCount int `json:"grafana_admin_count"`
+}
+
+type GetAdminStatsQuery struct {
+ Result *AdminStats
+}
diff --git a/pkg/plugins/app_plugin.go b/pkg/plugins/app_plugin.go
index 898065be8a2..547cd91283c 100644
--- a/pkg/plugins/app_plugin.go
+++ b/pkg/plugins/app_plugin.go
@@ -26,14 +26,30 @@ type AppIncludeInfo struct {
type AppPlugin struct {
FrontendPluginBase
- Css *AppPluginCss `json:"css"`
- Pages []AppPluginPage `json:"pages"`
- Includes []AppIncludeInfo `json:"-"`
+ Css *AppPluginCss `json:"css"`
+ Pages []AppPluginPage `json:"pages"`
+ Routes []*AppPluginRoute `json:"routes"`
+ Includes []AppIncludeInfo `json:"-"`
Pinned bool `json:"-"`
Enabled bool `json:"-"`
}
+type AppPluginRoute struct {
+ Path string `json:"path"`
+ Method string `json:"method"`
+ ReqSignedIn bool `json:"reqSignedIn"`
+ ReqGrafanaAdmin bool `json:"reqGrafanaAdmin"`
+ ReqRole models.RoleType `json:"reqRole"`
+ Url string `json:"url"`
+ Headers []AppPluginRouteHeader `json:"headers"`
+}
+
+type AppPluginRouteHeader struct {
+ Name string `json:"name"`
+ Content string `json:"content"`
+}
+
func (app *AppPlugin) Load(decoder *json.Decoder, pluginDir string) error {
if err := decoder.Decode(&app); err != nil {
return err
diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go
index 375bdf1a52c..55ba8f97767 100644
--- a/pkg/plugins/models.go
+++ b/pkg/plugins/models.go
@@ -2,8 +2,6 @@ package plugins
import (
"encoding/json"
-
- "github.com/grafana/grafana/pkg/models"
)
type PluginLoader interface {
@@ -44,24 +42,9 @@ type PluginStaticRoute struct {
PluginId string
}
-type ApiPluginRoute struct {
- Path string `json:"path"`
- Method string `json:"method"`
- ReqSignedIn bool `json:"reqSignedIn"`
- ReqGrafanaAdmin bool `json:"reqGrafanaAdmin"`
- ReqRole models.RoleType `json:"reqRole"`
- Url string `json:"url"`
-}
-
-type ApiPlugin struct {
- PluginBase
- Routes []*ApiPluginRoute `json:"routes"`
-}
-
type EnabledPlugins struct {
Panels []*PanelPlugin
DataSources map[string]*DataSourcePlugin
- ApiList []*ApiPlugin
Apps []*AppPlugin
}
@@ -69,7 +52,6 @@ func NewEnabledPlugins() EnabledPlugins {
return EnabledPlugins{
Panels: make([]*PanelPlugin, 0),
DataSources: make(map[string]*DataSourcePlugin),
- ApiList: make([]*ApiPlugin, 0),
Apps: make([]*AppPlugin, 0),
}
}
diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go
index d6d390d93c0..5878a25a2da 100644
--- a/pkg/plugins/plugins.go
+++ b/pkg/plugins/plugins.go
@@ -17,7 +17,6 @@ import (
var (
DataSources map[string]*DataSourcePlugin
Panels map[string]*PanelPlugin
- ApiPlugins map[string]*ApiPlugin
StaticRoutes []*PluginStaticRoute
Apps map[string]*AppPlugin
PluginTypes map[string]interface{}
@@ -30,14 +29,12 @@ type PluginScanner struct {
func Init() error {
DataSources = make(map[string]*DataSourcePlugin)
- ApiPlugins = make(map[string]*ApiPlugin)
StaticRoutes = make([]*PluginStaticRoute, 0)
Panels = make(map[string]*PanelPlugin)
Apps = make(map[string]*AppPlugin)
PluginTypes = map[string]interface{}{
"panel": PanelPlugin{},
"datasource": DataSourcePlugin{},
- "api": ApiPlugin{},
"app": AppPlugin{},
}
diff --git a/pkg/plugins/queries.go b/pkg/plugins/queries.go
index 58e7d865c63..8e628c5024e 100644
--- a/pkg/plugins/queries.go
+++ b/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
}
diff --git a/pkg/services/sqlstore/app_settings.go b/pkg/services/sqlstore/app_settings.go
index e9bfbeaa73b..f454d2cc5ff 100644
--- a/pkg/services/sqlstore/app_settings.go
+++ b/pkg/services/sqlstore/app_settings.go
@@ -5,10 +5,13 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
)
func init() {
bus.AddHandler("sql", GetAppSettings)
+ bus.AddHandler("sql", GetAppSettingByAppId)
bus.AddHandler("sql", UpdateAppSettings)
}
@@ -19,6 +22,18 @@ func GetAppSettings(query *m.GetAppSettingsQuery) error {
return sess.Find(&query.Result)
}
+func GetAppSettingByAppId(query *m.GetAppSettingByAppIdQuery) error {
+ appSetting := m.AppSettings{OrgId: query.OrgId, AppId: query.AppId}
+ has, err := x.Get(&appSetting)
+ if err != nil {
+ return err
+ } else if has == false {
+ return m.ErrAppSettingNotFound
+ }
+ query.Result = &appSetting
+ return nil
+}
+
func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error {
return inTransaction2(func(sess *session) error {
var app m.AppSettings
@@ -27,18 +42,27 @@ func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error {
sess.UseBool("enabled")
sess.UseBool("pinned")
if !exists {
+ // encrypt secureJsonData
+ secureJsonData := make(map[string][]byte)
+ for key, data := range cmd.SecureJsonData {
+ secureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey)
+ }
app = m.AppSettings{
- AppId: cmd.AppId,
- OrgId: cmd.OrgId,
- Enabled: cmd.Enabled,
- Pinned: cmd.Pinned,
- JsonData: cmd.JsonData,
- Created: time.Now(),
- Updated: time.Now(),
+ AppId: cmd.AppId,
+ OrgId: cmd.OrgId,
+ Enabled: cmd.Enabled,
+ Pinned: cmd.Pinned,
+ JsonData: cmd.JsonData,
+ SecureJsonData: secureJsonData,
+ Created: time.Now(),
+ Updated: time.Now(),
}
_, err = sess.Insert(&app)
return err
} else {
+ for key, data := range cmd.SecureJsonData {
+ app.SecureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey)
+ }
app.Updated = time.Now()
app.Enabled = cmd.Enabled
app.JsonData = cmd.JsonData
diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go
index bbec541589c..2a8ff2dc941 100644
--- a/pkg/services/sqlstore/dashboard.go
+++ b/pkg/services/sqlstore/dashboard.go
@@ -14,6 +14,7 @@ import (
func init() {
bus.AddHandler("sql", SaveDashboard)
bus.AddHandler("sql", GetDashboard)
+ bus.AddHandler("sql", GetDashboards)
bus.AddHandler("sql", DeleteDashboard)
bus.AddHandler("sql", SearchDashboards)
bus.AddHandler("sql", GetDashboardTags)
@@ -223,3 +224,20 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
return nil
})
}
+
+func GetDashboards(query *m.GetDashboardsQuery) error {
+ if len(query.DashboardIds) == 0 {
+ return m.ErrCommandValidationFailed
+ }
+
+ var dashboards = make([]m.Dashboard, 0)
+
+ err := x.In("id", query.DashboardIds).Find(&dashboards)
+ query.Result = &dashboards
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/pkg/services/sqlstore/migrations/app_settings.go b/pkg/services/sqlstore/migrations/app_settings.go
index 437debbe95b..885dbbf9f05 100644
--- a/pkg/services/sqlstore/migrations/app_settings.go
+++ b/pkg/services/sqlstore/migrations/app_settings.go
@@ -4,7 +4,7 @@ import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func addAppSettingsMigration(mg *Migrator) {
- appSettingsV1 := Table{
+ appSettingsV2 := Table{
Name: "app_settings",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
@@ -13,6 +13,7 @@ func addAppSettingsMigration(mg *Migrator) {
{Name: "enabled", Type: DB_Bool, Nullable: false},
{Name: "pinned", Type: DB_Bool, Nullable: false},
{Name: "json_data", Type: DB_Text, Nullable: true},
+ {Name: "secure_json_data", Type: DB_Text, Nullable: true},
{Name: "created", Type: DB_DateTime, Nullable: false},
{Name: "updated", Type: DB_DateTime, Nullable: false},
},
@@ -21,8 +22,10 @@ func addAppSettingsMigration(mg *Migrator) {
},
}
- mg.AddMigration("create app_settings table v1", NewAddTableMigration(appSettingsV1))
+ mg.AddMigration("Drop old table app_settings v1", NewDropTableMigration("app_settings"))
+
+ mg.AddMigration("create app_settings table v2", NewAddTableMigration(appSettingsV2))
//------- indexes ------------------
- addTableIndicesMigrations(mg, "v3", appSettingsV1)
+ addTableIndicesMigrations(mg, "v3", appSettingsV2)
}
diff --git a/pkg/services/sqlstore/playlist.go b/pkg/services/sqlstore/playlist.go
index ee0b3b950c7..56fae9d3feb 100644
--- a/pkg/services/sqlstore/playlist.go
+++ b/pkg/services/sqlstore/playlist.go
@@ -15,7 +15,6 @@ func init() {
bus.AddHandler("sql", DeletePlaylist)
bus.AddHandler("sql", SearchPlaylists)
bus.AddHandler("sql", GetPlaylist)
- bus.AddHandler("sql", GetPlaylistDashboards)
bus.AddHandler("sql", GetPlaylistItem)
}
@@ -162,20 +161,3 @@ func GetPlaylistItem(query *m.GetPlaylistItemsByIdQuery) error {
return err
}
-
-func GetPlaylistDashboards(query *m.GetPlaylistDashboardsQuery) error {
- if len(query.DashboardIds) == 0 {
- return m.ErrCommandValidationFailed
- }
-
- var dashboards = make(m.PlaylistDashboards, 0)
-
- err := x.In("id", query.DashboardIds).Find(&dashboards)
- query.Result = &dashboards
-
- if err != nil {
- return err
- }
-
- return nil
-}
diff --git a/pkg/services/sqlstore/playlist_test.go b/pkg/services/sqlstore/playlist_test.go
new file mode 100644
index 00000000000..ba530f74fcf
--- /dev/null
+++ b/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)
+ })
+ })
+ })
+ })
+}
diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go
index 044aa185f19..92efab2015d 100644
--- a/pkg/services/sqlstore/stats.go
+++ b/pkg/services/sqlstore/stats.go
@@ -8,6 +8,7 @@ import (
func init() {
bus.AddHandler("sql", GetSystemStats)
bus.AddHandler("sql", GetDataSourceStats)
+ bus.AddHandler("sql", GetAdminStats)
}
func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error {
@@ -34,7 +35,11 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
(
SELECT COUNT(*)
FROM ` + dialect.Quote("dashboard") + `
- ) AS dashboard_count
+ ) AS dashboard_count,
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("playlist") + `
+ ) AS playlist_count
`
var stats m.SystemStats
@@ -46,3 +51,54 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
query.Result = &stats
return err
}
+
+func GetAdminStats(query *m.GetAdminStatsQuery) error {
+ var rawSql = `SELECT
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("user") + `
+ ) AS user_count,
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("org") + `
+ ) AS org_count,
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("dashboard") + `
+ ) AS dashboard_count,
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("dashboard_snapshot") + `
+ ) AS db_snapshot_count,
+ (
+ SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` ))
+ FROM ` + dialect.Quote("dashboard_tag") + `
+ ) AS db_tag_count,
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("data_source") + `
+ ) AS data_source_count,
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("playlist") + `
+ ) AS playlist_count,
+ (
+ SELECT COUNT(DISTINCT ` + dialect.Quote("dashboard_id") + ` )
+ FROM ` + dialect.Quote("star") + `
+ ) AS starred_db_count,
+ (
+ SELECT COUNT(*)
+ FROM ` + dialect.Quote("user") + `
+ WHERE ` + dialect.Quote("is_admin") + ` = 1
+ ) AS grafana_admin_count
+ `
+
+ var stats m.AdminStats
+ _, err := x.Sql(rawSql).Get(&stats)
+ if err != nil {
+ return err
+ }
+
+ query.Result = &stats
+ return err
+}
diff --git a/pkg/util/encryption.go b/pkg/util/encryption.go
new file mode 100644
index 00000000000..42586ddac8b
--- /dev/null
+++ b/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)
+}
diff --git a/pkg/util/encryption_test.go b/pkg/util/encryption_test.go
new file mode 100644
index 00000000000..5f1dc18fea3
--- /dev/null
+++ b/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")
+ })
+
+}
diff --git a/pkg/util/url.go b/pkg/util/url.go
index 758e6864fe4..ba452596a2b 100644
--- a/pkg/util/url.go
+++ b/pkg/util/url.go
@@ -27,6 +27,11 @@ func (r *UrlQueryReader) Get(name string, def string) string {
func JoinUrlFragments(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
+
+ if len(b) == 0 {
+ return a
+ }
+
switch {
case aslash && bslash:
return a + b[1:]
diff --git a/pkg/util/url_test.go b/pkg/util/url_test.go
new file mode 100644
index 00000000000..4dd221b9e0b
--- /dev/null
+++ b/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/")
+ })
+}
diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts
index 15c7f7fdb3b..efcd1f2ec25 100644
--- a/public/app/core/components/grafana_app.ts
+++ b/public/app/core/components/grafana_app.ts
@@ -150,6 +150,9 @@ export function grafanaAppDirective(playlistSrv) {
scope.$watch('contextSrv.sidemenu', newVal => {
if (newVal !== undefined) {
elem.toggleClass('sidemenu-open', scope.contextSrv.sidemenu);
+ if (!newVal) {
+ scope.contextSrv.setPinnedState(false);
+ }
}
if (scope.contextSrv.sidemenu) {
ignoreSideMenuHide = true;
@@ -159,6 +162,12 @@ export function grafanaAppDirective(playlistSrv) {
}
});
+ scope.$watch('contextSrv.pinned', newVal => {
+ if (newVal !== undefined) {
+ elem.toggleClass('sidemenu-pinned', newVal);
+ }
+ });
+
// tooltip removal fix
scope.$on("$routeChangeSuccess", function() {
$("#tooltip, .tooltip").remove();
@@ -182,7 +191,7 @@ export function grafanaAppDirective(playlistSrv) {
}
}
// hide sidemenu
- if (!ignoreSideMenuHide && elem.find('.sidemenu').length > 0) {
+ if (!ignoreSideMenuHide && !scope.contextSrv.pinned && elem.find('.sidemenu').length > 0) {
if (target.parents('.sidemenu').length === 0) {
scope.$apply(() => scope.contextSrv.toggleSideMenu());
}
diff --git a/public/app/core/components/navbar/navbar.html b/public/app/core/components/navbar/navbar.html
index 5c1e08de1f6..3ebac53e8d1 100644
--- a/public/app/core/components/navbar/navbar.html
+++ b/public/app/core/components/navbar/navbar.html
@@ -1,10 +1,11 @@
-