Merge branch 'master' into core/theming

This commit is contained in:
Torkel Ödegaard
2019-02-08 09:05:41 +01:00
113 changed files with 9177 additions and 1515 deletions

1
.gitignore vendored
View File

@@ -46,6 +46,7 @@ devenv/docker-compose.yaml
/conf/provisioning/**/custom.yaml
/conf/provisioning/**/dev.yaml
/conf/ldap_dev.toml
/conf/ldap_freeipa.toml
profile.cov
/grafana
/local

View File

@@ -7,6 +7,7 @@
* **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson)
* **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen)
* **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh)
* **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda)
# 6.0.0-beta1 (2019-01-30)

View File

@@ -106,25 +106,6 @@ path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private
#################################### Login ###############################
[login]
# Login cookie name
cookie_name = grafana_session
# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
cookie_samesite = lax
# How many days an session can be unused before we inactivate it
login_remember_days = 7
# How often should the login token be rotated. default to '10m'
rotate_token_minutes = 10
# How long should Grafana keep expired tokens before deleting them
delete_expired_token_after_days = 30
#################################### Session #############################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -206,8 +187,11 @@ data_source_proxy_whitelist =
# disable protection against brute force login attempts
disable_brute_force_login_protection = false
# set cookies as https only. default is false
https_flag_cookies = false
# set to true if you host Grafana behind HTTPS. default is false.
cookie_secure = false
# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none"
cookie_samesite = lax
#################################### Snapshots ###########################
[snapshots]
@@ -260,6 +244,18 @@ external_manage_info =
viewers_can_edit = false
[auth]
# Login cookie name
login_cookie_name = grafana_session
# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days.
login_maximum_inactive_lifetime_days = 7
# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
login_maximum_lifetime_days = 30
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
token_rotation_interval_minutes = 10
# Set to true to disable (hide) the login form, useful if you use OAuth
disable_login_form = false

View File

@@ -102,25 +102,6 @@ log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private
#################################### Login ###############################
[login]
# Login cookie name
;cookie_name = grafana_session
# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
;cookie_samesite = lax
# How many days an session can be unused before we inactivate it
;login_remember_days = 7
# How often should the login token be rotated. default to '10'
;rotate_token_minutes = 10
# How long should Grafana keep expired tokens before deleting them
;delete_expired_token_after_days = 30
#################################### Session ####################################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -193,8 +174,11 @@ log_queries =
# disable protection against brute force login attempts
;disable_brute_force_login_protection = false
# set cookies as https only. default is false
;https_flag_cookies = false
# set to true if you host Grafana behind HTTPS. default is false.
;cookie_secure = false
# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none"
;cookie_samesite = lax
#################################### Snapshots ###########################
[snapshots]
@@ -240,6 +224,18 @@ log_queries =
;viewers_can_edit = false
[auth]
# Login cookie name
;login_cookie_name = grafana_session
# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days,
;login_maximum_inactive_lifetime_days = 7
# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
;login_maximum_lifetime_days = 30
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
;token_rotation_interval_minutes = 10
# Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false
;disable_login_form = false
@@ -253,7 +249,7 @@ log_queries =
# This setting is ignored if multiple OAuth providers are configured.
;oauth_auto_login = false
#################################### Anonymous Auth ##########################
#################################### Anonymous Auth ######################
[auth.anonymous]
# enable anonymous access
;enabled = false

View File

@@ -0,0 +1,54 @@
version: '3'
volumes:
freeipa_data: {}
services:
freeipa:
image: freeipa/freeipa-server:fedora-29
container_name: freeipa
stdin_open: true
tty: true
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
hostname: ipa.example.test
environment:
# - DEBUG_TRACE=1
- IPA_SERVER_IP=172.17.0.2
- DEBUG_NO_EXIT=1
- IPA_SERVER_HOSTNAME=ipa.example.test
- PASSWORD=Secret123
- HOSTNAME=ipa.example.test
command:
- --admin-password=Secret123
- --ds-password=Secret123
- -U
- --realm=EXAMPLE.TEST
ports:
# FreeIPA WebUI
- "80:80"
- "443:443"
# Kerberos
- "88:88/udp"
- "88:88"
- "464:464/udp"
- "464:464"
# LDAP
- "389:389"
- "636:636"
# DNS
# - "53:53/udp"
# - "53:53"
# NTP
- "123:123/udp"
# other
- "7389:7389"
- "9443:9443"
- "9444:9444"
- "9445:9445"
tmpfs:
- /run
- /tmp
volumes:
- freeipa_data:/data:Z
- /sys/fs/cgroup:/sys/fs/cgroup:ro

View File

@@ -0,0 +1,74 @@
# To troubleshoot and get more log info enable ldap debug logging in grafana.ini
# [log]
# filters = ldap:debug
[[servers]]
# Ldap server host (specify multiple hosts space separated)
host = "172.17.0.1"
# Default port is 389 or 636 if use_ssl = true
port = 389
# Set to true if ldap server supports TLS
use_ssl = false
# Set to true if connect ldap server with STARTTLS pattern (create connection in insecure, then upgrade to secure connection with TLS)
start_tls = false
# set to true if you want to skip ssl cert validation
ssl_skip_verify = false
# set to the path to your root CA certificate or leave unset to use system defaults
# root_ca_cert = "/path/to/certificate.crt"
# Search user bind dn
bind_dn = "uid=admin,cn=users,cn=accounts,dc=example,dc=test"
# Search user bind password
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
bind_password = 'Secret123'
# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)"
search_filter = "(uid=%s)"
# An array of base dns to search through
search_base_dns = ["cn=users,cn=accounts,dc=example,dc=test"]
# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups.
# This is done by enabling group_search_filter below. You must also set member_of= "cn"
# in [servers.attributes] below.
# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN
# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of
# below in such a way that the user's recursive group membership is considered.
#
# Nested Groups + Active Directory (AD) Example:
#
# AD groups store the Distinguished Names (DNs) of members, so your filter must
# recursively search your groups for the authenticating user's DN. For example:
#
# group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)"
# group_search_filter_user_attribute = "distinguishedName"
# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
#
# [servers.attributes]
# ...
# member_of = "distinguishedName"
## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available)
# group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))"
## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter.
## Defaults to the value of username in [server.attributes]
## Valid options are any of your values in [servers.attributes]
## If you are using nested groups you probably want to set this and member_of in
## [servers.attributes] to "distinguishedName"
# group_search_filter_user_attribute = "distinguishedName"
## An array of the base DNs to search through for groups. Typically uses ou=groups
# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
# Specify names of the ldap attributes your ldap uses
[servers.attributes]
name = "givenName"
username = "uid"
member_of = "memberOf"
# surname = "sn"
# email = "mail"
[[servers.group_mappings]]
# If you want to match all (or no ldap groups) then you can use wildcard
group_dn = "*"
org_role = "Viewer"

View File

@@ -0,0 +1,32 @@
# Notes on FreeIPA LDAP Docker Block
Users have to be created manually. The docker-compose up command takes a few minutes to run.
## Create a user
`docker exec -it freeipa /bin/bash`
To create a user with username: `ldap-viewer` and password: `grafana123`
```bash
kinit admin
```
Log in with password `Secret123`
```bash
ipa user-add ldap-viewer --first ldap --last viewer
ipa passwd ldap-viewer
ldappasswd -D uid=ldap-viewer,cn=users,cn=accounts,dc=example,dc=org -w test -a test -s grafana123
```
## Enabling FreeIPA LDAP in Grafana
Copy the ldap_freeipa.toml file in this folder into your `conf` folder (it is gitignored already). To enable it in the .ini file to get Grafana to use this block:
```ini
[auth.ldap]
enabled = true
config_file = conf/ldap_freeipa.toml
; allow_sign_up = true
```

View File

@@ -15,6 +15,7 @@ services:
MYSQL_DATABASE: grafana
MYSQL_USER: grafana
MYSQL_PASSWORD: password
command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all, --max-connections=1001]
ports:
- 3306
healthcheck:
@@ -22,6 +23,16 @@ services:
timeout: 10s
retries: 10
mysqld-exporter:
image: prom/mysqld-exporter
environment:
- DATA_SOURCE_NAME=root:rootpass@(db:3306)/
ports:
- 9104
depends_on:
db:
condition: service_healthy
# db:
# image: postgres:9.3
# environment:
@@ -47,6 +58,7 @@ services:
- GF_DATABASE_PASSWORD=password
- GF_DATABASE_TYPE=mysql
- GF_DATABASE_HOST=db:3306
- GF_DATABASE_MAX_OPEN_CONN=300
- GF_SESSION_PROVIDER=mysql
- GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true
# - GF_DATABASE_TYPE=postgres
@@ -55,7 +67,7 @@ services:
# - GF_SESSION_PROVIDER=postgres
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
- GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
- GF_LOGIN_ROTATE_TOKEN_MINUTES=2
- GF_AUTH_TOKEN_ROTATION_INTERVAL_MINUTES=2
ports:
- 3000
depends_on:
@@ -70,10 +82,3 @@ services:
- VIRTUAL_HOST=prometheus.loc
ports:
- 9090
# mysqld-exporter:
# image: prom/mysqld-exporter
# environment:
# - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
# ports:
# - 9104

View File

@@ -6,3 +6,9 @@ providers:
type: file
options:
path: /etc/grafana/provisioning/dashboards/alerts
- name: 'MySQL'
folder: 'MySQL'
type: file
options:
path: /etc/grafana/provisioning/dashboards/mysql

File diff suppressed because it is too large Load Diff

View File

@@ -30,10 +30,10 @@ scrape_configs:
port: 3000
refresh_interval: 10s
# - job_name: 'mysql'
# dns_sd_configs:
# - names:
# - 'mysqld-exporter'
# type: 'A'
# port: 9104
# refresh_interval: 10s
- job_name: 'mysql'
dns_sd_configs:
- names:
- 'mysqld-exporter'
type: 'A'
port: 9104
refresh_interval: 10s

View File

@@ -8,7 +8,7 @@ Docker
## Run
Run load test for 15 minutes:
Run load test for 15 minutes using 2 virtual users and targeting http://localhost:3000.
```bash
$ ./run.sh
@@ -20,6 +20,18 @@ Run load test for custom duration:
$ ./run.sh -d 10s
```
Run load test for custom target url:
```bash
$ ./run.sh -u http://grafana.loc
```
Run load test for 10 virtual users:
```bash
$ ./run.sh -v 10
```
Example output:
```bash

View File

@@ -65,7 +65,7 @@ export default (data) => {
}
});
sleep(1)
sleep(5)
}
export const teardown = (data) => {}

View File

@@ -5,8 +5,9 @@ PWD=$(pwd)
run() {
duration='15m'
url='http://localhost:3000'
vus='2'
while getopts ":d:u:" o; do
while getopts ":d:u:v:" o; do
case "${o}" in
d)
duration=${OPTARG}
@@ -14,11 +15,14 @@ run() {
u)
url=${OPTARG}
;;
v)
vus=${OPTARG}
;;
esac
done
shift $((OPTIND-1))
docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus 2 --duration $duration src/auth_token_test.js
docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus $vus --duration $duration src/auth_token_test.js
}
run "$@"

View File

@@ -36,6 +36,35 @@ Grafana of course has a built in user authentication system with password authen
disable authentication by enabling anonymous access. You can also hide login form and only allow login through an auth
provider (listed above). There is also options for allowing self sign up.
### Login and short-lived tokens
> The followung applies when using Grafana's built in user authentication, LDAP (without Auth proxy) or OAuth integration.
Grafana are using short-lived tokens as a mechanism for verifying authenticated users.
These short-lived tokens are rotated each `token_rotation_interval_minutes` for an active authenticated user.
An active authenticated user that gets it token rotated will extend the `login_maximum_inactive_lifetime_days` time from "now" that Grafana will remember the user.
This means that a user can close its browser and come back before `now + login_maximum_inactive_lifetime_days` and still being authenticated.
This is true as long as the time since user login is less than `login_maximum_lifetime_days`.
Example:
```bash
[auth]
# Login cookie name
login_cookie_name = grafana_session
# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days.
login_maximum_inactive_lifetime_days = 7
# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
login_maximum_lifetime_days = 30
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
token_rotation_interval_minutes = 10
```
### Anonymous authentication
You can make Grafana accessible without any login required by enabling anonymous access in the configuration file.

View File

@@ -287,6 +287,14 @@ Default is `false`.
Define a white list of allowed ips/domains to use in data sources. Format: `ip_or_domain:port` separated by spaces
### cookie_secure
Set to `true` if you host Grafana behind HTTPS. Default is `false`.
### cookie_samesite
Sets the `SameSite` cookie attribute and prevents the browser from sending this cookie along with cross-site requests. The main goal is mitigate the risk of cross-origin information leakage. It also provides some protection against cross-site request forgery attacks (CSRF), [read more here](https://www.owasp.org/index.php/SameSite). Valid values are `lax`, `strict` and `none`. Default is `lax`.
<hr />
## [users]

View File

@@ -86,6 +86,7 @@
"prettier": "1.9.2",
"react-hot-loader": "^4.3.6",
"react-test-renderer": "^16.5.0",
"redux-mock-store": "^1.5.3",
"regexp-replace-loader": "^1.0.1",
"sass-lint": "^1.10.2",
"sass-loader": "^7.0.1",

View File

@@ -94,14 +94,13 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
}
type scenarioContext struct {
m *macaron.Macaron
context *m.ReqContext
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
userAuthTokenService *fakeUserAuthTokenService
m *macaron.Macaron
context *m.ReqContext
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
}
func (sc *scenarioContext) exec() {
@@ -123,30 +122,7 @@ func setupScenarioContext(url string) *scenarioContext {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
sc.m.Use(middleware.GetContextHandler(nil))
return sc
}
type fakeUserAuthTokenService struct {
initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
}
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
return &fakeUserAuthTokenService{
initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
return false
},
}
}
func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
return s.initContextWithTokenProvider(ctx, orgID)
}
func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
return nil
}
func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }

View File

@@ -5,6 +5,7 @@ type PlaylistDashboard struct {
Slug string `json:"slug"`
Title string `json:"title"`
Uri string `json:"uri"`
Url string `json:"url"`
Order int `json:"order"`
}

View File

@@ -21,7 +21,6 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cache"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/hooks"
@@ -48,14 +47,14 @@ type HTTPServer struct {
streamManager *live.StreamManager
httpSrv *http.Server
RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
HooksService *hooks.HooksService `inject:""`
CacheService *cache.CacheService `inject:""`
DatasourceCache datasources.CacheService `inject:""`
AuthTokenService auth.UserAuthTokenService `inject:""`
RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
HooksService *hooks.HooksService `inject:""`
CacheService *cache.CacheService `inject:""`
DatasourceCache datasources.CacheService `inject:""`
AuthTokenService models.UserTokenService `inject:""`
}
func (hs *HTTPServer) Init() error {

View File

@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -126,17 +127,23 @@ func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response
func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
if user == nil {
hs.log.Error("User login with nil user")
hs.log.Error("user login with nil user")
}
err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
userToken, err := hs.AuthTokenService.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
if err != nil {
hs.log.Error("User auth hook failed", "error", err)
hs.log.Error("failed to create auth token", "error", err)
}
middleware.WriteSessionCookie(c, userToken.UnhashedToken, hs.Cfg.LoginMaxLifetimeDays)
}
func (hs *HTTPServer) Logout(c *m.ReqContext) {
hs.AuthTokenService.SignOutUser(c)
if err := hs.AuthTokenService.RevokeToken(c.UserToken); err != nil && err != m.ErrUserTokenNotFound {
hs.log.Error("failed to revoke auth token", "error", err)
}
middleware.WriteSessionCookie(c, "", -1)
if setting.SignoutRedirectUrl != "" {
c.Redirect(setting.SignoutRedirectUrl)
@@ -176,7 +183,8 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string
Value: hex.EncodeToString(encryptedError),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.SecurityHTTPSCookies,
Secure: hs.Cfg.CookieSecure,
SameSite: hs.Cfg.CookieSameSite,
})
return nil

View File

@@ -214,7 +214,8 @@ func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value stri
Value: value,
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.SecurityHTTPSCookies,
Secure: hs.Cfg.CookieSecure,
SameSite: hs.Cfg.CookieSameSite,
})
}

View File

@@ -26,6 +26,7 @@ func populateDashboardsByID(dashboardByIDs []int64, dashboardIDOrder map[int64]i
Slug: item.Slug,
Title: item.Title,
Uri: "db/" + item.Slug,
Url: m.GetDashboardUrl(item.Uid, item.Slug),
Order: dashboardIDOrder[item.Id],
})
}

View File

@@ -32,6 +32,7 @@ import (
_ "github.com/grafana/grafana/pkg/metrics"
_ "github.com/grafana/grafana/pkg/plugins"
_ "github.com/grafana/grafana/pkg/services/alerting"
_ "github.com/grafana/grafana/pkg/services/auth"
_ "github.com/grafana/grafana/pkg/services/cleanup"
_ "github.com/grafana/grafana/pkg/services/notifications"
_ "github.com/grafana/grafana/pkg/services/provisioning"

View File

@@ -273,23 +273,35 @@ func (a *ldapAuther) initialBind(username, userPassword string) error {
return nil
}
func appendIfNotEmpty(slice []string, values ...string) []string {
for _, v := range values {
if v != "" {
slice = append(slice, v)
}
}
return slice
}
func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
var searchResult *ldap.SearchResult
var err error
for _, searchBase := range a.server.SearchBaseDNs {
attributes := make([]string, 0)
inputs := a.server.Attr
attributes = appendIfNotEmpty(attributes,
inputs.Username,
inputs.Surname,
inputs.Email,
inputs.Name,
inputs.MemberOf)
searchReq := ldap.SearchRequest{
BaseDN: searchBase,
Scope: ldap.ScopeWholeSubtree,
DerefAliases: ldap.NeverDerefAliases,
Attributes: []string{
a.server.Attr.Username,
a.server.Attr.Surname,
a.server.Attr.Email,
a.server.Attr.Name,
a.server.Attr.MemberOf,
},
Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
Attributes: attributes,
Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
}
a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/ldap.v3"
@@ -322,11 +323,51 @@ func TestLdapAuther(t *testing.T) {
So(sc.addOrgUserCmd.Role, ShouldEqual, "Admin")
})
})
Convey("When searching for a user and not all five attributes are mapped", t, func() {
mockLdapConnection := &mockLdapConn{}
entry := ldap.Entry{
DN: "dn", Attributes: []*ldap.EntryAttribute{
{Name: "username", Values: []string{"roelgerrits"}},
{Name: "surname", Values: []string{"Gerrits"}},
{Name: "email", Values: []string{"roel@test.com"}},
{Name: "name", Values: []string{"Roel"}},
{Name: "memberof", Values: []string{"admins"}},
}}
result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}}
mockLdapConnection.setSearchResult(&result)
// Set up attribute map without surname and email
ldapAuther := &ldapAuther{
server: &LdapServerConf{
Attr: LdapAttributeMap{
Username: "username",
Name: "name",
MemberOf: "memberof",
},
SearchBaseDNs: []string{"BaseDNHere"},
},
conn: mockLdapConnection,
log: log.New("test-logger"),
}
searchResult, err := ldapAuther.searchForUser("roelgerrits")
So(err, ShouldBeNil)
So(searchResult, ShouldNotBeNil)
// User should be searched in ldap
So(mockLdapConnection.searchCalled, ShouldBeTrue)
// No empty attributes should be added to the search request
So(len(mockLdapConnection.searchAttributes), ShouldEqual, 3)
})
}
type mockLdapConn struct {
result *ldap.SearchResult
searchCalled bool
result *ldap.SearchResult
searchCalled bool
searchAttributes []string
}
func (c *mockLdapConn) Bind(username, password string) error {
@@ -339,8 +380,9 @@ func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) {
c.result = result
}
func (c *mockLdapConn) Search(*ldap.SearchRequest) (*ldap.SearchResult, error) {
func (c *mockLdapConn) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) {
c.searchCalled = true
c.searchAttributes = sr.Attributes
return c.result, nil
}

View File

@@ -1,13 +1,15 @@
package middleware
import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -21,7 +23,7 @@ var (
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
)
func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
func GetContextHandler(ats m.UserTokenService) macaron.Handler {
return func(c *macaron.Context) {
ctx := &m.ReqContext{
Context: c,
@@ -49,7 +51,7 @@ func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
case initContextWithApiKey(ctx):
case initContextWithBasicAuth(ctx, orgId):
case initContextWithAuthProxy(ctx, orgId):
case ats.InitContextWithToken(ctx, orgId):
case initContextWithToken(ats, ctx, orgId):
case initContextWithAnonymousUser(ctx):
}
@@ -166,6 +168,69 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
return true
}
func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext, orgID int64) bool {
rawToken := ctx.GetCookie(setting.LoginCookieName)
if rawToken == "" {
return false
}
token, err := authTokenService.LookupToken(rawToken)
if err != nil {
ctx.Logger.Error("failed to look up user based on cookie", "error", err)
WriteSessionCookie(ctx, "", -1)
return false
}
query := m.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID}
if err := bus.Dispatch(&query); err != nil {
ctx.Logger.Error("failed to get user with id", "userId", token.UserId, "error", err)
return false
}
ctx.SignedInUser = query.Result
ctx.IsSignedIn = true
ctx.UserToken = token
rotated, err := authTokenService.TryRotateToken(token, ctx.RemoteAddr(), ctx.Req.UserAgent())
if err != nil {
ctx.Logger.Error("failed to rotate token", "error", err)
return true
}
if rotated {
WriteSessionCookie(ctx, token.UnhashedToken, setting.LoginMaxLifetimeDays)
}
return true
}
func WriteSessionCookie(ctx *m.ReqContext, value string, maxLifetimeDays int) {
if setting.Env == setting.DEV {
ctx.Logger.Info("new token", "unhashed token", value)
}
var maxAge int
if maxLifetimeDays <= 0 {
maxAge = -1
} else {
maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour) + time.Hour
maxAge = int(maxAgeHours.Seconds())
}
ctx.Resp.Header().Del("Set-Cookie")
cookie := http.Cookie{
Name: setting.LoginCookieName,
Value: url.QueryEscape(value),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: setting.CookieSecure,
MaxAge: maxAge,
SameSite: setting.CookieSameSite,
}
http.SetCookie(ctx.Resp, &cookie)
}
func AddDefaultResponseHeaders() macaron.Handler {
return func(ctx *m.ReqContext) {
if ctx.IsApiRequest() && ctx.Req.Method == "GET" {

View File

@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"path/filepath"
"testing"
"time"
msession "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
@@ -146,17 +147,95 @@ func TestMiddlewareContext(t *testing.T) {
})
})
middlewareScenario("Auth token service", func(sc *scenarioContext) {
var wasCalled bool
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
wasCalled = true
return false
middlewareScenario("Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: unhashedToken,
}, nil
}
sc.fakeReq("GET", "/").exec()
Convey("should call middleware", func() {
So(wasCalled, ShouldBeTrue)
Convey("should init context with user info", func() {
So(sc.context.IsSignedIn, ShouldBeTrue)
So(sc.context.UserId, ShouldEqual, 12)
So(sc.context.UserToken.UserId, ShouldEqual, 12)
So(sc.context.UserToken.UnhashedToken, ShouldEqual, "token")
})
Convey("should not set cookie", func() {
So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, "")
})
})
middlewareScenario("Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
}, nil
}
sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
userToken.UnhashedToken = "rotated"
return true, nil
}
maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour)
maxAge := (maxAgeHours + time.Hour).Seconds()
expectedCookie := &http.Cookie{
Name: setting.LoginCookieName,
Value: "rotated",
Path: setting.AppSubUrl + "/",
HttpOnly: true,
MaxAge: int(maxAge),
Secure: setting.CookieSecure,
SameSite: setting.CookieSameSite,
}
sc.fakeReq("GET", "/").exec()
Convey("should init context with user info", func() {
So(sc.context.IsSignedIn, ShouldBeTrue)
So(sc.context.UserId, ShouldEqual, 12)
So(sc.context.UserToken.UserId, ShouldEqual, 12)
So(sc.context.UserToken.UnhashedToken, ShouldEqual, "rotated")
})
Convey("should set cookie", func() {
So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, expectedCookie.String())
})
})
middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return nil, m.ErrUserTokenNotFound
}
sc.fakeReq("GET", "/").exec()
Convey("should not init context with user info", func() {
So(sc.context.IsSignedIn, ShouldBeFalse)
So(sc.context.UserId, ShouldEqual, 0)
So(sc.context.UserToken, ShouldBeNil)
})
})
@@ -469,6 +548,9 @@ func middlewareScenario(desc string, fn scenarioFunc) {
Convey(desc, func() {
defer bus.ClearBusHandlers()
setting.LoginCookieName = "grafana_session"
setting.LoginMaxLifetimeDays = 30
sc := &scenarioContext{}
viewsPath, _ := filepath.Abs("../../public/views")
@@ -508,6 +590,7 @@ type scenarioContext struct {
resp *httptest.ResponseRecorder
apiKey string
authHeader string
tokenSessionCookie string
respJson map[string]interface{}
handlerFunc handlerFunc
defaultHandler macaron.Handler
@@ -522,6 +605,11 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext {
return sc
}
func (sc *scenarioContext) withTokenSessionCookie(unhashedToken string) *scenarioContext {
sc.tokenSessionCookie = unhashedToken
return sc
}
func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
sc.authHeader = authHeader
return sc
@@ -571,6 +659,13 @@ func (sc *scenarioContext) exec() {
sc.req.Header.Add("Authorization", sc.authHeader)
}
if sc.tokenSessionCookie != "" {
sc.req.AddCookie(&http.Cookie{
Name: setting.LoginCookieName,
Value: sc.tokenSessionCookie,
})
}
sc.m.ServeHTTP(sc.resp, sc.req)
if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" {
@@ -583,23 +678,47 @@ type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext)
type fakeUserAuthTokenService struct {
initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error)
tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error)
lookupTokenProvider func(unhashedToken string) (*m.UserToken, error)
revokeTokenProvider func(token *m.UserToken) error
}
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
return &fakeUserAuthTokenService{
initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
return false
createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 0,
UnhashedToken: "",
}, nil
},
tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) {
return false, nil
},
lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 0,
UnhashedToken: "",
}, nil
},
revokeTokenProvider: func(token *m.UserToken) error {
return nil
},
}
}
func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
return s.initContextWithTokenProvider(ctx, orgID)
func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
return s.createTokenProvider(userId, clientIP, userAgent)
}
func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
return nil
func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) {
return s.lookupTokenProvider(unhashedToken)
}
func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }
func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) {
return s.tryRotateTokenProvider(token, clientIP, userAgent)
}
func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error {
return s.revokeTokenProvider(token)
}

View File

@@ -14,14 +14,21 @@ func TestOrgRedirectMiddleware(t *testing.T) {
Convey("Can redirect to correct org", t, func() {
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return nil
})
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
ctx.IsSignedIn = true
return true
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 0,
UnhashedToken: "",
}, nil
}
sc.m.Get("/", sc.defaultHandler)
@@ -33,21 +40,23 @@ func TestOrgRedirectMiddleware(t *testing.T) {
})
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return fmt.Errorf("")
})
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
ctx.IsSignedIn = true
return true
}
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
}, nil
}
sc.m.Get("/", sc.defaultHandler)
sc.fakeReq("GET", "/?orgId=3").exec()

View File

@@ -74,10 +74,17 @@ func TestMiddlewareQuota(t *testing.T) {
})
middlewareScenario("with user logged in", func(sc *scenarioContext) {
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
ctx.IsSignedIn = true
return true
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
}, nil
}
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {

View File

@@ -13,6 +13,7 @@ import (
type ReqContext struct {
*macaron.Context
*SignedInUser
UserToken *UserToken
// This should only be used by the auth_proxy
Session session.SessionStore

32
pkg/models/user_token.go Normal file
View File

@@ -0,0 +1,32 @@
package models
import "errors"
// Typed errors
var (
ErrUserTokenNotFound = errors.New("user token not found")
)
// UserToken represents a user token
type UserToken struct {
Id int64
UserId int64
AuthToken string
PrevAuthToken string
UserAgent string
ClientIp string
AuthTokenSeen bool
SeenAt int64
RotatedAt int64
CreatedAt int64
UpdatedAt int64
UnhashedToken string
}
// UserTokenService are used for generating and validating user tokens
type UserTokenService interface {
CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error)
LookupToken(unhashedToken string) (*UserToken, error)
TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error)
RevokeToken(token *UserToken) error
}

View File

@@ -3,13 +3,10 @@ package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/url"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/registry"
@@ -19,116 +16,26 @@ import (
)
func init() {
registry.RegisterService(&UserAuthTokenServiceImpl{})
registry.RegisterService(&UserAuthTokenService{})
}
var (
getTime = time.Now
UrgentRotateTime = 1 * time.Minute
oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
)
var getTime = time.Now
// UserAuthTokenService are used for generating and validating user auth tokens
type UserAuthTokenService interface {
InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
SignOutUser(c *models.ReqContext) error
}
const urgentRotateTime = 1 * time.Minute
type UserAuthTokenServiceImpl struct {
type UserAuthTokenService struct {
SQLStore *sqlstore.SqlStore `inject:""`
ServerLockService *serverlock.ServerLockService `inject:""`
Cfg *setting.Cfg `inject:""`
log log.Logger
}
// Init this service
func (s *UserAuthTokenServiceImpl) Init() error {
func (s *UserAuthTokenService) Init() error {
s.log = log.New("auth")
return nil
}
func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool {
//auth User
unhashedToken := ctx.GetCookie(s.Cfg.LoginCookieName)
if unhashedToken == "" {
return false
}
userToken, err := s.LookupToken(unhashedToken)
if err != nil {
ctx.Logger.Info("failed to look up user based on cookie", "error", err)
return false
}
query := models.GetSignedInUserQuery{UserId: userToken.UserId, OrgId: orgID}
if err := bus.Dispatch(&query); err != nil {
ctx.Logger.Error("Failed to get user with id", "userId", userToken.UserId, "error", err)
return false
}
ctx.SignedInUser = query.Result
ctx.IsSignedIn = true
//rotate session token if needed.
rotated, err := s.RefreshToken(userToken, ctx.RemoteAddr(), ctx.Req.UserAgent())
if err != nil {
ctx.Logger.Error("failed to rotate token", "error", err, "userId", userToken.UserId, "tokenId", userToken.Id)
return true
}
if rotated {
s.writeSessionCookie(ctx, userToken.UnhashedToken, oneYearInSeconds)
}
return true
}
func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) {
if setting.Env == setting.DEV {
ctx.Logger.Debug("new token", "unhashed token", value)
}
ctx.Resp.Header().Del("Set-Cookie")
cookie := http.Cookie{
Name: s.Cfg.LoginCookieName,
Value: url.QueryEscape(value),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: s.Cfg.SecurityHTTPSCookies,
MaxAge: maxAge,
SameSite: s.Cfg.LoginCookieSameSite,
}
http.SetCookie(ctx.Resp, &cookie)
}
func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *models.ReqContext) error {
userToken, err := s.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
if err != nil {
return err
}
s.writeSessionCookie(c, userToken.UnhashedToken, oneYearInSeconds)
return nil
}
func (s *UserAuthTokenServiceImpl) SignOutUser(c *models.ReqContext) error {
unhashedToken := c.GetCookie(s.Cfg.LoginCookieName)
if unhashedToken == "" {
return errors.New("cannot logout without session token")
}
hashedToken := hashToken(unhashedToken)
sql := `DELETE FROM user_auth_token WHERE auth_token = ?`
_, err := s.SQLStore.NewSession().Exec(sql, hashedToken)
s.writeSessionCookie(c, "", -1)
return err
}
func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
func (s *UserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
clientIP = util.ParseIPAddress(clientIP)
token, err := util.RandomHex(16)
if err != nil {
@@ -139,7 +46,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
now := getTime().Unix()
userToken := userAuthToken{
userAuthToken := userAuthToken{
UserId: userId,
AuthToken: hashedToken,
PrevAuthToken: hashedToken,
@@ -151,98 +58,114 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
SeenAt: 0,
AuthTokenSeen: false,
}
_, err = s.SQLStore.NewSession().Insert(&userToken)
_, err = s.SQLStore.NewSession().Insert(&userAuthToken)
if err != nil {
return nil, err
}
userToken.UnhashedToken = token
userAuthToken.UnhashedToken = token
return &userToken, nil
s.log.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken)
var userToken models.UserToken
err = userAuthToken.toUserToken(&userToken)
return &userToken, err
}
func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
func (s *UserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) {
hashedToken := hashToken(unhashedToken)
if setting.Env == setting.DEV {
s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
}
expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
tokenMaxInactiveLifetime := time.Duration(s.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
createdAfter := getTime().Add(-tokenMaxLifetime).Unix()
rotatedAfter := getTime().Add(-tokenMaxInactiveLifetime).Unix()
var userToken userAuthToken
exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
var model userAuthToken
exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ? AND rotated_at > ?", hashedToken, hashedToken, createdAfter, rotatedAfter).Get(&model)
if err != nil {
return nil, err
}
if !exists {
return nil, ErrAuthTokenNotFound
return nil, models.ErrUserTokenNotFound
}
if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen {
userTokenCopy := userToken
userTokenCopy.AuthTokenSeen = false
expireBefore := getTime().Add(-UrgentRotateTime).Unix()
affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", userTokenCopy.Id, userTokenCopy.PrevAuthToken, expireBefore).AllCols().Update(&userTokenCopy)
if model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen {
modelCopy := model
modelCopy.AuthTokenSeen = false
expireBefore := getTime().Add(-urgentRotateTime).Unix()
affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", modelCopy.Id, modelCopy.PrevAuthToken, expireBefore).AllCols().Update(&modelCopy)
if err != nil {
return nil, err
}
if affectedRows == 0 {
s.log.Debug("prev seen token unchanged", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
s.log.Debug("prev seen token unchanged", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
} else {
s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
s.log.Debug("prev seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
}
}
if !userToken.AuthTokenSeen && userToken.AuthToken == hashedToken {
userTokenCopy := userToken
userTokenCopy.AuthTokenSeen = true
userTokenCopy.SeenAt = getTime().Unix()
affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy)
if !model.AuthTokenSeen && model.AuthToken == hashedToken {
modelCopy := model
modelCopy.AuthTokenSeen = true
modelCopy.SeenAt = getTime().Unix()
affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", modelCopy.Id, modelCopy.AuthToken).AllCols().Update(&modelCopy)
if err != nil {
return nil, err
}
if affectedRows == 1 {
userToken = userTokenCopy
model = modelCopy
}
if affectedRows == 0 {
s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
s.log.Debug("seen wrong token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
} else {
s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
s.log.Debug("seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
}
}
userToken.UnhashedToken = unhashedToken
model.UnhashedToken = unhashedToken
return &userToken, nil
var userToken models.UserToken
err = model.toUserToken(&userToken)
return &userToken, err
}
func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) {
func (s *UserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) {
if token == nil {
return false, nil
}
model := userAuthTokenFromUserToken(token)
now := getTime()
needsRotation := false
rotatedAt := time.Unix(token.RotatedAt, 0)
if token.AuthTokenSeen {
needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute))
rotatedAt := time.Unix(model.RotatedAt, 0)
if model.AuthTokenSeen {
needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.TokenRotationIntervalMinutes) * time.Minute))
} else {
needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime))
needsRotation = rotatedAt.Before(now.Add(-urgentRotateTime))
}
if !needsRotation {
return false, nil
}
s.log.Debug("refresh token needs rotation?", "auth_token_seen", token.AuthTokenSeen, "rotated_at", rotatedAt, "token.Id", token.Id)
s.log.Debug("token needs rotation", "tokenId", model.Id, "authTokenSeen", model.AuthTokenSeen, "rotatedAt", rotatedAt)
clientIP = util.ParseIPAddress(clientIP)
newToken, _ := util.RandomHex(16)
newToken, err := util.RandomHex(16)
if err != nil {
return false, err
}
hashedToken := hashToken(newToken)
// very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
@@ -258,21 +181,44 @@ func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP,
rotated_at = ?
WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)`
res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), token.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix())
res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), model.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix())
if err != nil {
return false, err
}
affected, _ := res.RowsAffected()
s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId)
s.log.Debug("auth token rotated", "affected", affected, "auth_token_id", model.Id, "userId", model.UserId)
if affected > 0 {
token.UnhashedToken = newToken
model.UnhashedToken = newToken
model.toUserToken(token)
return true, nil
}
return false, nil
}
func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error {
if token == nil {
return models.ErrUserTokenNotFound
}
model := userAuthTokenFromUserToken(token)
rowsAffected, err := s.SQLStore.NewSession().Delete(model)
if err != nil {
return err
}
if rowsAffected == 0 {
s.log.Debug("user auth token not found/revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
return models.ErrUserTokenNotFound
}
s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
return nil
}
func hashToken(token string) string {
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
return hex.EncodeToString(hashBytes[:])

View File

@@ -1,17 +1,15 @@
package auth
import (
"fmt"
"net/http"
"net/http/httptest"
"encoding/json"
"testing"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/setting"
macaron "gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
. "github.com/smartystreets/goconvey/convey"
)
@@ -28,236 +26,265 @@ func TestUserAuthToken(t *testing.T) {
}
Convey("When creating token", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
So(token.AuthTokenSeen, ShouldBeFalse)
So(userToken, ShouldNotBeNil)
So(userToken.AuthTokenSeen, ShouldBeFalse)
Convey("When lookup unhashed token should return user auth token", func() {
LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken)
userToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
So(LookupToken, ShouldNotBeNil)
So(LookupToken.UserId, ShouldEqual, userID)
So(LookupToken.AuthTokenSeen, ShouldBeTrue)
So(userToken, ShouldNotBeNil)
So(userToken.UserId, ShouldEqual, userID)
So(userToken.AuthTokenSeen, ShouldBeTrue)
storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id)
storedAuthToken, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedAuthToken, ShouldNotBeNil)
So(storedAuthToken.AuthTokenSeen, ShouldBeTrue)
})
Convey("When lookup hashed token should return user auth token not found error", func() {
LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken)
So(err, ShouldEqual, ErrAuthTokenNotFound)
So(LookupToken, ShouldBeNil)
userToken, err := userAuthTokenService.LookupToken(userToken.AuthToken)
So(err, ShouldEqual, models.ErrUserTokenNotFound)
So(userToken, ShouldBeNil)
})
Convey("signing out should delete token and cookie if present", func() {
httpreq := &http.Request{Header: make(http.Header)}
httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: token.UnhashedToken})
ctx := &models.ReqContext{Context: &macaron.Context{
Req: macaron.Request{Request: httpreq},
Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
},
Logger: log.New("fakelogger"),
}
err = userAuthTokenService.SignOutUser(ctx)
Convey("revoking existing token should delete token", func() {
err = userAuthTokenService.RevokeToken(userToken)
So(err, ShouldBeNil)
// makes sure we tell the browser to overwrite the cookie
cookieHeader := fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly", userAuthTokenService.Cfg.LoginCookieName)
So(ctx.Resp.Header().Get("Set-Cookie"), ShouldEqual, cookieHeader)
model, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(model, ShouldBeNil)
})
Convey("signing out an none existing session should return an error", func() {
httpreq := &http.Request{Header: make(http.Header)}
httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: ""})
Convey("revoking nil token should return error", func() {
err = userAuthTokenService.RevokeToken(nil)
So(err, ShouldEqual, models.ErrUserTokenNotFound)
})
ctx := &models.ReqContext{Context: &macaron.Context{
Req: macaron.Request{Request: httpreq},
Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
},
Logger: log.New("fakelogger"),
}
err = userAuthTokenService.SignOutUser(ctx)
So(err, ShouldNotBeNil)
Convey("revoking non-existing token should return error", func() {
userToken.Id = 1000
err = userAuthTokenService.RevokeToken(userToken)
So(err, ShouldEqual, models.ErrUserTokenNotFound)
})
})
Convey("expires correctly", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
token, err = ctx.getAuthTokenByID(token.Id)
userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
getTime = func() time.Time {
return t.Add(time.Hour)
}
refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.11:1234", "some user agent")
rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
So(rotated, ShouldBeTrue)
_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
stillGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
So(stillGood, ShouldNotBeNil)
getTime = func() time.Time {
return t.Add(24 * 7 * time.Hour)
}
notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldEqual, ErrAuthTokenNotFound)
So(notGood, ShouldBeNil)
model, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
Convey("when rotated_at is 6:59:59 ago should find token", func() {
getTime = func() time.Time {
return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour).Add(-time.Second)
}
stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken)
So(err, ShouldBeNil)
So(stillGood, ShouldNotBeNil)
})
Convey("when rotated_at is 7:00:00 ago should not find token", func() {
getTime = func() time.Time {
return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour)
}
notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldEqual, models.ErrUserTokenNotFound)
So(notGood, ShouldBeNil)
})
Convey("when rotated_at is 5 days ago and created_at is 29 days and 23:59:59 ago should not find token", func() {
updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix())
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
getTime = func() time.Time {
return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour).Add(-time.Second)
}
stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken)
So(err, ShouldBeNil)
So(stillGood, ShouldNotBeNil)
})
Convey("when rotated_at is 5 days ago and created_at is 30 days ago should not find token", func() {
updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix())
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
getTime = func() time.Time {
return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour)
}
notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldEqual, models.ErrUserTokenNotFound)
So(notGood, ShouldBeNil)
})
})
Convey("can properly rotate tokens", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
prevToken := token.AuthToken
unhashedPrev := token.UnhashedToken
prevToken := userToken.AuthToken
unhashedPrev := userToken.UnhashedToken
refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
So(refreshed, ShouldBeFalse)
So(rotated, ShouldBeFalse)
updated, err := ctx.markAuthTokenAsSeen(token.Id)
updated, err := ctx.markAuthTokenAsSeen(userToken.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
token, err = ctx.getAuthTokenByID(token.Id)
model, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
var tok models.UserToken
err = model.toUserToken(&tok)
So(err, ShouldBeNil)
getTime = func() time.Time {
return t.Add(time.Hour)
}
refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
rotated, err = userAuthTokenService.TryRotateToken(&tok, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
So(rotated, ShouldBeTrue)
unhashedToken := token.UnhashedToken
unhashedToken := tok.UnhashedToken
token, err = ctx.getAuthTokenByID(token.Id)
model, err = ctx.getAuthTokenByID(tok.Id)
So(err, ShouldBeNil)
token.UnhashedToken = unhashedToken
model.UnhashedToken = unhashedToken
So(token.RotatedAt, ShouldEqual, getTime().Unix())
So(token.ClientIp, ShouldEqual, "192.168.10.12")
So(token.UserAgent, ShouldEqual, "a new user agent")
So(token.AuthTokenSeen, ShouldBeFalse)
So(token.SeenAt, ShouldEqual, 0)
So(token.PrevAuthToken, ShouldEqual, prevToken)
So(model.RotatedAt, ShouldEqual, getTime().Unix())
So(model.ClientIp, ShouldEqual, "192.168.10.12")
So(model.UserAgent, ShouldEqual, "a new user agent")
So(model.AuthTokenSeen, ShouldBeFalse)
So(model.SeenAt, ShouldEqual, 0)
So(model.PrevAuthToken, ShouldEqual, prevToken)
// ability to auth using an old token
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
So(lookedUpUserToken, ShouldNotBeNil)
So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
So(lookedUpUserToken.SeenAt, ShouldEqual, getTime().Unix())
lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.Id, ShouldEqual, token.Id)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
So(lookedUpUserToken, ShouldNotBeNil)
So(lookedUpUserToken.Id, ShouldEqual, model.Id)
So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(time.Hour + (2 * time.Minute))
}
lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
So(lookedUpUserToken, ShouldNotBeNil)
So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeFalse)
So(lookedUpModel, ShouldNotBeNil)
So(lookedUpModel.AuthTokenSeen, ShouldBeFalse)
refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
rotated, err = userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
So(rotated, ShouldBeTrue)
token, err = ctx.getAuthTokenByID(token.Id)
model, err = ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
So(token.SeenAt, ShouldEqual, 0)
So(model, ShouldNotBeNil)
So(model.SeenAt, ShouldEqual, 0)
})
Convey("keeps prev token valid for 1 minute after it is confirmed", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
So(userToken, ShouldNotBeNil)
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUpUserToken, ShouldNotBeNil)
getTime = func() time.Time {
return t.Add(10 * time.Minute)
}
prevToken := token.UnhashedToken
refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
prevToken := userToken.UnhashedToken
rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
So(rotated, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(20 * time.Minute)
}
current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
currentUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
So(current, ShouldNotBeNil)
So(currentUserToken, ShouldNotBeNil)
prev, err := userAuthTokenService.LookupToken(prevToken)
prevUserToken, err := userAuthTokenService.LookupToken(prevToken)
So(err, ShouldBeNil)
So(prev, ShouldNotBeNil)
So(prevUserToken, ShouldNotBeNil)
})
Convey("will not mark token unseen when prev and current are the same", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
So(userToken, ShouldNotBeNil)
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUpUserToken, ShouldNotBeNil)
lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken)
lookedUpUserToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUpUserToken, ShouldNotBeNil)
lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
So(lookedUpModel, ShouldNotBeNil)
So(lookedUpModel.AuthTokenSeen, ShouldBeTrue)
})
Convey("Rotate token", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
So(userToken, ShouldNotBeNil)
prevToken := token.AuthToken
prevToken := userToken.AuthToken
Convey("Should rotate current token and previous token when auth token seen", func() {
updated, err := ctx.markAuthTokenAsSeen(token.Id)
updated, err := ctx.markAuthTokenAsSeen(userToken.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
@@ -265,11 +292,11 @@ func TestUserAuthToken(t *testing.T) {
return t.Add(10 * time.Minute)
}
refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
So(rotated, ShouldBeTrue)
storedToken, err := ctx.getAuthTokenByID(token.Id)
storedToken, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -278,7 +305,7 @@ func TestUserAuthToken(t *testing.T) {
prevToken = storedToken.AuthToken
updated, err = ctx.markAuthTokenAsSeen(token.Id)
updated, err = ctx.markAuthTokenAsSeen(userToken.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
@@ -286,11 +313,11 @@ func TestUserAuthToken(t *testing.T) {
return t.Add(20 * time.Minute)
}
refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
rotated, err = userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
So(rotated, ShouldBeTrue)
storedToken, err = ctx.getAuthTokenByID(token.Id)
storedToken, err = ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -299,17 +326,17 @@ func TestUserAuthToken(t *testing.T) {
})
Convey("Should rotate current token, but keep previous token when auth token not seen", func() {
token.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
userToken.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
getTime = func() time.Time {
return t.Add(2 * time.Minute)
}
refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
So(rotated, ShouldBeTrue)
storedToken, err := ctx.getAuthTokenByID(token.Id)
storedToken, err := ctx.getAuthTokenByID(userToken.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -318,6 +345,71 @@ func TestUserAuthToken(t *testing.T) {
})
})
Convey("When populating userAuthToken from UserToken should copy all properties", func() {
ut := models.UserToken{
Id: 1,
UserId: 2,
AuthToken: "a",
PrevAuthToken: "b",
UserAgent: "c",
ClientIp: "d",
AuthTokenSeen: true,
SeenAt: 3,
RotatedAt: 4,
CreatedAt: 5,
UpdatedAt: 6,
UnhashedToken: "e",
}
utBytes, err := json.Marshal(ut)
So(err, ShouldBeNil)
utJSON, err := simplejson.NewJson(utBytes)
So(err, ShouldBeNil)
utMap := utJSON.MustMap()
var uat userAuthToken
uat.fromUserToken(&ut)
uatBytes, err := json.Marshal(uat)
So(err, ShouldBeNil)
uatJSON, err := simplejson.NewJson(uatBytes)
So(err, ShouldBeNil)
uatMap := uatJSON.MustMap()
So(uatMap, ShouldResemble, utMap)
})
Convey("When populating userToken from userAuthToken should copy all properties", func() {
uat := userAuthToken{
Id: 1,
UserId: 2,
AuthToken: "a",
PrevAuthToken: "b",
UserAgent: "c",
ClientIp: "d",
AuthTokenSeen: true,
SeenAt: 3,
RotatedAt: 4,
CreatedAt: 5,
UpdatedAt: 6,
UnhashedToken: "e",
}
uatBytes, err := json.Marshal(uat)
So(err, ShouldBeNil)
uatJSON, err := simplejson.NewJson(uatBytes)
So(err, ShouldBeNil)
uatMap := uatJSON.MustMap()
var ut models.UserToken
err = uat.toUserToken(&ut)
So(err, ShouldBeNil)
utBytes, err := json.Marshal(ut)
So(err, ShouldBeNil)
utJSON, err := simplejson.NewJson(utBytes)
So(err, ShouldBeNil)
utMap := utJSON.MustMap()
So(utMap, ShouldResemble, uatMap)
})
Reset(func() {
getTime = time.Now
})
@@ -328,19 +420,16 @@ func createTestContext(t *testing.T) *testContext {
t.Helper()
sqlstore := sqlstore.InitTestDB(t)
tokenService := &UserAuthTokenServiceImpl{
tokenService := &UserAuthTokenService{
SQLStore: sqlstore,
Cfg: &setting.Cfg{
LoginCookieName: "grafana_session",
LoginCookieMaxDays: 7,
LoginDeleteExpiredTokensAfterDays: 30,
LoginCookieRotation: 10,
LoginMaxInactiveLifetimeDays: 7,
LoginMaxLifetimeDays: 30,
TokenRotationIntervalMinutes: 10,
},
log: log.New("test-logger"),
}
UrgentRotateTime = time.Minute
return &testContext{
sqlstore: sqlstore,
tokenService: tokenService,
@@ -349,7 +438,7 @@ func createTestContext(t *testing.T) *testContext {
type testContext struct {
sqlstore *sqlstore.SqlStore
tokenService *UserAuthTokenServiceImpl
tokenService *UserAuthTokenService
}
func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
@@ -376,3 +465,17 @@ func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
}
return rowsAffected == 1, nil
}
func (c *testContext) updateRotatedAt(id, rotatedAt int64) (bool, error) {
sess := c.sqlstore.NewSession()
res, err := sess.Exec("UPDATE user_auth_token SET rotated_at = ? WHERE id = ?", rotatedAt, id)
if err != nil {
return false, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return false, err
}
return rowsAffected == 1, nil
}

View File

@@ -1,12 +1,9 @@
package auth
import (
"errors"
)
"fmt"
// Typed errors
var (
ErrAuthTokenNotFound = errors.New("User auth token not found")
"github.com/grafana/grafana/pkg/models"
)
type userAuthToken struct {
@@ -23,3 +20,51 @@ type userAuthToken struct {
UpdatedAt int64
UnhashedToken string `xorm:"-"`
}
func userAuthTokenFromUserToken(ut *models.UserToken) *userAuthToken {
var uat userAuthToken
uat.fromUserToken(ut)
return &uat
}
func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error {
if uat == nil {
return fmt.Errorf("needs pointer to userAuthToken struct")
}
uat.Id = ut.Id
uat.UserId = ut.UserId
uat.AuthToken = ut.AuthToken
uat.PrevAuthToken = ut.PrevAuthToken
uat.UserAgent = ut.UserAgent
uat.ClientIp = ut.ClientIp
uat.AuthTokenSeen = ut.AuthTokenSeen
uat.SeenAt = ut.SeenAt
uat.RotatedAt = ut.RotatedAt
uat.CreatedAt = ut.CreatedAt
uat.UpdatedAt = ut.UpdatedAt
uat.UnhashedToken = ut.UnhashedToken
return nil
}
func (uat *userAuthToken) toUserToken(ut *models.UserToken) error {
if uat == nil {
return fmt.Errorf("needs pointer to userAuthToken struct")
}
ut.Id = uat.Id
ut.UserId = uat.UserId
ut.AuthToken = uat.AuthToken
ut.PrevAuthToken = uat.PrevAuthToken
ut.UserAgent = uat.UserAgent
ut.ClientIp = uat.ClientIp
ut.AuthTokenSeen = uat.AuthTokenSeen
ut.SeenAt = uat.SeenAt
ut.RotatedAt = uat.RotatedAt
ut.CreatedAt = uat.CreatedAt
ut.UpdatedAt = uat.UpdatedAt
ut.UnhashedToken = uat.UnhashedToken
return nil
}

View File

@@ -1,38 +0,0 @@
package auth
import (
"context"
"time"
)
func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error {
ticker := time.NewTicker(time.Hour * 12)
deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays)
for {
select {
case <-ticker.C:
srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() {
srv.deleteOldSession(deleteSessionAfter)
})
case <-ctx.Done():
return ctx.Err()
}
}
}
func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) {
sql := `DELETE from user_auth_token WHERE rotated_at < ?`
deleteBefore := getTime().Add(-deleteSessionAfter)
res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix())
if err != nil {
return 0, err
}
affected, err := res.RowsAffected()
srv.log.Info("deleted old sessions", "count", affected)
return affected, err
}

View File

@@ -1,36 +0,0 @@
package auth
import (
"fmt"
"testing"
"time"
. "github.com/smartystreets/goconvey/convey"
)
func TestUserAuthTokenCleanup(t *testing.T) {
Convey("Test user auth token cleanup", t, func() {
ctx := createTestContext(t)
insertToken := func(token string, prev string, rotatedAt int64) {
ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
_, err := ctx.sqlstore.NewSession().Insert(&ut)
So(err, ShouldBeNil)
}
// insert three old tokens that should be deleted
for i := 0; i < 3; i++ {
insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i))
}
// insert three active tokens that should not be deleted
for i := 0; i < 3; i++ {
insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix())
}
affected, err := ctx.tokenService.deleteOldSession(time.Hour)
So(err, ShouldBeNil)
So(affected, ShouldEqual, 3)
})
}

View File

@@ -0,0 +1,57 @@
package auth
import (
"context"
"time"
)
func (srv *UserAuthTokenService) Run(ctx context.Context) error {
ticker := time.NewTicker(time.Hour)
maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
})
if err != nil {
srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
}
for {
select {
case <-ticker.C:
err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
})
if err != nil {
srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
}
case <-ctx.Done():
return ctx.Err()
}
}
}
func (srv *UserAuthTokenService) deleteExpiredTokens(maxInactiveLifetime, maxLifetime time.Duration) (int64, error) {
createdBefore := getTime().Add(-maxLifetime)
rotatedBefore := getTime().Add(-maxInactiveLifetime)
srv.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore)
sql := `DELETE from user_auth_token WHERE created_at <= ? OR rotated_at <= ?`
res, err := srv.SQLStore.NewSession().Exec(sql, createdBefore.Unix(), rotatedBefore.Unix())
if err != nil {
return 0, err
}
affected, err := res.RowsAffected()
if err != nil {
srv.log.Error("failed to cleanup expired auth tokens", "error", err)
return 0, nil
}
srv.log.Info("cleanup of expired auth tokens done", "count", affected)
return affected, err
}

View File

@@ -0,0 +1,68 @@
package auth
import (
"fmt"
"testing"
"time"
. "github.com/smartystreets/goconvey/convey"
)
func TestUserAuthTokenCleanup(t *testing.T) {
Convey("Test user auth token cleanup", t, func() {
ctx := createTestContext(t)
ctx.tokenService.Cfg.LoginMaxInactiveLifetimeDays = 7
ctx.tokenService.Cfg.LoginMaxLifetimeDays = 30
insertToken := func(token string, prev string, createdAt, rotatedAt int64) {
ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, CreatedAt: createdAt, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
_, err := ctx.sqlstore.NewSession().Insert(&ut)
So(err, ShouldBeNil)
}
t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC)
getTime = func() time.Time {
return t
}
Convey("should delete tokens where token rotation age is older than or equal 7 days", func() {
from := t.Add(-7 * 24 * time.Hour)
// insert three old tokens that should be deleted
for i := 0; i < 3; i++ {
insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), from.Unix(), from.Unix())
}
// insert three active tokens that should not be deleted
for i := 0; i < 3; i++ {
from = from.Add(time.Second)
insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), from.Unix())
}
affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour)
So(err, ShouldBeNil)
So(affected, ShouldEqual, 3)
})
Convey("should delete tokens where token age is older than or equal 30 days", func() {
from := t.Add(-30 * 24 * time.Hour)
fromRotate := t.Add(-time.Second)
// insert three old tokens that should be deleted
for i := 0; i < 3; i++ {
insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), from.Unix(), fromRotate.Unix())
}
// insert three active tokens that should not be deleted
for i := 0; i < 3; i++ {
from = from.Add(time.Second)
insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), fromRotate.Unix())
}
affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour)
So(err, ShouldBeNil)
So(affected, ShouldEqual, 3)
})
})
}

View File

@@ -89,6 +89,8 @@ var (
EmailCodeValidMinutes int
DataProxyWhiteList map[string]bool
DisableBruteForceLoginProtection bool
CookieSecure bool
CookieSameSite http.SameSite
// Snapshots
ExternalSnapshotUrl string
@@ -118,8 +120,10 @@ var (
ViewersCanEdit bool
// Http auth
AdminUser string
AdminPassword string
AdminUser string
AdminPassword string
LoginCookieName string
LoginMaxLifetimeDays int
AnonymousEnabled bool
AnonymousOrgName string
@@ -215,7 +219,11 @@ type Cfg struct {
RendererLimit int
RendererLimitAlerting int
// Security
DisableBruteForceLoginProtection bool
CookieSecure bool
CookieSameSite http.SameSite
TempDataLifetime time.Duration
MetricsEndpointEnabled bool
MetricsEndpointBasicAuthUsername string
@@ -224,13 +232,11 @@ type Cfg struct {
DisableSanitizeHtml bool
EnterpriseLicensePath string
LoginCookieName string
LoginCookieMaxDays int
LoginCookieRotation int
LoginDeleteExpiredTokensAfterDays int
LoginCookieSameSite http.SameSite
SecurityHTTPSCookies bool
// Auth
LoginCookieName string
LoginMaxInactiveLifetimeDays int
LoginMaxLifetimeDays int
TokenRotationIntervalMinutes int
}
type CommandLineArgs struct {
@@ -554,30 +560,6 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
ApplicationName = APP_NAME_ENTERPRISE
}
//login
login := iniFile.Section("login")
cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
samesiteString := login.Key("cookie_samesite").MustString("lax")
validSameSiteValues := map[string]http.SameSite{
"lax": http.SameSiteLaxMode,
"strict": http.SameSiteStrictMode,
"none": http.SameSiteDefaultMode,
}
if samesite, ok := validSameSiteValues[samesiteString]; ok {
cfg.LoginCookieSameSite = samesite
} else {
cfg.LoginCookieSameSite = http.SameSiteLaxMode
}
cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
if cfg.LoginCookieRotation < 2 {
cfg.LoginCookieRotation = 2
}
Env = iniFile.Section("").Key("app_mode").MustString("development")
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
@@ -621,9 +603,26 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
SecretKey = security.Key("secret_key").String()
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
CookieSecure = security.Key("cookie_secure").MustBool(false)
cfg.CookieSecure = CookieSecure
samesiteString := security.Key("cookie_samesite").MustString("lax")
validSameSiteValues := map[string]http.SameSite{
"lax": http.SameSiteLaxMode,
"strict": http.SameSiteStrictMode,
"none": http.SameSiteDefaultMode,
}
if samesite, ok := validSameSiteValues[samesiteString]; ok {
CookieSameSite = samesite
cfg.CookieSameSite = CookieSameSite
} else {
CookieSameSite = http.SameSiteLaxMode
cfg.CookieSameSite = CookieSameSite
}
// read snapshots settings
snapshots := iniFile.Section("snapshots")
ExternalSnapshotUrl = snapshots.Key("external_snapshot_url").String()
@@ -661,6 +660,19 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// auth
auth := iniFile.Section("auth")
LoginCookieName = auth.Key("login_cookie_name").MustString("grafana_session")
cfg.LoginCookieName = LoginCookieName
cfg.LoginMaxInactiveLifetimeDays = auth.Key("login_maximum_inactive_lifetime_days").MustInt(7)
LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30)
cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays
cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10)
if cfg.TokenRotationIntervalMinutes < 2 {
cfg.TokenRotationIntervalMinutes = 2
}
DisableLoginForm = auth.Key("disable_login_form").MustBool(false)
DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false)
OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false)

View File

@@ -1,4 +1,5 @@
import { Emitter } from './utils/emitter';
const appEvents = new Emitter();
export const appEvents = new Emitter();
export default appEvents;

View File

@@ -0,0 +1,42 @@
import React, { FunctionComponent } from 'react';
import { AppNotificationSeverity } from 'app/types';
interface Props {
title: string;
icon?: string;
text?: string;
severity: AppNotificationSeverity;
onClose?: () => void;
}
function getIconFromSeverity(severity: AppNotificationSeverity): string {
switch (severity) {
case AppNotificationSeverity.Error: {
return 'fa fa-exclamation-triangle';
}
case AppNotificationSeverity.Success: {
return 'fa fa-check';
}
default:
return null;
}
}
export const AlertBox: FunctionComponent<Props> = ({ title, icon, text, severity, onClose }) => {
return (
<div className={`alert alert-${severity}`}>
<div className="alert-icon">
<i className={icon || getIconFromSeverity(severity)} />
</div>
<div className="alert-body">
<div className="alert-title">{title}</div>
{text && <div className="alert-text">{text}</div>}
</div>
{onClose && (
<button type="button" className="alert-close" onClick={onClose}>
<i className="fa fa fa-remove" />
</button>
)}
</div>
);
};

View File

@@ -1,5 +1,6 @@
import React, { Component } from 'react';
import { AppNotification } from 'app/types';
import { AlertBox } from '../AlertBox/AlertBox';
interface Props {
appNotification: AppNotification;
@@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component<Props> {
const { appNotification, onClearNotification } = this.props;
return (
<div className={`alert-${appNotification.severity} alert`}>
<div className="alert-icon">
<i className={appNotification.icon} />
</div>
<div className="alert-body">
<div className="alert-title">{appNotification.title}</div>
<div className="alert-text">{appNotification.text}</div>
</div>
<button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
<i className="fa fa fa-remove" />
</button>
</div>
<AlertBox
severity={appNotification.severity}
title={appNotification.title}
text={appNotification.text}
icon={appNotification.icon}
onClose={() => onClearNotification(appNotification.id)}
/>
);
}
}

View File

@@ -17,13 +17,10 @@ interface Props {
}
class Page extends Component<Props> {
private bodyClass = 'is-react';
private body = document.body;
static Header = PageHeader;
static Contents = PageContents;
componentDidMount() {
this.body.classList.add(this.bodyClass);
this.updateTitle();
}
@@ -33,10 +30,6 @@ class Page extends Component<Props> {
}
}
componentWillUnmount() {
this.body.classList.remove(this.bodyClass);
}
updateTitle = () => {
const title = this.getPageTitle;
document.title = title ? title + ' - Grafana' : 'Grafana';

View File

@@ -1,40 +0,0 @@
import coreModule from 'app/core/core_module';
const template = `
<div class="scroll-canvas">
<navbar model="model"></navbar>
<div class="page-container">
<div class="page-header">
<h1>
<i class="{{::model.node.icon}}" ng-if="::model.node.icon"></i>
<img ng-src="{{::model.node.img}}" ng-if="::model.node.img"></i>
{{::model.node.text}}
</h1>
<div class="page-header__actions" ng-transclude="header"></div>
</div>
<div class="page-body" ng-transclude="body">
</div>
</div>
</div>
`;
export function gfPageDirective() {
return {
restrict: 'E',
template: template,
scope: {
model: '=',
},
transclude: {
header: '?gfPageHeader',
body: 'gfPageBody',
},
link: (scope, elem, attrs) => {
console.log(scope);
},
};
}
coreModule.directive('gfPage', gfPageDirective);

View File

@@ -1,43 +0,0 @@
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export function pageScrollbar() {
return {
restrict: 'A',
link: (scope, elem, attrs) => {
let lastPos = 0;
appEvents.on(
'dash-scroll',
evt => {
if (evt.restore) {
elem[0].scrollTop = lastPos;
return;
}
lastPos = elem[0].scrollTop;
if (evt.animate) {
elem.animate({ scrollTop: evt.pos }, 500);
} else {
elem[0].scrollTop = evt.pos;
}
},
scope
);
scope.$on('$routeChangeSuccess', () => {
lastPos = 0;
elem[0].scrollTop = 0;
// Focus page to enable scrolling by keyboard
elem[0].focus({ preventScroll: true });
});
elem[0].tabIndex = -1;
// Focus page to enable scrolling by keyboard
elem[0].focus({ preventScroll: true });
},
};
}
coreModule.directive('pageScrollbar', pageScrollbar);

View File

@@ -68,5 +68,5 @@ const bootData = (window as any).grafanaBootData || {
const options = bootData.settings;
options.bootData = bootData;
const config = new Settings(options);
export const config = new Settings(options);
export default config;

View File

@@ -1,4 +1,5 @@
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
import { getMessageFromError } from 'app/core/utils/errors';
const defaultSuccessNotification: AppNotification = {
title: '',
@@ -31,12 +32,14 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti
id: Date.now(),
});
export const createErrorNotification = (title: string, text?: string): AppNotification => ({
...defaultErrorNotification,
title: title,
text: text,
id: Date.now(),
});
export const createErrorNotification = (title: string, text?: any): AppNotification => {
return {
...defaultErrorNotification,
title: title,
text: getMessageFromError(text),
id: Date.now(),
};
};
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
...defaultWarningNotification,

View File

@@ -43,8 +43,6 @@ import { helpModal } from './components/help/help';
import { JsonExplorer } from './components/json_explorer/json_explorer';
import { NavModelSrv, NavModel } from './nav_model_srv';
import { geminiScrollbar } from './components/scroll/scroll';
import { pageScrollbar } from './components/scroll/page_scroll';
import { gfPageDirective } from './components/gf_page';
import { orgSwitcher } from './components/org_switcher';
import { profiler } from './profiler';
import { registerAngularDirectives } from './angular_wrappers';
@@ -79,8 +77,6 @@ export {
NavModelSrv,
NavModel,
geminiScrollbar,
pageScrollbar,
gfPageDirective,
orgSwitcher,
manageDashboardsDirective,
TimeSeries,

View File

@@ -8,12 +8,13 @@ export const initialState: LocationState = {
path: '',
query: {},
routeParams: {},
replace: false,
};
export const locationReducer = (state = initialState, action: Action): LocationState => {
switch (action.type) {
case CoreActionTypes.UpdateLocation: {
const { path, routeParams } = action.payload;
const { path, routeParams, replace } = action.payload;
let query = action.payload.query || state.query;
if (action.payload.partial) {
@@ -26,6 +27,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
path: path || state.path,
query: { ...query },
routeParams: routeParams || state.routeParams,
replace: replace === true,
};
}
}

View File

@@ -53,5 +53,20 @@ export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCrea
return { create };
};
export interface NoPayloadActionCreatorMock extends NoPayloadActionCreator {
calls: number;
}
export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): NoPayloadActionCreatorMock => {
const mock: NoPayloadActionCreatorMock = Object.assign(
(): ActionOf<undefined> => {
mock.calls++;
return { type: creator.type, payload: undefined };
},
{ type: creator.type, calls: 0 }
);
return mock;
};
// Should only be used by tests
export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);

View File

@@ -1,4 +1,2 @@
import { actionCreatorFactory } from './actionCreatorFactory';
import { reducerFactory } from './reducerFactory';
export { actionCreatorFactory, reducerFactory };
export * from './actionCreatorFactory';
export * from './reducerFactory';

View File

@@ -0,0 +1,14 @@
const backendSrv = {
get: jest.fn(),
getDashboard: jest.fn(),
getDashboardByUid: jest.fn(),
getFolderByUid: jest.fn(),
post: jest.fn(),
};
export function getBackendSrv() {
return backendSrv;
}

View File

@@ -46,6 +46,10 @@ export class BridgeSrv {
if (angularUrl !== url) {
this.$timeout(() => {
this.$location.url(url);
// some state changes should not trigger new browser history
if (state.location.replace) {
this.$location.replace();
}
});
console.log('store updating angular $location.url', url);
}

View File

@@ -104,7 +104,7 @@ export class KeybindingSrv {
}
if (search.fullscreen) {
this.$rootScope.appEvent('panel-change-view', { fullscreen: false, edit: false });
appEvents.emit('panel-change-view', { fullscreen: false, edit: false });
return;
}
@@ -174,7 +174,7 @@ export class KeybindingSrv {
// edit panel
this.bind('e', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
this.$rootScope.appEvent('panel-change-view', {
appEvents.emit('panel-change-view', {
fullscreen: true,
edit: true,
panelId: dashboard.meta.focusPanelId,
@@ -186,7 +186,7 @@ export class KeybindingSrv {
// view panel
this.bind('v', () => {
if (dashboard.meta.focusPanelId) {
this.$rootScope.appEvent('panel-change-view', {
appEvents.emit('panel-change-view', {
fullscreen: true,
edit: null,
panelId: dashboard.meta.focusPanelId,
@@ -212,9 +212,7 @@ export class KeybindingSrv {
// delete panel
this.bind('p r', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
this.$rootScope.appEvent('panel-remove', {
panelId: dashboard.meta.focusPanelId,
});
appEvents.emit('remove-panel', dashboard.meta.focusPanelId);
dashboard.meta.focusPanelId = 0;
}
});

View File

@@ -0,0 +1,17 @@
import _ from 'lodash';
export function getMessageFromError(err: any): string | null {
if (err && !_.isString(err)) {
if (err.message) {
return err.message;
} else if (err.data && err.data.message) {
return err.data.message;
} else if (err.statusText) {
return err.statusText;
} else {
return JSON.stringify(err);
}
}
return null;
}

View File

@@ -1,6 +1,6 @@
import config from 'app/core/config';
export const stripBaseFromUrl = url => {
export const stripBaseFromUrl = (url: string): string => {
const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
const urlWithoutBase =

View File

@@ -20,12 +20,25 @@ export class SemVersion {
isGtOrEq(version: string): boolean {
const compared = new SemVersion(version);
return !(this.major < compared.major || this.minor < compared.minor || this.patch < compared.patch);
for (let i = 0; i < this.comparable.length; ++i) {
if (this.comparable[i] > compared.comparable[i]) {
return true;
}
if (this.comparable[i] < compared.comparable[i]) {
return false;
}
}
return true;
}
isValid(): boolean {
return _.isNumber(this.major);
}
get comparable() {
return [this.major, this.minor, this.patch];
}
}
export function isVersionGtOrEq(a: string, b: string): boolean {

View File

@@ -2,6 +2,7 @@ import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
export class AnnotationsEditorCtrl {
mode: any;
@@ -10,6 +11,7 @@ export class AnnotationsEditorCtrl {
currentAnnotation: any;
currentDatasource: any;
currentIsNew: any;
dashboard: DashboardModel;
annotationDefaults: any = {
name: '',
@@ -26,9 +28,10 @@ export class AnnotationsEditorCtrl {
constructor($scope, private datasourceSrv) {
$scope.ctrl = this;
this.dashboard = $scope.dashboard;
this.mode = 'list';
this.datasources = datasourceSrv.getAnnotationSources();
this.annotations = $scope.dashboard.annotations.list;
this.annotations = this.dashboard.annotations.list;
this.reset();
this.onColorChange = this.onColorChange.bind(this);
@@ -78,11 +81,13 @@ export class AnnotationsEditorCtrl {
this.annotations.push(this.currentAnnotation);
this.reset();
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
removeAnnotation(annotation) {
const index = _.indexOf(this.annotations, annotation);
this.annotations.splice(index, 1);
this.dashboard.updateSubmenuVisibility();
}
onColorChange(newColor) {

View File

@@ -1,10 +1,12 @@
import _ from 'lodash';
import angular from 'angular';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
export class AdHocFiltersCtrl {
segments: any;
variable: any;
dashboard: DashboardModel;
removeTagFilterSegment: any;
/** @ngInject */
@@ -14,14 +16,13 @@ export class AdHocFiltersCtrl {
private $q,
private variableSrv,
$scope,
private $rootScope
) {
this.removeTagFilterSegment = uiSegmentSrv.newSegment({
fake: true,
value: '-- remove filter --',
});
this.buildSegmentModel();
this.$rootScope.onAppEvent('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
this.dashboard.events.on('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
}
buildSegmentModel() {
@@ -171,6 +172,7 @@ export function adHocFiltersComponent() {
controllerAs: 'ctrl',
scope: {
variable: '=',
dashboard: '=',
},
};
}

View File

@@ -1,5 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
import { DashboardModel } from 'app/features/dashboard/state';
export let iconMap = {
'external link': 'fa-external-link',
@@ -12,7 +13,7 @@ export let iconMap = {
};
export class DashLinksEditorCtrl {
dashboard: any;
dashboard: DashboardModel;
iconMap: any;
mode: any;
link: any;
@@ -40,6 +41,7 @@ export class DashLinksEditorCtrl {
addLink() {
this.dashboard.links.push(this.link);
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
editLink(link) {

View File

@@ -0,0 +1,253 @@
// Libaries
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
import { appEvents } from 'app/core/app_events';
import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
// Components
import { DashNavButton } from './DashNavButton';
// State
import { updateLocation } from 'app/core/actions';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel;
editview: string;
isEditing: boolean;
isFullscreen: boolean;
$injector: any;
updateLocation: typeof updateLocation;
onAddPanel: () => void;
}
export class DashNav extends PureComponent<Props> {
timePickerEl: HTMLElement;
timepickerCmp: AngularComponent;
playlistSrv: PlaylistSrv;
constructor(props: Props) {
super(props);
this.playlistSrv = this.props.$injector.get('playlistSrv');
}
componentDidMount() {
const loader = getAngularLoader();
const template =
'<gf-time-picker class="gf-timepicker-nav" dashboard="dashboard" ng-if="!dashboard.timepicker.hidden" />';
const scopeProps = { dashboard: this.props.dashboard };
this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template);
}
componentWillUnmount() {
if (this.timepickerCmp) {
this.timepickerCmp.destroy();
}
}
onOpenSearch = () => {
appEvents.emit('show-dash-search');
};
onClose = () => {
if (this.props.editview) {
this.props.updateLocation({
query: { editview: null },
partial: true,
});
} else {
this.props.updateLocation({
query: { panelId: null, edit: null, fullscreen: null },
partial: true,
});
}
};
onToggleTVMode = () => {
appEvents.emit('toggle-kiosk-mode');
};
onSave = () => {
const { $injector } = this.props;
const dashboardSrv = $injector.get('dashboardSrv');
dashboardSrv.saveDashboard();
};
onOpenSettings = () => {
this.props.updateLocation({
query: { editview: 'settings' },
partial: true,
});
};
onStarDashboard = () => {
const { dashboard, $injector } = this.props;
const dashboardSrv = $injector.get('dashboardSrv');
dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => {
dashboard.meta.isStarred = newState;
this.forceUpdate();
});
};
onPlaylistPrev = () => {
this.playlistSrv.prev();
};
onPlaylistNext = () => {
this.playlistSrv.next();
};
onPlaylistStop = () => {
this.playlistSrv.stop();
this.forceUpdate();
};
onOpenShare = () => {
const $rootScope = this.props.$injector.get('$rootScope');
const modalScope = $rootScope.$new();
modalScope.tabIndex = 0;
modalScope.dashboard = this.props.dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
};
render() {
const { dashboard, isFullscreen, editview, onAddPanel } = this.props;
const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta;
const { snapshot } = dashboard;
const haveFolder = dashboard.meta.folderId > 0;
const snapshotUrl = snapshot && snapshot.originalUrl;
return (
<div className="navbar">
<div>
<a className="navbar-page-btn" onClick={this.onOpenSearch}>
<i className="gicon gicon-dashboard" />
{haveFolder && <span className="navbar-page-btn--folder">{folderTitle} / </span>}
{dashboard.title}
<i className="fa fa-caret-down" />
</a>
</div>
<div className="navbar__spacer" />
{this.playlistSrv.isPlaying && (
<div className="navbar-buttons navbar-buttons--playlist">
<DashNavButton
tooltip="Go to previous dashboard"
classSuffix="tight"
icon="fa fa-step-backward"
onClick={this.onPlaylistPrev}
/>
<DashNavButton
tooltip="Stop playlist"
classSuffix="tight"
icon="fa fa-stop"
onClick={this.onPlaylistStop}
/>
<DashNavButton
tooltip="Go to next dashboard"
classSuffix="tight"
icon="fa fa-forward"
onClick={this.onPlaylistNext}
/>
</div>
)}
<div className="navbar-buttons navbar-buttons--actions">
{canSave && (
<DashNavButton
tooltip="Add panel"
classSuffix="add-panel"
icon="gicon gicon-add-panel"
onClick={onAddPanel}
/>
)}
{canStar && (
<DashNavButton
tooltip="Mark as favorite"
classSuffix="star"
icon={`${isStarred ? 'fa fa-star' : 'fa fa-star-o'}`}
onClick={this.onStarDashboard}
/>
)}
{canShare && (
<DashNavButton
tooltip="Share dashboard"
classSuffix="share"
icon="fa fa-share-square-o"
onClick={this.onOpenShare}
/>
)}
{canSave && (
<DashNavButton tooltip="Save dashboard" classSuffix="save" icon="fa fa-save" onClick={this.onSave} />
)}
{snapshotUrl && (
<DashNavButton
tooltip="Open original dashboard"
classSuffix="snapshot-origin"
icon="fa fa-link"
href={snapshotUrl}
/>
)}
{showSettings && (
<DashNavButton
tooltip="Dashboard settings"
classSuffix="settings"
icon="fa fa-cog"
onClick={this.onOpenSettings}
/>
)}
</div>
<div className="navbar-buttons navbar-buttons--tv">
<DashNavButton
tooltip="Cycke view mode"
classSuffix="tv"
icon="fa fa-desktop"
onClick={this.onToggleTVMode}
/>
</div>
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
{(isFullscreen || editview) && (
<div className="navbar-buttons navbar-buttons--close">
<DashNavButton
tooltip="Back to dashboard"
classSuffix="primary"
icon="fa fa-reply"
onClick={this.onClose}
/>
</div>
)}
</div>
);
}
}
const mapStateToProps = () => ({});
const mapDispatchToProps = {
updateLocation,
};
export default connect(mapStateToProps, mapDispatchToProps)(DashNav);

View File

@@ -0,0 +1,33 @@
// Libraries
import React, { FunctionComponent } from 'react';
// Components
import { Tooltip } from '@grafana/ui';
interface Props {
icon: string;
tooltip: string;
classSuffix: string;
onClick?: () => void;
href?: string;
}
export const DashNavButton: FunctionComponent<Props> = ({ icon, tooltip, classSuffix, onClick, href }) => {
if (onClick) {
return (
<Tooltip content={tooltip}>
<button className={`btn navbar-button navbar-button--${classSuffix}`} onClick={onClick}>
<i className={icon} />
</button>
</Tooltip>
);
}
return (
<Tooltip content={tooltip}>
<a className={`btn navbar-button navbar-button--${classSuffix}`} href={href}>
<i className={icon} />
</a>
</Tooltip>
);
};

View File

@@ -1,119 +0,0 @@
import moment from 'moment';
import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core';
import { DashboardModel } from '../../state/DashboardModel';
export class DashNavCtrl {
dashboard: DashboardModel;
navModel: NavModel;
titleTooltip: string;
/** @ngInject */
constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) {
appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope);
if (this.dashboard.meta.isSnapshot) {
const meta = this.dashboard.meta;
this.titleTooltip = 'Created: &nbsp;' + moment(meta.created).calendar();
if (meta.expires) {
this.titleTooltip += '<br>Expires: &nbsp;' + moment(meta.expires).fromNow() + '<br>';
}
}
}
toggleSettings() {
const search = this.$location.search();
if (search.editview) {
delete search.editview;
} else {
search.editview = 'settings';
}
this.$location.search(search);
}
toggleViewMode() {
appEvents.emit('toggle-kiosk-mode');
}
close() {
const search = this.$location.search();
if (search.editview) {
delete search.editview;
} else if (search.fullscreen) {
delete search.fullscreen;
delete search.edit;
delete search.tab;
delete search.panelId;
}
this.$location.search(search);
}
starDashboard() {
this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => {
this.dashboard.meta.isStarred = newState;
});
}
shareDashboard(tabIndex) {
const modalScope = this.$scope.$new();
modalScope.tabIndex = tabIndex;
modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
}
hideTooltip(evt) {
angular.element(evt.currentTarget).tooltip('hide');
}
saveDashboard() {
return this.dashboardSrv.saveDashboard();
}
showSearch() {
if (this.dashboard.meta.fullscreen) {
this.close();
return;
}
appEvents.emit('show-dash-search');
}
addPanel() {
appEvents.emit('dash-scroll', { animate: true, evt: 0 });
if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') {
return; // Return if the "Add panel" exists already
}
this.dashboard.addPanel({
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
title: 'Panel Title',
});
}
navItemClicked(navItem, evt) {
if (navItem.clickHandler) {
navItem.clickHandler();
evt.preventDefault();
}
}
}
export function dashNavDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl,
bindToController: true,
controllerAs: 'ctrl',
transclude: true,
scope: { dashboard: '=' },
};
}
angular.module('grafana.directives').directive('dashnav', dashNavDirective);

View File

@@ -1 +1,2 @@
export { DashNavCtrl } from './DashNavCtrl';
import DashNav from './DashNav';
export { DashNav };

View File

@@ -1,61 +0,0 @@
<div class="navbar">
<div>
<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
<i class="gicon gicon-dashboard"></i>
<span ng-if="ctrl.dashboard.meta.folderId > 0" class="navbar-page-btn--folder">{{ctrl.dashboard.meta.folderTitle}} / </span>{{ctrl.dashboard.title}}
<i class="fa fa-caret-down"></i>
</a>
</div>
<div class="navbar__spacer"></div>
<div class="navbar-buttons navbar-buttons--playlist" ng-if="ctrl.playlistSrv.isPlaying">
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.prev()"><i class="fa fa-step-backward"></i></a>
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.stop()"><i class="fa fa-stop"></i></a>
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.next()"><i class="fa fa-step-forward"></i></a>
</div>
<div class="navbar-buttons navbar-buttons--actions">
<button class="btn navbar-button navbar-button--add-panel" ng-show="::ctrl.dashboard.meta.canSave" bs-tooltip="'Add panel'" data-placement="bottom" ng-click="ctrl.addPanel()">
<i class="gicon gicon-add-panel"></i>
</button>
<button class="btn navbar-button navbar-button--star" ng-show="::ctrl.dashboard.meta.canStar" ng-click="ctrl.starDashboard()" bs-tooltip="'Mark as favorite'" data-placement="bottom">
<i class="fa" ng-class="{'fa-star-o': !ctrl.dashboard.meta.isStarred, 'fa-star': ctrl.dashboard.meta.isStarred}"></i>
</button>
<button class="btn navbar-button navbar-button--share" ng-show="::ctrl.dashboard.meta.canShare" ng-click="ctrl.shareDashboard(0)" bs-tooltip="'Share dashboard'" data-placement="bottom">
<i class="fa fa-share-square-o"></i></a>
</button>
<button class="btn navbar-button navbar-button--save" ng-show="ctrl.dashboard.meta.canSave" ng-click="ctrl.saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom">
<i class="fa fa-save"></i>
</button>
<a class="btn navbar-button navbar-button--snapshot-origin" ng-if="::ctrl.dashboard.snapshot.originalUrl" href="{{ctrl.dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom">
<i class="fa fa-link"></i>
</a>
<button class="btn navbar-button navbar-button--settings" ng-click="ctrl.toggleSettings()" bs-tooltip="'Dashboard Settings'" data-placement="bottom" ng-show="ctrl.dashboard.meta.showSettings">
<i class="fa fa-cog"></i>
</button>
</div>
<div class="navbar-buttons navbar-buttons--tv">
<button class="btn navbar-button navbar-button--tv" ng-click="ctrl.toggleViewMode()" bs-tooltip="'Cycle view mode'" data-placement="bottom">
<i class="fa fa-desktop"></i>
</button>
</div>
<gf-time-picker class="gf-timepicker-nav" dashboard="ctrl.dashboard" ng-if="!ctrl.dashboard.timepicker.hidden"></gf-time-picker>
<div class="navbar-buttons navbar-buttons--close">
<button class="btn navbar-button navbar-button--primary" ng-click="ctrl.close()" bs-tooltip="'Back to dashboard'" data-placement="bottom">
<i class="fa fa-reply"></i>
</button>
</div>
</div>
<dashboard-search></dashboard-search>

View File

@@ -9,6 +9,7 @@ describe('DashboardRow', () => {
beforeEach(() => {
dashboardMock = {
toggleRow: jest.fn(),
on: jest.fn(),
meta: {
canEdit: true,
},

View File

@@ -18,11 +18,11 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
collapsed: this.props.panel.collapsed,
};
appEvents.on('template-variable-value-updated', this.onVariableUpdated);
this.props.dashboard.on('template-variable-value-updated', this.onVariableUpdated);
}
componentWillUnmount() {
appEvents.off('template-variable-value-updated', this.onVariableUpdated);
this.props.dashboard.off('template-variable-value-updated', this.onVariableUpdated);
}
onVariableUpdated = () => {

View File

@@ -0,0 +1,36 @@
// Libaries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel | null;
}
export class DashboardSettings extends PureComponent<Props> {
element: HTMLElement;
angularCmp: AngularComponent;
componentDidMount() {
const loader = getAngularLoader();
const template = '<dashboard-settings dashboard="dashboard" class="dashboard-settings" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
render() {
return <div className="panel-height-helper" ref={element => this.element = element} />;
}
}

View File

@@ -1 +1,2 @@
export { SettingsCtrl } from './SettingsCtrl';
export { DashboardSettings } from './DashboardSettings';

View File

@@ -0,0 +1,36 @@
// Libaries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel | null;
}
export class SubMenu extends PureComponent<Props> {
element: HTMLElement;
angularCmp: AngularComponent;
componentDidMount() {
const loader = getAngularLoader();
const template = '<dashboard-submenu dashboard="dashboard" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
render() {
return <div ref={element => this.element = element} />;
}
}

View File

@@ -1 +1,2 @@
export { SubMenuCtrl } from './SubMenuCtrl';
export { SubMenu } from './SubMenu';

View File

@@ -7,7 +7,7 @@
<value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
<input type="text" ng-if="variable.type === 'textbox'" ng-model="variable.query" class="gf-form-input width-12" ng-blur="variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ng-keydown="$event.keyCode === 13 && variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ></input>
</div>
<ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable"></ad-hoc-filters>
<ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable" dashboard="ctrl.dashboard"></ad-hoc-filters>
</div>
<div ng-if="ctrl.dashboard.annotations.list.length > 0">

View File

@@ -1,156 +0,0 @@
// Utils
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import coreModule from 'app/core/core_module';
import { removePanel } from 'app/features/dashboard/utils/panel';
// Services
import { AnnotationsSrv } from '../../annotations/annotations_srv';
// Types
import { DashboardModel } from '../state/DashboardModel';
export class DashboardCtrl {
dashboard: DashboardModel;
dashboardViewState: any;
loadedFallbackDashboard: boolean;
editTab: number;
/** @ngInject */
constructor(
private $scope,
private keybindingSrv,
private timeSrv,
private variableSrv,
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
private annotationsSrv: AnnotationsSrv,
public playlistSrv
) {
// temp hack due to way dashboards are loaded
// can't use controllerAs on route yet
$scope.ctrl = this;
// TODO: break out settings view to separate view & controller
this.editTab = 0;
// funcs called from React component bindings and needs this binding
this.getPanelContainer = this.getPanelContainer.bind(this);
}
setupDashboard(data) {
try {
this.setupDashboardInternal(data);
} catch (err) {
this.onInitFailed(err, 'Dashboard init failed', true);
}
}
setupDashboardInternal(data) {
const dashboard = this.dashboardSrv.create(data.dashboard, data.meta);
this.dashboardSrv.setCurrent(dashboard);
// init services
this.timeSrv.init(dashboard);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
this.variableSrv
.init(dashboard)
// template values failes are non fatal
.catch(this.onInitFailed.bind(this, 'Templating init failed', false))
// continue
.finally(() => {
this.dashboard = dashboard;
this.dashboard.processRepeats();
this.dashboard.updateSubmenuVisibility();
this.dashboard.autoFitPanels(window.innerHeight);
this.unsavedChangesSrv.init(dashboard, this.$scope);
// TODO refactor ViewStateSrv
this.$scope.dashboard = dashboard;
this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope);
this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
this.setWindowTitleAndTheme();
appEvents.emit('dashboard-initialized', dashboard);
})
.catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
}
onInitFailed(msg, fatal, err) {
console.log(msg, err);
if (err.data && err.data.message) {
err.message = err.data.message;
} else if (!err.message) {
err = { message: err.toString() };
}
this.$scope.appEvent('alert-error', [msg, err.message]);
// protect against recursive fallbacks
if (fatal && !this.loadedFallbackDashboard) {
this.loadedFallbackDashboard = true;
this.setupDashboard({ dashboard: { title: 'Dashboard Init failed' } });
}
}
templateVariableUpdated() {
this.dashboard.processRepeats();
}
setWindowTitleAndTheme() {
window.document.title = config.windowTitlePrefix + this.dashboard.title;
}
showJsonEditor(evt, options) {
const model = {
object: options.object,
updateHandler: options.updateHandler,
};
this.$scope.appEvent('show-dash-editor', {
src: 'public/app/partials/edit_json.html',
model: model,
});
}
getDashboard() {
return this.dashboard;
}
getPanelContainer() {
return this;
}
onRemovingPanel(evt, options) {
options = options || {};
if (!options.panelId) {
return;
}
const panelInfo = this.dashboard.getPanelInfoById(options.panelId);
removePanel(this.dashboard, panelInfo.panel, true);
}
onDestroy() {
if (this.dashboard) {
this.dashboard.destroy();
}
}
init(dashboard) {
this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
this.$scope.$on('$destroy', this.onDestroy.bind(this));
this.setupDashboard(dashboard);
}
}
coreModule.controller('DashboardCtrl', DashboardCtrl);

View File

@@ -0,0 +1,251 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { DashboardPage, Props, State } from './DashboardPage';
import { DashboardModel } from '../state';
import { cleanUpDashboard } from '../state/actions';
import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux';
import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
jest.mock('sass/_variables.scss', () => ({
panelhorizontalpadding: 10,
panelVerticalPadding: 10,
}));
jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
interface ScenarioContext {
cleanUpDashboardMock: NoPayloadActionCreatorMock;
dashboard?: DashboardModel;
setDashboardProp: (overrides?: any, metaOverrides?: any) => void;
wrapper?: ShallowWrapper<Props, State, DashboardPage>;
mount: (propOverrides?: Partial<Props>) => void;
setup?: (fn: () => void) => void;
}
function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel {
const data = Object.assign({
title: 'My dashboard',
panels: [
{
id: 1,
type: 'graph',
title: 'My graph',
gridPos: { x: 0, y: 0, w: 1, h: 1 },
},
],
}, overrides);
const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides);
return new DashboardModel(data, meta);
}
function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) => void) {
describe(description, () => {
let setupFn: () => void;
const ctx: ScenarioContext = {
cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard),
setup: fn => {
setupFn = fn;
},
setDashboardProp: (overrides?: any, metaOverrides?: any) => {
ctx.dashboard = getTestDashboard(overrides, metaOverrides);
ctx.wrapper.setProps({ dashboard: ctx.dashboard });
},
mount: (propOverrides?: Partial<Props>) => {
const props: Props = {
urlSlug: 'my-dash',
$scope: {},
urlUid: '11',
$injector: {},
routeInfo: DashboardRouteInfo.Normal,
urlEdit: false,
urlFullscreen: false,
initPhase: DashboardInitPhase.NotStarted,
isInitSlow: false,
initDashboard: jest.fn(),
updateLocation: jest.fn(),
notifyApp: jest.fn(),
cleanUpDashboard: ctx.cleanUpDashboardMock,
dashboard: null,
};
Object.assign(props, propOverrides);
ctx.dashboard = props.dashboard;
ctx.wrapper = shallow(<DashboardPage {...props} />);
}
};
beforeEach(() => {
setupFn();
});
scenarioFn(ctx);
});
}
describe('DashboardPage', () => {
dashboardPageScenario("Given initial state", (ctx) => {
ctx.setup(() => {
ctx.mount();
});
it('Should render nothing', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario("Dashboard is fetching slowly", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.wrapper.setProps({
isInitSlow: true,
initPhase: DashboardInitPhase.Fetching,
});
});
it('Should render slow init state', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario("Dashboard init completed ", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
});
it('Should update title', () => {
expect(document.title).toBe('My dashboard - Grafana');
});
it('Should render dashboard grid', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario("When user goes into panel edit", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setProps({
urlFullscreen: true,
urlEdit: true,
urlPanelId: '1',
});
});
it('Should update model state to fullscreen & edit', () => {
expect(ctx.dashboard.meta.fullscreen).toBe(true);
expect(ctx.dashboard.meta.isEditing).toBe(true);
});
it('Should update component state to fullscreen and edit', () => {
const state = ctx.wrapper.state();
expect(state.isEditing).toBe(true);
expect(state.isFullscreen).toBe(true);
});
});
dashboardPageScenario("When user goes back to dashboard from panel edit", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setState({ scrollTop: 100 });
ctx.wrapper.setProps({
urlFullscreen: true,
urlEdit: true,
urlPanelId: '1',
});
ctx.wrapper.setProps({
urlFullscreen: false,
urlEdit: false,
urlPanelId: null,
});
});
it('Should update model state normal state', () => {
expect(ctx.dashboard.meta.fullscreen).toBe(false);
expect(ctx.dashboard.meta.isEditing).toBe(false);
});
it('Should update component state to normal and restore scrollTop', () => {
const state = ctx.wrapper.state();
expect(state.isEditing).toBe(false);
expect(state.isFullscreen).toBe(false);
expect(state.scrollTop).toBe(100);
});
});
dashboardPageScenario("When dashboard has editview url state", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setProps({
editview: 'settings',
});
});
it('should render settings view', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
it('should set animation state', () => {
expect(ctx.wrapper.state().isSettingsOpening).toBe(true);
});
});
dashboardPageScenario("When adding panel", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setState({ scrollTop: 100 });
ctx.wrapper.instance().onAddPanel();
});
it('should set scrollTop to 0', () => {
expect(ctx.wrapper.state().scrollTop).toBe(0);
});
it('should add panel widget to dashboard panels', () => {
expect(ctx.dashboard.panels[0].type).toBe('add-panel');
});
});
dashboardPageScenario("Given panel with id 0", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp({
panels: [{ id: 0, type: 'graph'}],
schemaVersion: 17,
});
ctx.wrapper.setProps({
urlEdit: true,
urlFullscreen: true,
urlPanelId: '0'
});
});
it('Should go into edit mode' , () => {
expect(ctx.wrapper.state().isEditing).toBe(true);
expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0);
});
});
dashboardPageScenario("When dashboard unmounts", (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp({
panels: [{ id: 0, type: 'graph'}],
schemaVersion: 17,
});
ctx.wrapper.unmount();
});
it('Should call clean up action' , () => {
expect(ctx.cleanUpDashboardMock.calls).toBe(1);
});
});
});

View File

@@ -0,0 +1,309 @@
// Libraries
import $ from 'jquery';
import React, { PureComponent, MouseEvent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import classNames from 'classnames';
// Services & Utils
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getMessageFromError } from 'app/core/utils/errors';
// Components
import { DashboardGrid } from '../dashgrid/DashboardGrid';
import { DashNav } from '../components/DashNav';
import { SubMenu } from '../components/SubMenu';
import { DashboardSettings } from '../components/DashboardSettings';
import { CustomScrollbar } from '@grafana/ui';
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
// Redux
import { initDashboard } from '../state/initDashboard';
import { cleanUpDashboard } from '../state/actions';
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
// Types
import {
StoreState,
DashboardInitPhase,
DashboardRouteInfo,
DashboardInitError,
AppNotificationSeverity,
} from 'app/types';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
export interface Props {
urlUid?: string;
urlSlug?: string;
urlType?: string;
editview?: string;
urlPanelId?: string;
urlFolderId?: string;
$scope: any;
$injector: any;
routeInfo: DashboardRouteInfo;
urlEdit: boolean;
urlFullscreen: boolean;
initPhase: DashboardInitPhase;
isInitSlow: boolean;
dashboard: DashboardModel | null;
initError?: DashboardInitError;
initDashboard: typeof initDashboard;
cleanUpDashboard: typeof cleanUpDashboard;
notifyApp: typeof notifyApp;
updateLocation: typeof updateLocation;
}
export interface State {
isSettingsOpening: boolean;
isEditing: boolean;
isFullscreen: boolean;
fullscreenPanel: PanelModel | null;
scrollTop: number;
rememberScrollTop: number;
showLoadingState: boolean;
}
export class DashboardPage extends PureComponent<Props, State> {
state: State = {
isSettingsOpening: false,
isEditing: false,
isFullscreen: false,
showLoadingState: false,
fullscreenPanel: null,
scrollTop: 0,
rememberScrollTop: 0,
};
async componentDidMount() {
this.props.initDashboard({
$injector: this.props.$injector,
$scope: this.props.$scope,
urlSlug: this.props.urlSlug,
urlUid: this.props.urlUid,
urlType: this.props.urlType,
urlFolderId: this.props.urlFolderId,
routeInfo: this.props.routeInfo,
fixUrl: true,
});
}
componentWillUnmount() {
if (this.props.dashboard) {
this.props.cleanUpDashboard();
}
}
componentDidUpdate(prevProps: Props) {
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props;
if (!dashboard) {
return;
}
// if we just got dashboard update title
if (!prevProps.dashboard) {
document.title = dashboard.title + ' - Grafana';
}
// handle animation states when opening dashboard settings
if (!prevProps.editview && editview) {
this.setState({ isSettingsOpening: true });
setTimeout(() => {
this.setState({ isSettingsOpening: false });
}, 10);
}
// Sync url state with model
if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) {
if (!isNaN(parseInt(urlPanelId, 10))) {
this.onEnterFullscreen();
} else {
this.onLeaveFullscreen();
}
}
}
onEnterFullscreen() {
const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props;
const panelId = parseInt(urlPanelId, 10);
// need to expand parent row if this panel is inside a row
dashboard.expandParentRowFor(panelId);
const panel = dashboard.getPanelById(panelId);
if (panel) {
dashboard.setViewMode(panel, urlFullscreen, urlEdit);
this.setState({
isEditing: urlEdit && dashboard.meta.canEdit,
isFullscreen: urlFullscreen,
fullscreenPanel: panel,
rememberScrollTop: this.state.scrollTop,
});
this.setPanelFullscreenClass(urlFullscreen);
} else {
this.handleFullscreenPanelNotFound(urlPanelId);
}
}
onLeaveFullscreen() {
const { dashboard } = this.props;
if (this.state.fullscreenPanel) {
dashboard.setViewMode(this.state.fullscreenPanel, false, false);
}
this.setState(
{
isEditing: false,
isFullscreen: false,
fullscreenPanel: null,
scrollTop: this.state.rememberScrollTop,
},
() => {
dashboard.render();
}
);
this.setPanelFullscreenClass(false);
}
handleFullscreenPanelNotFound(urlPanelId: string) {
// Panel not found
this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));
// Clear url state
this.props.updateLocation({
query: {
edit: null,
fullscreen: null,
panelId: null,
},
partial: true,
});
}
setPanelFullscreenClass(isFullscreen: boolean) {
$('body').toggleClass('panel-in-fullscreen', isFullscreen);
}
setScrollTop = (e: MouseEvent<HTMLElement>): void => {
const target = e.target as HTMLElement;
this.setState({ scrollTop: target.scrollTop });
};
onAddPanel = () => {
const { dashboard } = this.props;
// Return if the "Add panel" exists already
if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') {
return;
}
dashboard.addPanel({
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
title: 'Panel Title',
});
// scroll to top after adding panel
this.setState({ scrollTop: 0 });
};
renderSlowInitState() {
return (
<div className="dashboard-loading">
<div className="dashboard-loading__text">
<i className="fa fa-spinner fa-spin" /> {this.props.initPhase}
</div>
</div>
);
}
renderInitFailedState() {
const { initError } = this.props;
return (
<div className="dashboard-loading">
<AlertBox
severity={AppNotificationSeverity.Error}
title={initError.message}
text={getMessageFromError(initError.error)}
/>
</div>
);
}
render() {
const { dashboard, editview, $injector, isInitSlow, initError } = this.props;
const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state;
if (!dashboard) {
if (isInitSlow) {
return this.renderSlowInitState();
}
return null;
}
const classes = classNames({
'dashboard-page--settings-opening': isSettingsOpening,
'dashboard-page--settings-open': !isSettingsOpening && editview,
});
const gridWrapperClasses = classNames({
'dashboard-container': true,
'dashboard-container--has-submenu': dashboard.meta.submenuEnabled,
});
return (
<div className={classes}>
<DashNav
dashboard={dashboard}
isEditing={isEditing}
isFullscreen={isFullscreen}
editview={editview}
$injector={$injector}
onAddPanel={this.onAddPanel}
/>
<div className="scroll-canvas scroll-canvas--dashboard">
<CustomScrollbar autoHeightMin={'100%'} setScrollTop={this.setScrollTop} scrollTop={scrollTop}>
{editview && <DashboardSettings dashboard={dashboard} />}
{initError && this.renderInitFailedState()}
<div className={gridWrapperClasses}>
{dashboard.meta.submenuEnabled && <SubMenu dashboard={dashboard} />}
<DashboardGrid dashboard={dashboard} isEditing={isEditing} isFullscreen={isFullscreen} />
</div>
</CustomScrollbar>
</div>
</div>
);
}
}
const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
editview: state.location.query.editview,
urlPanelId: state.location.query.panelId,
urlFolderId: state.location.query.folderId,
urlFullscreen: state.location.query.fullscreen === true,
urlEdit: state.location.query.edit === true,
initPhase: state.dashboard.initPhase,
isInitSlow: state.dashboard.isInitSlow,
initError: state.dashboard.initError,
dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
initDashboard,
cleanUpDashboard,
notifyApp,
updateLocation,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage));

View File

@@ -3,98 +3,84 @@ import React, { Component } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
// Utils & Services
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { getBackendSrv } from 'app/core/services/backend_srv';
// Components
import { DashboardPanel } from '../dashgrid/DashboardPanel';
// Redux
import { updateLocation } from 'app/core/actions';
import { initDashboard } from '../state/initDashboard';
// Types
import { StoreState } from 'app/types';
import { StoreState, DashboardRouteInfo } from 'app/types';
import { PanelModel, DashboardModel } from 'app/features/dashboard/state';
interface Props {
panelId: string;
urlPanelId: string;
urlUid?: string;
urlSlug?: string;
urlType?: string;
$scope: any;
$injector: any;
updateLocation: typeof updateLocation;
routeInfo: DashboardRouteInfo;
initDashboard: typeof initDashboard;
dashboard: DashboardModel | null;
}
interface State {
panel: PanelModel | null;
dashboard: DashboardModel | null;
notFound: boolean;
}
export class SoloPanelPage extends Component<Props, State> {
state: State = {
panel: null,
dashboard: null,
notFound: false,
};
componentDidMount() {
const { $injector, $scope, urlUid, urlType, urlSlug } = this.props;
const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props;
// handle old urls with no uid
if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
this.redirectToNewUrl();
return;
}
const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
// subscribe to event to know when dashboard controller is done with inititalization
appEvents.on('dashboard-initialized', this.onDashoardInitialized);
dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
result.meta.soloMode = true;
$scope.initDashboard(result, $scope);
this.props.initDashboard({
$injector: $injector,
$scope: $scope,
urlSlug: urlSlug,
urlUid: urlUid,
urlType: urlType,
routeInfo: routeInfo,
fixUrl: false,
});
}
redirectToNewUrl() {
getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
this.props.updateLocation(url);
componentDidUpdate(prevProps: Props) {
const { urlPanelId, dashboard } = this.props;
if (!dashboard) {
return;
}
// we just got the dashboard!
if (!prevProps.dashboard) {
const panelId = parseInt(urlPanelId, 10);
// need to expand parent row if this panel is inside a row
dashboard.expandParentRowFor(panelId);
const panel = dashboard.getPanelById(panelId);
if (!panel) {
this.setState({ notFound: true });
return;
}
});
}
onDashoardInitialized = () => {
const { $scope, panelId } = this.props;
const dashboard: DashboardModel = $scope.dashboard;
const panel = dashboard.getPanelById(parseInt(panelId, 10));
if (!panel) {
this.setState({ notFound: true });
return;
this.setState({ panel });
}
this.setState({ dashboard, panel });
};
}
render() {
const { panelId } = this.props;
const { notFound, panel, dashboard } = this.state;
const { urlPanelId, dashboard } = this.props;
const { notFound, panel } = this.state;
if (notFound) {
return (
<div className="alert alert-error">
Panel with id { panelId } not found
</div>
);
return <div className="alert alert-error">Panel with id {urlPanelId} not found</div>;
}
if (!panel) {
@@ -113,11 +99,12 @@ const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
panelId: state.location.query.panelId
urlPanelId: state.location.query.panelId,
dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
updateLocation
initDashboard,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));

View File

@@ -0,0 +1,546 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = `
<div
className=""
>
<Connect(DashNav)
$injector={Object {}}
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
onAddPanel={[Function]}
/>
<div
className="scroll-canvas scroll-canvas--dashboard"
>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="100%"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
customClassName="custom-scrollbars"
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
>
<div
className="dashboard-container"
>
<DashboardGrid
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
/>
</div>
</CustomScrollbar>
</div>
</div>
`;
exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = `
<div
className="dashboard-loading"
>
<div
className="dashboard-loading__text"
>
<i
className="fa fa-spinner fa-spin"
/>
Fetching
</div>
</div>
`;
exports[`DashboardPage Given initial state Should render nothing 1`] = `""`;
exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = `
<div
className="dashboard-page--settings-opening"
>
<Connect(DashNav)
$injector={Object {}}
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
editview="settings"
isEditing={false}
isFullscreen={false}
onAddPanel={[Function]}
/>
<div
className="scroll-canvas scroll-canvas--dashboard"
>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="100%"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
customClassName="custom-scrollbars"
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
>
<DashboardSettings
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
/>
<div
className="dashboard-container"
>
<DashboardGrid
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 17,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
/>
</div>
</CustomScrollbar>
</div>
</div>
`;

View File

@@ -1,11 +1,14 @@
import React from 'react';
// Libaries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import ReactGridLayout, { ItemCallback } from 'react-grid-layout';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
// Types
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel';
import { DashboardModel, PanelModel } from '../state';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
let lastGridWidth = 1200;
let ignoreNextWidthChange = false;
@@ -76,19 +79,18 @@ function GridWrapper({
const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
export interface DashboardGridProps {
export interface Props {
dashboard: DashboardModel;
isEditing: boolean;
isFullscreen: boolean;
}
export class DashboardGrid extends React.Component<DashboardGridProps> {
export class DashboardGrid extends PureComponent<Props> {
gridToPanelMap: any;
panelMap: { [id: string]: PanelModel };
constructor(props: DashboardGridProps) {
super(props);
// subscribe to dashboard events
const dashboard = this.props.dashboard;
componentDidMount() {
const { dashboard } = this.props;
dashboard.on('panel-added', this.triggerForceUpdate);
dashboard.on('panel-removed', this.triggerForceUpdate);
dashboard.on('repeats-processed', this.triggerForceUpdate);
@@ -97,6 +99,16 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
dashboard.on('row-expanded', this.triggerForceUpdate);
}
componentWillUnmount() {
const { dashboard } = this.props;
dashboard.off('panel-added', this.triggerForceUpdate);
dashboard.off('panel-removed', this.triggerForceUpdate);
dashboard.off('repeats-processed', this.triggerForceUpdate);
dashboard.off('view-mode-changed', this.onViewModeChanged);
dashboard.off('row-collapsed', this.triggerForceUpdate);
dashboard.off('row-expanded', this.triggerForceUpdate);
}
buildLayout() {
const layout = [];
this.panelMap = {};
@@ -151,7 +163,6 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
onViewModeChanged = () => {
ignoreNextWidthChange = true;
this.forceUpdate();
}
updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => {
@@ -197,18 +208,20 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
}
render() {
const { dashboard, isFullscreen } = this.props;
return (
<SizedReactLayoutGrid
className={classNames({ layout: true })}
layout={this.buildLayout()}
isResizable={this.props.dashboard.meta.canEdit}
isDraggable={this.props.dashboard.meta.canEdit}
isResizable={dashboard.meta.canEdit}
isDraggable={dashboard.meta.canEdit}
onLayoutChange={this.onLayoutChange}
onWidthChange={this.onWidthChange}
onDragStop={this.onDragStop}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
isFullscreen={this.props.dashboard.meta.fullscreen}
isFullscreen={isFullscreen}
>
{this.renderPanels()}
</SizedReactLayoutGrid>

View File

@@ -1,8 +1,6 @@
import './containers/DashboardCtrl';
import './dashgrid/DashboardGridDirective';
// Services
import './services/DashboardViewStateSrv';
import './services/UnsavedChangesSrv';
import './services/DashboardLoaderSrv';
import './services/DashboardSrv';

View File

@@ -1,25 +1,74 @@
import coreModule from 'app/core/core_module';
import { DashboardModel } from '../state/DashboardModel';
import { appEvents } from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { DashboardModel } from '../state/DashboardModel';
import { removePanel } from '../utils/panel';
export class DashboardSrv {
dash: any;
dashboard: DashboardModel;
/** @ngInject */
constructor(private backendSrv, private $rootScope, private $location) {}
constructor(private backendSrv, private $rootScope, private $location) {
appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope);
appEvents.on('panel-change-view', this.onPanelChangeView);
appEvents.on('remove-panel', this.onRemovePanel);
}
create(dashboard, meta) {
return new DashboardModel(dashboard, meta);
}
setCurrent(dashboard) {
this.dash = dashboard;
setCurrent(dashboard: DashboardModel) {
this.dashboard = dashboard;
}
getCurrent() {
return this.dash;
getCurrent(): DashboardModel {
return this.dashboard;
}
onRemovePanel = (panelId: number) => {
const dashboard = this.getCurrent();
removePanel(dashboard, dashboard.getPanelById(panelId), true);
};
onPanelChangeView = (options) => {
const urlParams = this.$location.search();
// handle toggle logic
if (options.fullscreen === urlParams.fullscreen) {
// I hate using these truthy converters (!!) but in this case
// I think it's appropriate. edit can be null/false/undefined and
// here i want all of those to compare the same
if (!!options.edit === !!urlParams.edit) {
delete urlParams.fullscreen;
delete urlParams.edit;
delete urlParams.panelId;
this.$location.search(urlParams);
return;
}
}
if (options.fullscreen) {
urlParams.fullscreen = true;
} else {
delete urlParams.fullscreen;
}
if (options.edit) {
urlParams.edit = true;
} else {
delete urlParams.edit;
}
if (options.panelId || options.panelId === 0) {
urlParams.panelId = options.panelId;
} else {
delete urlParams.panelId;
}
this.$location.search(urlParams);
};
handleSaveDashboardError(clone, options, err) {
options = options || {};
options.overwrite = true;
@@ -75,10 +124,10 @@ export class DashboardSrv {
}
postSave(clone, data) {
this.dash.version = data.version;
this.dashboard.version = data.version;
// important that these happens before location redirect below
this.$rootScope.appEvent('dashboard-saved', this.dash);
this.$rootScope.appEvent('dashboard-saved', this.dashboard);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
const newUrl = locationUtil.stripBaseFromUrl(data.url);
@@ -88,12 +137,12 @@ export class DashboardSrv {
this.$location.url(newUrl).replace();
}
return this.dash;
return this.dashboard;
}
save(clone, options) {
options = options || {};
options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
return this.backendSrv
.saveDashboard(clone, options)
@@ -103,26 +152,26 @@ export class DashboardSrv {
saveDashboard(options?, clone?) {
if (clone) {
this.setCurrent(this.create(clone, this.dash.meta));
this.setCurrent(this.create(clone, this.dashboard.meta));
}
if (this.dash.meta.provisioned) {
if (this.dashboard.meta.provisioned) {
return this.showDashboardProvisionedModal();
}
if (!this.dash.meta.canSave && options.makeEditable !== true) {
if (!this.dashboard.meta.canSave && options.makeEditable !== true) {
return Promise.resolve();
}
if (this.dash.title === 'New dashboard') {
if (this.dashboard.title === 'New dashboard') {
return this.showSaveAsModal();
}
if (this.dash.version > 0) {
if (this.dashboard.version > 0) {
return this.showSaveModal();
}
return this.save(this.dash.getSaveModelClone(), options);
return this.save(this.dashboard.getSaveModelClone(), options);
}
saveJSONDashboard(json: string) {
@@ -163,8 +212,8 @@ export class DashboardSrv {
}
return promise.then(res => {
if (this.dash && this.dash.id === dashboardId) {
this.dash.meta.isStarred = res;
if (this.dashboard && this.dashboard.id === dashboardId) {
this.dashboard.meta.isStarred = res;
}
return res;
});

View File

@@ -1,64 +0,0 @@
import config from 'app/core/config';
import { DashboardViewStateSrv } from './DashboardViewStateSrv';
import { DashboardModel } from '../state/DashboardModel';
describe('when updating view state', () => {
const location = {
replace: jest.fn(),
search: jest.fn(),
};
const $scope = {
appEvent: jest.fn(),
onAppEvent: jest.fn(() => {}),
dashboard: new DashboardModel({
panels: [{ id: 1 }],
}),
};
let viewState;
beforeEach(() => {
config.bootData = {
user: {
orgId: 1,
},
};
});
describe('to fullscreen true and edit true', () => {
beforeEach(() => {
location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 };
});
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should update querystring and view state', () => {
const updateState = { fullscreen: true, edit: true, panelId: 1 };
viewState.update(updateState);
expect(location.search).toHaveBeenCalledWith({
edit: true,
editview: null,
fullscreen: true,
orgId: 1,
panelId: 1,
});
expect(viewState.dashboard.meta.fullscreen).toBe(true);
expect(viewState.state.fullscreen).toBe(true);
});
});
describe('to fullscreen false', () => {
beforeEach(() => {
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true });
viewState.update({ fullscreen: false });
expect(viewState.state.fullscreen).toBe(null);
});
});
});

View File

@@ -1,185 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import { DashboardModel } from '../state/DashboardModel';
// represents the transient view state
// like fullscreen panel & edit
export class DashboardViewStateSrv {
state: any;
panelScopes: any;
$scope: any;
dashboard: DashboardModel;
fullscreenPanel: any;
oldTimeRange: any;
/** @ngInject */
constructor($scope, private $location, private $timeout) {
const self = this;
self.state = {};
self.panelScopes = [];
self.$scope = $scope;
self.dashboard = $scope.dashboard;
$scope.onAppEvent('$routeUpdate', () => {
const urlState = self.getQueryStringState();
if (self.needsSync(urlState)) {
self.update(urlState, true);
}
});
$scope.onAppEvent('panel-change-view', (evt, payload) => {
self.update(payload);
});
// this marks changes to location during this digest cycle as not to add history item
// don't want url changes like adding orgId to add browser history
$location.replace();
this.update(this.getQueryStringState());
}
needsSync(urlState) {
return _.isEqual(this.state, urlState) === false;
}
getQueryStringState() {
const state = this.$location.search();
state.panelId = parseInt(state.panelId, 10) || null;
state.fullscreen = state.fullscreen ? true : null;
state.edit = state.edit === 'true' || state.edit === true || null;
state.editview = state.editview || null;
state.orgId = config.bootData.user.orgId;
return state;
}
serializeToUrl() {
const urlState = _.clone(this.state);
urlState.fullscreen = this.state.fullscreen ? true : null;
urlState.edit = this.state.edit ? true : null;
return urlState;
}
update(state, fromRouteUpdated?) {
// implement toggle logic
if (state.toggle) {
delete state.toggle;
if (this.state.fullscreen && state.fullscreen) {
if (this.state.edit === state.edit) {
state.fullscreen = !state.fullscreen;
}
}
}
_.extend(this.state, state);
if (!this.state.fullscreen) {
this.state.fullscreen = null;
this.state.edit = null;
// clear panel id unless in solo mode
if (!this.dashboard.meta.soloMode) {
this.state.panelId = null;
}
}
if ((this.state.fullscreen || this.dashboard.meta.soloMode) && this.state.panelId) {
// Trying to render panel in fullscreen when it's in the collapsed row causes an issue.
// So in this case expand collapsed row first.
this.toggleCollapsedPanelRow(this.state.panelId);
}
// if no edit state cleanup tab parm
if (!this.state.edit) {
delete this.state.tab;
}
// do not update url params if we are here
// from routeUpdated event
if (fromRouteUpdated !== true) {
this.$location.search(this.serializeToUrl());
}
this.syncState();
}
toggleCollapsedPanelRow(panelId) {
for (const panel of this.dashboard.panels) {
if (panel.collapsed) {
for (const rowPanel of panel.panels) {
if (rowPanel.id === panelId) {
this.dashboard.toggleRow(panel);
return;
}
}
}
}
}
syncState() {
if (this.state.fullscreen) {
const panel = this.dashboard.getPanelById(this.state.panelId);
if (!panel) {
this.state.fullscreen = null;
this.state.panelId = null;
this.state.edit = null;
this.update(this.state);
setTimeout(() => {
appEvents.emit('alert-error', ['Error', 'Panel not found']);
}, 100);
return;
}
if (!panel.fullscreen) {
this.enterFullscreen(panel);
} else if (this.dashboard.meta.isEditing !== this.state.edit) {
this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
}
} else if (this.fullscreenPanel) {
this.leaveFullscreen();
}
}
leaveFullscreen() {
const panel = this.fullscreenPanel;
this.dashboard.setViewMode(panel, false, false);
delete this.fullscreenPanel;
this.$timeout(() => {
appEvents.emit('dash-scroll', { restore: true });
if (this.oldTimeRange !== this.dashboard.time) {
this.dashboard.startRefresh();
} else {
this.dashboard.render();
}
});
}
enterFullscreen(panel) {
const isEditing = this.state.edit && this.dashboard.meta.canEdit;
this.oldTimeRange = this.dashboard.time;
this.fullscreenPanel = panel;
// Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.dashboard.setViewMode(panel, true, isEditing);
}
}
/** @ngInject */
export function dashboardViewStateSrv($location, $timeout) {
return {
create: $scope => {
return new DashboardViewStateSrv($scope, $location, $timeout);
},
};
}
angular.module('grafana.services').factory('dashboardViewStateSrv', dashboardViewStateSrv);

View File

@@ -1,20 +1,26 @@
// Libaries
import moment from 'moment';
import _ from 'lodash';
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
// Constants
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
// Utils & Services
import { Emitter } from 'app/core/utils/emitter';
import { contextSrv } from 'app/core/services/context_srv';
import sortByKeys from 'app/core/utils/sort_by_keys';
// Types
import { PanelModel } from './PanelModel';
import { DashboardMigrator } from './DashboardMigrator';
import { TimeRange } from '@grafana/ui/src';
import { UrlQueryValue, KIOSK_MODE_TV, DashboardMeta } from 'app/types';
export class DashboardModel {
id: any;
uid: any;
title: any;
uid: string;
title: string;
autoUpdate: any;
description: any;
tags: any;
@@ -43,7 +49,7 @@ export class DashboardModel {
// repeat process cycles
iteration: number;
meta: any;
meta: DashboardMeta;
events: Emitter;
static nonPersistedProperties: { [str: string]: boolean } = {
@@ -127,6 +133,8 @@ export class DashboardModel {
meta.canEdit = meta.canEdit !== false;
meta.showSettings = meta.canEdit;
meta.canMakeEditable = meta.canSave && !this.editable;
meta.fullscreen = false;
meta.isEditing = false;
if (!this.editable) {
meta.canEdit = false;
@@ -860,11 +868,7 @@ export class DashboardModel {
return !_.isEqual(updated, this.originalTemplating);
}
autoFitPanels(viewHeight: number) {
if (!this.meta.autofitpanels) {
return;
}
autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) {
const currentGridHeight = Math.max(
...this.panels.map(panel => {
return panel.gridPos.h + panel.gridPos.y;
@@ -878,12 +882,12 @@ export class DashboardModel {
let visibleHeight = viewHeight - navbarHeight - margin;
// Remove submenu height if visible
if (this.meta.submenuEnabled && !this.meta.kiosk) {
if (this.meta.submenuEnabled && !kioskMode) {
visibleHeight -= submenuHeight;
}
// add back navbar height
if (this.meta.kiosk === 'b') {
if (kioskMode === KIOSK_MODE_TV) {
visibleHeight += 55;
}
@@ -895,4 +899,23 @@ export class DashboardModel {
panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1;
});
}
templateVariableValueUpdated() {
this.processRepeats();
this.events.emit('template-variable-value-updated');
}
expandParentRowFor(panelId: number) {
for (const panel of this.panels) {
if (panel.collapsed) {
for (const rowPanel of panel.panels) {
if (rowPanel.id === panelId) {
this.toggleRow(panel);
return;
}
}
}
}
}
}

View File

@@ -1,39 +1,43 @@
import { StoreState } from 'app/types';
import { ThunkAction } from 'redux-thunk';
// Services & Utils
import { getBackendSrv } from 'app/core/services/backend_srv';
import appEvents from 'app/core/app_events';
import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
import { createSuccessNotification } from 'app/core/copy/appNotification';
// Actions
import { loadPluginDashboards } from '../../plugins/state/actions';
import { notifyApp } from 'app/core/actions';
// Types
import {
ThunkResult,
DashboardAcl,
DashboardAclDTO,
PermissionLevel,
DashboardAclUpdateDTO,
NewDashboardAclItem,
} from 'app/types/acl';
MutableDashboard,
DashboardInitError,
} from 'app/types';
export enum ActionTypes {
LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS',
LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
}
export const loadDashboardPermissions = actionCreatorFactory<DashboardAclDTO[]>('LOAD_DASHBOARD_PERMISSIONS').create();
export interface LoadDashboardPermissionsAction {
type: ActionTypes.LoadDashboardPermissions;
payload: DashboardAcl[];
}
export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create();
export interface LoadStarredDashboardsAction {
type: ActionTypes.LoadStarredDashboards;
payload: DashboardAcl[];
}
export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create();
export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create();
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
export const dashboardInitCompleted = actionCreatorFactory<MutableDashboard>('DASHBOARD_INIT_COMLETED').create();
export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({
type: ActionTypes.LoadDashboardPermissions,
payload: items,
});
/*
* Unrecoverable init failure (fetch or model creation failed)
*/
export const dashboardInitFailed = actionCreatorFactory<DashboardInitError>('DASHBOARD_INIT_FAILED').create();
/*
* When leaving dashboard, resets state
* */
export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create();
export function getDashboardPermissions(id: number): ThunkResult<void> {
return async dispatch => {
@@ -124,7 +128,7 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
export function importDashboard(data, dashboardTitle: string): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post('/api/dashboards/import', data);
appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle)));
dispatch(loadPluginDashboards());
};
}
@@ -135,3 +139,4 @@ export function removeDashboard(uri: string): ThunkResult<void> {
dispatch(loadPluginDashboards());
};
}

View File

@@ -0,0 +1,152 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { initDashboard, InitDashboardArgs } from './initDashboard';
import { DashboardRouteInfo } from 'app/types';
import { getBackendSrv } from 'app/core/services/backend_srv';
import {
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitServices,
} from './actions';
jest.mock('app/core/services/backend_srv');
const mockStore = configureMockStore([thunk]);
interface ScenarioContext {
args: InitDashboardArgs;
timeSrv: any;
annotationsSrv: any;
unsavedChangesSrv: any;
variableSrv: any;
dashboardSrv: any;
keybindingSrv: any;
backendSrv: any;
setup: (fn: () => void) => void;
actions: any[];
storeState: any;
}
type ScenarioFn = (ctx: ScenarioContext) => void;
function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
describe(description, () => {
const timeSrv = { init: jest.fn() };
const annotationsSrv = { init: jest.fn() };
const unsavedChangesSrv = { init: jest.fn() };
const variableSrv = { init: jest.fn() };
const dashboardSrv = { setCurrent: jest.fn() };
const keybindingSrv = { setupDashboardBindings: jest.fn() };
const injectorMock = {
get: (name: string) => {
switch (name) {
case 'timeSrv':
return timeSrv;
case 'annotationsSrv':
return annotationsSrv;
case 'unsavedChangesSrv':
return unsavedChangesSrv;
case 'dashboardSrv':
return dashboardSrv;
case 'variableSrv':
return variableSrv;
case 'keybindingSrv':
return keybindingSrv;
default:
throw { message: 'Unknown service ' + name };
}
},
};
let setupFn = () => {};
const ctx: ScenarioContext = {
args: {
$injector: injectorMock,
$scope: {},
fixUrl: false,
routeInfo: DashboardRouteInfo.Normal,
},
backendSrv: getBackendSrv(),
timeSrv,
annotationsSrv,
unsavedChangesSrv,
variableSrv,
dashboardSrv,
keybindingSrv,
actions: [],
storeState: {
location: {
query: {},
},
user: {},
},
setup: (fn: () => void) => {
setupFn = fn;
},
};
beforeEach(async () => {
setupFn();
const store = mockStore(ctx.storeState);
await store.dispatch(initDashboard(ctx.args));
ctx.actions = store.getActions();
});
scenarioFn(ctx);
});
}
describeInitScenario('Initializing new dashboard', ctx => {
ctx.setup(() => {
ctx.storeState.user.orgId = 12;
ctx.args.routeInfo = DashboardRouteInfo.New;
});
it('Should send action dashboardInitFetching', () => {
expect(ctx.actions[0].type).toBe(dashboardInitFetching.type);
});
it('Should send action dashboardInitServices ', () => {
expect(ctx.actions[1].type).toBe(dashboardInitServices.type);
});
it('Should update location with orgId query param', () => {
expect(ctx.actions[2].type).toBe('UPDATE_LOCATION');
expect(ctx.actions[2].payload.query.orgId).toBe(12);
});
it('Should send action dashboardInitCompleted', () => {
expect(ctx.actions[3].type).toBe(dashboardInitCompleted.type);
expect(ctx.actions[3].payload.title).toBe('New dashboard');
});
it('Should Initializing services', () => {
expect(ctx.timeSrv.init).toBeCalled();
expect(ctx.annotationsSrv.init).toBeCalled();
expect(ctx.variableSrv.init).toBeCalled();
expect(ctx.unsavedChangesSrv.init).toBeCalled();
expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled();
expect(ctx.dashboardSrv.setCurrent).toBeCalled();
});
});
describeInitScenario('Initializing home dashboard', ctx => {
ctx.setup(() => {
ctx.args.routeInfo = DashboardRouteInfo.Home;
ctx.backendSrv.get.mockReturnValue(Promise.resolve({
redirectUri: '/u/123/my-home'
}));
});
it('Should redirect to custom home dashboard', () => {
expect(ctx.actions[1].type).toBe('UPDATE_LOCATION');
expect(ctx.actions[1].payload.path).toBe('/u/123/my-home');
});
});

View File

@@ -0,0 +1,233 @@
// Services & Utils
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
import { VariableSrv } from 'app/features/templating/variable_srv';
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
// Actions
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
import locationUtil from 'app/core/utils/location_util';
import {
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitFailed,
dashboardInitSlow,
dashboardInitServices,
} from './actions';
// Types
import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
import { DashboardModel } from './DashboardModel';
export interface InitDashboardArgs {
$injector: any;
$scope: any;
urlUid?: string;
urlSlug?: string;
urlType?: string;
urlFolderId?: string;
routeInfo: DashboardRouteInfo;
fixUrl: boolean;
}
async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) {
const res = await getBackendSrv().getDashboardBySlug(slug);
if (res) {
let newUrl = res.meta.url;
// fix solo route urls
if (currentPath.indexOf('dashboard-solo') !== -1) {
newUrl = newUrl.replace('/d/', '/d-solo/');
}
const url = locationUtil.stripBaseFromUrl(newUrl);
dispatch(updateLocation({ path: url, partial: true, replace: true }));
}
}
async function fetchDashboard(
args: InitDashboardArgs,
dispatch: ThunkDispatch,
getState: () => StoreState
): Promise<DashboardDTO | null> {
try {
switch (args.routeInfo) {
case DashboardRouteInfo.Home: {
// load home dash
const dashDTO: DashboardDTO = await getBackendSrv().get('/api/dashboards/home');
// if user specified a custom home dashboard redirect to that
if (dashDTO.redirectUri) {
const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri);
dispatch(updateLocation({ path: newUrl, replace: true }));
return null;
}
// disable some actions on the default home dashboard
dashDTO.meta.canSave = false;
dashDTO.meta.canShare = false;
dashDTO.meta.canStar = false;
return dashDTO;
}
case DashboardRouteInfo.Normal: {
// for old db routes we redirect
if (args.urlType === 'db') {
redirectToNewUrl(args.urlSlug, dispatch, getState().location.path);
return null;
}
const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv');
const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
if (args.fixUrl && dashDTO.meta.url) {
// check if the current url is correct (might be old slug)
const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
const currentPath = getState().location.path;
if (dashboardUrl !== currentPath) {
// replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times.
dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true }));
return null;
}
}
return dashDTO;
}
case DashboardRouteInfo.New: {
return getNewDashboardModelData(args.urlFolderId);
}
default:
throw { message: 'Unknown route ' + args.routeInfo };
}
} catch (err) {
dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err }));
console.log(err);
return null;
}
}
/**
* This action (or saga) does everything needed to bootstrap a dashboard & dashboard model.
* First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates)
*
* This is used both for single dashboard & solo panel routes, home & new dashboard routes.
*
* Then it handles the initializing of the old angular services that the dashboard components & panels still depend on
*
*/
export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
return async (dispatch, getState) => {
// set fetching state
dispatch(dashboardInitFetching());
// Detect slow loading / initializing and set state flag
// This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing
setTimeout(() => {
if (getState().dashboard.model === null) {
dispatch(dashboardInitSlow());
}
}, 500);
// fetch dashboard data
const dashDTO = await fetchDashboard(args, dispatch, getState);
// returns null if there was a redirect or error
if (!dashDTO) {
return;
}
// set initializing state
dispatch(dashboardInitServices());
// create model
let dashboard: DashboardModel;
try {
dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta);
} catch (err) {
dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err }));
console.log(err);
return;
}
// add missing orgId query param
const storeState = getState();
if (!storeState.location.query.orgId) {
dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true }));
}
// init services
const timeSrv: TimeSrv = args.$injector.get('timeSrv');
const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv');
const variableSrv: VariableSrv = args.$injector.get('variableSrv');
const keybindingSrv: KeybindingSrv = args.$injector.get('keybindingSrv');
const unsavedChangesSrv = args.$injector.get('unsavedChangesSrv');
const dashboardSrv: DashboardSrv = args.$injector.get('dashboardSrv');
timeSrv.init(dashboard);
annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
try {
await variableSrv.init(dashboard);
} catch (err) {
dispatch(notifyApp(createErrorNotification('Templating init failed', err)));
console.log(err);
}
try {
dashboard.processRepeats();
dashboard.updateSubmenuVisibility();
// handle auto fix experimental feature
const queryParams = getState().location.query;
if (queryParams.autofitpanels) {
dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
}
// init unsaved changes tracking
unsavedChangesSrv.init(dashboard, args.$scope);
keybindingSrv.setupDashboardBindings(args.$scope, dashboard);
} catch (err) {
dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));
console.log(err);
}
// legacy srv state
dashboardSrv.setCurrent(dashboard);
// yay we are done
dispatch(dashboardInitCompleted(dashboard));
};
}
function getNewDashboardModelData(urlFolderId?: string): any {
const data = {
meta: {
canStar: false,
canShare: false,
isNew: true,
folderId: 0,
},
dashboard: {
title: 'New dashboard',
panels: [
{
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 9 },
title: 'Panel Title',
},
],
},
};
if (urlFolderId) {
data.meta.folderId = parseInt(urlFolderId, 10);
}
return data;
}

View File

@@ -1,19 +1,23 @@
import { Action, ActionTypes } from './actions';
import { OrgRole, PermissionLevel, DashboardState } from 'app/types';
import {
loadDashboardPermissions,
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitFailed,
dashboardInitSlow,
} from './actions';
import { OrgRole, PermissionLevel, DashboardState, DashboardInitPhase } from 'app/types';
import { initialState, dashboardReducer } from './reducers';
import { DashboardModel } from './DashboardModel';
describe('dashboard reducer', () => {
describe('loadDashboardPermissions', () => {
let state: DashboardState;
beforeEach(() => {
const action: Action = {
type: ActionTypes.LoadDashboardPermissions,
payload: [
{ id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
{ id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
],
};
const action = loadDashboardPermissions([
{ id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
{ id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
]);
state = dashboardReducer(initialState, action);
});
@@ -21,4 +25,47 @@ describe('dashboard reducer', () => {
expect(state.permissions.length).toBe(2);
});
});
describe('dashboardInitCompleted', () => {
let state: DashboardState;
beforeEach(() => {
state = dashboardReducer(initialState, dashboardInitFetching());
state = dashboardReducer(state, dashboardInitSlow());
state = dashboardReducer(state, dashboardInitCompleted(new DashboardModel({ title: 'My dashboard' })));
});
it('should set model', async () => {
expect(state.model.title).toBe('My dashboard');
});
it('should set reset isInitSlow', async () => {
expect(state.isInitSlow).toBe(false);
});
});
describe('dashboardInitFailed', () => {
let state: DashboardState;
beforeEach(() => {
state = dashboardReducer(initialState, dashboardInitFetching());
state = dashboardReducer(state, dashboardInitFailed({message: 'Oh no', error: 'sad'}));
});
it('should set model', async () => {
expect(state.model.title).toBe('Dashboard init failed');
});
it('should set reset isInitSlow', async () => {
expect(state.isInitSlow).toBe(false);
});
it('should set initError', async () => {
expect(state.initError.message).toBe('Oh no');
});
it('should set phase failed', async () => {
expect(state.initPhase).toBe(DashboardInitPhase.Failed);
});
});
});

View File

@@ -1,21 +1,90 @@
import { DashboardState } from 'app/types';
import { Action, ActionTypes } from './actions';
import { DashboardState, DashboardInitPhase } from 'app/types';
import {
loadDashboardPermissions,
dashboardInitFetching,
dashboardInitSlow,
dashboardInitServices,
dashboardInitFailed,
dashboardInitCompleted,
cleanUpDashboard,
} from './actions';
import { reducerFactory } from 'app/core/redux';
import { processAclItems } from 'app/core/utils/acl';
import { DashboardModel } from './DashboardModel';
export const initialState: DashboardState = {
initPhase: DashboardInitPhase.NotStarted,
isInitSlow: false,
model: null,
permissions: [],
};
export const dashboardReducer = (state = initialState, action: Action): DashboardState => {
switch (action.type) {
case ActionTypes.LoadDashboardPermissions:
export const dashboardReducer = reducerFactory(initialState)
.addMapper({
filter: loadDashboardPermissions,
mapper: (state, action) => ({
...state,
permissions: processAclItems(action.payload),
}),
})
.addMapper({
filter: dashboardInitFetching,
mapper: state => ({
...state,
initPhase: DashboardInitPhase.Fetching,
}),
})
.addMapper({
filter: dashboardInitServices,
mapper: state => ({
...state,
initPhase: DashboardInitPhase.Services,
}),
})
.addMapper({
filter: dashboardInitSlow,
mapper: state => ({
...state,
isInitSlow: true,
}),
})
.addMapper({
filter: dashboardInitFailed,
mapper: (state, action) => ({
...state,
initPhase: DashboardInitPhase.Failed,
isInitSlow: false,
initError: action.payload,
model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }),
}),
})
.addMapper({
filter: dashboardInitCompleted,
mapper: (state, action) => ({
...state,
initPhase: DashboardInitPhase.Completed,
model: action.payload,
isInitSlow: false,
}),
})
.addMapper({
filter: cleanUpDashboard,
mapper: (state, action) => {
// Destroy current DashboardModel
// Very important as this removes all dashboard event listeners
state.model.destroy();
return {
...state,
permissions: processAclItems(action.payload),
initPhase: DashboardInitPhase.NotStarted,
model: null,
isInitSlow: false,
initError: null,
};
}
return state;
};
},
})
.create();
export default {
dashboard: dashboardReducer,

View File

@@ -205,28 +205,34 @@ export class Explore extends React.PureComponent<ExploreProps> {
<div className="explore-container">
<QueryRows exploreEvents={this.exploreEvents} exploreId={exploreId} queryKeys={queryKeys} />
<AutoSizer onResize={this.onResize} disableHeight>
{({ width }) => (
<main className="m-t-2" style={{ width }}>
<ErrorBoundary>
{showingStartPage && <StartPage onClickExample={this.onClickExample} />}
{!showingStartPage && (
<>
{supportsGraph && !supportsLogs && <GraphContainer width={width} exploreId={exploreId} />}
{supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />}
{supportsLogs && (
<LogsContainer
exploreId={exploreId}
onChangeTime={this.onChangeTime}
onClickLabel={this.onClickLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
/>
)}
</>
)}
</ErrorBoundary>
</main>
)}
{({ width }) => {
if (width === 0) {
return null;
}
return (
<main className="m-t-2" style={{ width }}>
<ErrorBoundary>
{showingStartPage && <StartPage onClickExample={this.onClickExample} />}
{!showingStartPage && (
<>
{supportsGraph && !supportsLogs && <GraphContainer width={width} exploreId={exploreId} />}
{supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />}
{supportsLogs && (
<LogsContainer
exploreId={exploreId}
onChangeTime={this.onChangeTime}
onClickLabel={this.onClickLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
/>
)}
</>
)}
</ErrorBoundary>
</main>
);
}}
</AutoSizer>
</div>
)}

View File

@@ -102,10 +102,10 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
<div className="explore-toolbar-header">
<div className="explore-toolbar-header-title">
{exploreId === 'left' && (
<a className="navbar-page-btn">
<span className="navbar-page-btn">
<i className="fa fa-rocket fa-fw" />
Explore
</a>
</span>
)}
</div>
<div className="explore-toolbar-header-close">

View File

@@ -7,6 +7,7 @@ import { Emitter } from 'app/core/core';
import getFactors from 'app/core/utils/factors';
import {
duplicatePanel,
removePanel,
copyPanel as copyPanelUtil,
editPanelJson as editPanelJsonUtil,
sharePanel as sharePanelUtil,
@@ -213,9 +214,7 @@ export class PanelCtrl {
}
removePanel() {
this.publishAppEvent('panel-remove', {
panelId: this.panel.id,
});
removePanel(this.dashboard, this.panel, true);
}
editPanelJson() {

View File

@@ -1,12 +1,16 @@
import coreModule from '../../core/core_module';
import kbn from 'app/core/utils/kbn';
import appEvents from 'app/core/app_events';
// Libraries
import _ from 'lodash';
// Utils
import { toUrlParams } from 'app/core/utils/url';
import coreModule from '../../core/core_module';
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import kbn from 'app/core/utils/kbn';
export class PlaylistSrv {
private cancelPromise: any;
private dashboards: Array<{ uri: string }>;
private dashboards: Array<{ url: string }>;
private index: number;
private interval: number;
private startUrl: string;
@@ -36,7 +40,12 @@ export class PlaylistSrv {
const queryParams = this.$location.search();
const filteredParams = _.pickBy(queryParams, value => value !== null);
this.$location.url('dashboard/' + dash.uri + '?' + toUrlParams(filteredParams));
// this is done inside timeout to make sure digest happens after
// as this can be called from react
this.$timeout(() => {
const stripedUrl = locationUtil.stripBaseFromUrl(dash.url);
this.$location.url(stripedUrl + '?' + toUrlParams(filteredParams));
});
this.index++;
this.cancelPromise = this.$timeout(() => this.next(), this.interval);
@@ -54,6 +63,8 @@ export class PlaylistSrv {
this.index = 0;
this.isPlaying = true;
appEvents.emit('playlist-started');
return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
this.dashboards = dashboards;
@@ -77,6 +88,8 @@ export class PlaylistSrv {
if (this.cancelPromise) {
this.$timeout.cancel(this.cancelPromise);
}
appEvents.emit('playlist-stopped');
}
}

View File

@@ -1,6 +1,6 @@
import { PlaylistSrv } from '../playlist_srv';
const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }];
const dashboards = [{ url: 'dash1' }, { url: 'dash2' }];
const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance<any> }] => {
const mockBackendSrv = {
@@ -50,13 +50,12 @@ const mockWindowLocation = (): [jest.MockInstance<any>, () => void] => {
describe('PlaylistSrv', () => {
let srv: PlaylistSrv;
let mockLocationService: { url: jest.MockInstance<any> };
let hrefMock: jest.MockInstance<any>;
let unmockLocation: () => void;
const initialUrl = 'http://localhost/playlist';
beforeEach(() => {
[srv, mockLocationService] = createPlaylistSrv();
[srv] = createPlaylistSrv();
[hrefMock, unmockLocation] = mockWindowLocation();
// This will be cached in the srv when start() is called
@@ -71,7 +70,6 @@ describe('PlaylistSrv', () => {
await srv.start(1);
for (let i = 0; i < 6; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
@@ -84,7 +82,6 @@ describe('PlaylistSrv', () => {
// 1 complete loop
for (let i = 0; i < 3; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
@@ -93,7 +90,6 @@ describe('PlaylistSrv', () => {
// Another 2 loops
for (let i = 0; i < 4; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}

View File

@@ -0,0 +1,14 @@
import { UserState } from 'app/types';
import config from 'app/core/config';
export const initialState: UserState = {
orgId: config.bootData.user.orgId,
};
export const userReducer = (state = initialState, action: any): UserState => {
return state;
};
export default {
user: userReducer,
};

View File

@@ -48,7 +48,6 @@ describe('VariableSrv', function(this: any) {
ds.metricFindQuery = () => Promise.resolve(scenario.queryResult);
ctx.variableSrv = new VariableSrv(
ctx.$rootScope,
$q,
ctx.$location,
ctx.$injector,

View File

@@ -25,10 +25,6 @@ describe('VariableSrv init', function(this: any) {
};
const $injector = {} as any;
const $rootscope = {
$on: () => {},
};
let ctx = {} as any;
function describeInitScenario(desc, fn) {
@@ -54,7 +50,7 @@ describe('VariableSrv init', function(this: any) {
};
// @ts-ignore
ctx.variableSrv = new VariableSrv($rootscope, $q, {}, $injector, templateSrv, timeSrv);
ctx.variableSrv = new VariableSrv($q, {}, $injector, templateSrv, timeSrv);
$injector.instantiate = (variable, model) => {
return getVarMockConstructor(variable, model, ctx);

View File

@@ -18,18 +18,18 @@ export class VariableSrv {
variables: any[];
/** @ngInject */
constructor(private $rootScope,
private $q,
constructor(private $q,
private $location,
private $injector,
private templateSrv: TemplateSrv,
private timeSrv: TimeSrv) {
$rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope);
}
init(dashboard: DashboardModel) {
this.dashboard = dashboard;
this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this));
this.dashboard.events.on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this));
// create working class models representing variables
this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this));
@@ -59,7 +59,7 @@ export class VariableSrv {
return variable.updateOptions().then(() => {
if (angular.toJson(previousOptions) !== angular.toJson(variable.options)) {
this.$rootScope.$emit('template-variable-value-updated');
this.dashboard.templateVariableValueUpdated();
}
});
});
@@ -144,7 +144,7 @@ export class VariableSrv {
return this.$q.all(promises).then(() => {
if (emitChangeEvents) {
this.$rootScope.appEvent('template-variable-value-updated');
this.dashboard.templateVariableValueUpdated();
this.dashboard.startRefresh();
}
});

View File

@@ -1,17 +0,0 @@
<div dash-class ng-if="ctrl.dashboard">
<dashnav dashboard="ctrl.dashboard"></dashnav>
<div class="scroll-canvas scroll-canvas--dashboard" page-scrollbar>
<dashboard-settings dashboard="ctrl.dashboard"
ng-if="ctrl.dashboardViewState.state.editview"
class="dashboard-settings">
</dashboard-settings>
<div class="dashboard-container" ng-class="{'dashboard-container--has-submenu': ctrl.dashboard.meta.submenuEnabled}">
<dashboard-submenu ng-if="ctrl.dashboard.meta.submenuEnabled" dashboard="ctrl.dashboard">
</dashboard-submenu>
<dashboard-grid dashboard="ctrl.dashboard"></dashboard-grid>
</div>
</div>
</div>

View File

@@ -1,9 +1,11 @@
import config from 'app/core/config';
// Libraries
import _ from 'lodash';
import $ from 'jquery';
import Drop from 'tether-drop';
import { colors } from '@grafana/ui';
// Utils and servies
import { colors } from '@grafana/ui';
import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import { profiler } from 'app/core/profiler';
import appEvents from 'app/core/app_events';
@@ -13,6 +15,9 @@ import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource
import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
import { configureStore } from 'app/store/configureStore';
// Types
import { KioskUrlValue } from 'app/types';
export class GrafanaCtrl {
/** @ngInject */
constructor(
@@ -46,11 +51,6 @@ export class GrafanaCtrl {
$rootScope.colors = colors;
$scope.initDashboard = (dashboardData, viewScope) => {
$scope.appEvent('dashboard-fetch-end', dashboardData);
$controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData);
};
$rootScope.onAppEvent = function(name, callback, localScope) {
const unbind = $rootScope.$on(name, callback);
let callerScope = this;
@@ -72,7 +72,7 @@ export class GrafanaCtrl {
}
}
function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) {
function setViewModeBodyClass(body, mode: KioskUrlValue, sidemenuOpen: boolean) {
body.removeClass('view-mode--tv');
body.removeClass('view-mode--kiosk');
body.removeClass('view-mode--inactive');
@@ -126,12 +126,13 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
body.toggleClass('sidemenu-hidden');
});
scope.$watch(
() => playlistSrv.isPlaying,
newValue => {
elem.toggleClass('view-mode--playlist', newValue === true);
}
);
appEvents.on('playlist-started', () => {
elem.toggleClass('view-mode--playlist', true);
});
appEvents.on('playlist-stopped', () => {
elem.toggleClass('view-mode--playlist', false);
});
// check if we are in server side render
if (document.cookie.indexOf('renderKey') !== -1) {
@@ -165,6 +166,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
for (const drop of Drop.drops) {
drop.destroy();
}
appEvents.emit('hide-dash-search');
});
// handle kiosk mode
@@ -262,10 +265,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
}, 100);
}
if (target.parents('.navbar-buttons--playlist').length === 0) {
playlistSrv.stop();
}
// hide search
if (body.find('.search-container').length > 0) {
if (target.parents('.search-results-container, .search-field-wrapper').length === 0) {

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