Merge branch 'master' into panelbase

This commit is contained in:
Torkel Ödegaard
2016-01-31 16:57:04 +01:00
148 changed files with 2641 additions and 1677 deletions

View File

@@ -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)

View File

@@ -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}

View File

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

View File

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

View File

@@ -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:

View File

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

View File

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

View File

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

View File

@@ -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"]

View File

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

View File

@@ -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.

View File

@@ -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`

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
pkg/api/app_routes.go Normal file
View File

@@ -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}
}

View File

@@ -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")
}
}

View File

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

View File

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

88
pkg/api/playlist_play.go Normal file
View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

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

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

@@ -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),
}
}

View File

@@ -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{},
}

View File

@@ -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
}

View File

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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
})
})
})
})
}

View File

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

66
pkg/util/encryption.go Normal file
View File

@@ -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)
}

View File

@@ -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")
})
}

View File

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

46
pkg/util/url_test.go Normal file
View File

@@ -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/")
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More