Merge branch 'master' into tooling/storybook-poc

This commit is contained in:
Dominik Prokop 2019-01-28 17:36:41 +01:00
commit 7a8eb8c115
219 changed files with 3985 additions and 2145 deletions

View File

@ -19,7 +19,7 @@ version: 2
jobs: jobs:
mysql-integration-test: mysql-integration-test:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
- image: circleci/mysql:5.6-ram - image: circleci/mysql:5.6-ram
environment: environment:
MYSQL_ROOT_PASSWORD: rootpass MYSQL_ROOT_PASSWORD: rootpass
@ -39,7 +39,7 @@ jobs:
postgres-integration-test: postgres-integration-test:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
- image: circleci/postgres:9.3-ram - image: circleci/postgres:9.3-ram
environment: environment:
POSTGRES_USER: grafanatest POSTGRES_USER: grafanatest
@ -74,7 +74,7 @@ jobs:
gometalinter: gometalinter:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
environment: environment:
# we need CGO because of go-sqlite3 # we need CGO because of go-sqlite3
CGO_ENABLED: 1 CGO_ENABLED: 1
@ -106,7 +106,7 @@ jobs:
test-backend: test-backend:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -116,7 +116,7 @@ jobs:
build-all: build-all:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -147,9 +147,6 @@ jobs:
- run: - run:
name: sha-sum packages name: sha-sum packages
command: 'go run build.go sha-dist' command: 'go run build.go sha-dist'
- run:
name: Build Grafana.com master publisher
command: 'go build -o scripts/publish scripts/build/publish.go'
- run: - run:
name: Test and build Grafana.com release publisher name: Test and build Grafana.com release publisher
command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .' command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .'
@ -158,13 +155,12 @@ jobs:
paths: paths:
- dist/grafana* - dist/grafana*
- scripts/*.sh - scripts/*.sh
- scripts/publish
- scripts/build/release_publisher/release_publisher - scripts/build/release_publisher/release_publisher
- scripts/build/publish.sh - scripts/build/publish.sh
build: build:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -233,7 +229,7 @@ jobs:
build-enterprise: build-enterprise:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -265,7 +261,7 @@ jobs:
build-all-enterprise: build-all-enterprise:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -393,7 +389,7 @@ jobs:
name: Publish to Grafana.com name: Publish to Grafana.com
command: | command: |
rm dist/grafana-master-$(echo "${CIRCLE_SHA1}" | cut -b1-7).linux-x64.tar.gz rm dist/grafana-master-$(echo "${CIRCLE_SHA1}" | cut -b1-7).linux-x64.tar.gz
./scripts/publish -apiKey ${GRAFANA_COM_API_KEY} cd dist && ../scripts/build/release_publisher/release_publisher -apikey ${GRAFANA_COM_API_KEY} -from-local
deploy-release: deploy-release:
docker: docker:

View File

@ -1,29 +1,44 @@
# 5.5.0 (unreleased) # 6.0.0-beta1 (unreleased)
### New Features ### New Features
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster) * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
* **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968) * **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl)
* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109) * **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
### Minor ### Minor
* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi) * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175) * **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire) * **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK]req(https://github.com/IntegersOfK)
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483) * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548) * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas) * **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik) * **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok) * **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864) * **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484)
* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
### Bug fixes ### Bug fixes
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486) * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795) * **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy)
### Breaking changes
* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
* **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991)
# 5.4.3 (2019-01-14) # 5.4.3 (2019-01-14)

View File

@ -1,5 +1,5 @@
# Golang build container # Golang build container
FROM golang:1.11.4 FROM golang:1.11.5
WORKDIR $GOPATH/src/github.com/grafana/grafana WORKDIR $GOPATH/src/github.com/grafana/grafana
@ -19,11 +19,13 @@ COPY package.json package.json
RUN go run build.go build RUN go run build.go build
# Node build container # Node build container
FROM node:8 FROM node:10.14.2
WORKDIR /usr/src/app/ WORKDIR /usr/src/app/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
COPY packages packages
RUN yarn install --pure-lockfile --no-progress RUN yarn install --pure-lockfile --no-progress
COPY Gruntfile.js tsconfig.json tslint.json ./ COPY Gruntfile.js tsconfig.json tslint.json ./

View File

@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan.
### Short term (1-2 months) ### Short term (1-2 months)
- PRs & Bugs - PRs & Bugs
- Multi-Stat panel - React Panel Support
- React Query Editor Support
- Metrics & Log Explore UI - Metrics & Log Explore UI
- Grafana UI library shared between grafana & plugins
- Seperate visualization from panels
- More reuse between Explore & dashboard
- Explore logging support for more data sources
### Mid term (2-4 months) ### Mid term (2-4 months)
- React Panels - Drilldown links
- Change visualization (panel type) on the fly. - Dashboards as code workflows
- Templating Query Editor UI Plugin hook - React migration
- Backend plugins - New panels
### Long term (4 - 8 months) ### Long term (4 - 8 months)
- Alerting improvements (silence, per series tracking, etc) - Alerting improvements (silence, per series tracking, etc)
- Progress on React migration
### In a distant future far far away ### In a distant future far far away
- Meta queries - Meta queries

View File

@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment: environment:
nodejs_version: "8" nodejs_version: "8"
GOPATH: C:\gopath GOPATH: C:\gopath
GOVERSION: 1.11.4 GOVERSION: 1.11.5
install: install:
- rmdir c:\go /s /q - rmdir c:\go /s /q

View File

@ -46,6 +46,8 @@ var (
binaries []string = []string{"grafana-server", "grafana-cli"} binaries []string = []string{"grafana-server", "grafana-cli"}
isDev bool = false isDev bool = false
enterprise bool = false enterprise bool = false
skipRpmGen bool = false
skipDebGen bool = false
) )
func main() { func main() {
@ -67,6 +69,8 @@ func main() {
flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana") flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system") flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps") flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)")
flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)")
flag.Parse() flag.Parse()
buildId = shortenBuildId(buildIdRaw) buildId = shortenBuildId(buildIdRaw)
@ -165,6 +169,7 @@ func makeLatestDistCopies() {
".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm", ".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm",
".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz", ".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz", ".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz",
".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz", ".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
} }
@ -239,6 +244,8 @@ func createDebPackages() {
previousPkgArch := pkgArch previousPkgArch := pkgArch
if pkgArch == "armv7" { if pkgArch == "armv7" {
pkgArch = "armhf" pkgArch = "armhf"
} else if pkgArch == "armv6" {
pkgArch = "armel"
} }
createPackage(linuxPackageOptions{ createPackage(linuxPackageOptions{
packageType: "deb", packageType: "deb",
@ -289,8 +296,13 @@ func createRpmPackages() {
} }
func createLinuxPackages() { func createLinuxPackages() {
createDebPackages() if !skipDebGen {
createRpmPackages() createDebPackages()
}
if !skipRpmGen {
createRpmPackages()
}
} }
func createPackage(options linuxPackageOptions) { func createPackage(options linuxPackageOptions) {

View File

@ -106,6 +106,22 @@ path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database # For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private cache_mode = private
#################################### Login ###############################
[login]
# Login cookie name
cookie_name = grafana_session
# 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 #############################
[session] [session]
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file" # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@ -175,11 +191,6 @@ admin_password = admin
# used for signing # used for signing
secret_key = SW2YcwTIb9zpOOhoPsMm secret_key = SW2YcwTIb9zpOOhoPsMm
# Auto-login remember days
login_remember_days = 7
cookie_username = grafana_user
cookie_remember_name = grafana_remember
# disable gravatar profile images # disable gravatar profile images
disable_gravatar = false disable_gravatar = false
@ -189,6 +200,9 @@ data_source_proxy_whitelist =
# disable protection against brute force login attempts # disable protection against brute force login attempts
disable_brute_force_login_protection = false disable_brute_force_login_protection = false
# set cookies as https only. default is false
https_flag_cookies = false
#################################### Snapshots ########################### #################################### Snapshots ###########################
[snapshots] [snapshots]
# snapshot sharing options # snapshot sharing options
@ -490,7 +504,7 @@ concurrent_render_limit = 5
#################################### Explore ############################# #################################### Explore #############################
[explore] [explore]
# Enable the Explore section # Enable the Explore section
enabled = false enabled = true
#################################### Internal Grafana Metrics ############ #################################### Internal Grafana Metrics ############
# Metrics available at HTTP API Url /metrics # Metrics available at HTTP API Url /metrics
@ -570,6 +584,7 @@ callback_url =
[panels] [panels]
enable_alpha = false enable_alpha = false
disable_sanitize_html = false
[enterprise] [enterprise]
license_path = license_path =

View File

@ -102,6 +102,22 @@ log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared) # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private ;cache_mode = private
#################################### Login ###############################
[login]
# Login cookie name
;cookie_name = grafana_session
# 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 ####################################
[session] [session]
# Either "memory", "file", "redis", "mysql", "postgres", default is "file" # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@ -162,11 +178,6 @@ log_queries =
# used for signing # used for signing
;secret_key = SW2YcwTIb9zpOOhoPsMm ;secret_key = SW2YcwTIb9zpOOhoPsMm
# Auto-login remember days
;login_remember_days = 7
;cookie_username = grafana_user
;cookie_remember_name = grafana_remember
# disable gravatar profile images # disable gravatar profile images
;disable_gravatar = false ;disable_gravatar = false
@ -176,6 +187,9 @@ log_queries =
# disable protection against brute force login attempts # disable protection against brute force login attempts
;disable_brute_force_login_protection = false ;disable_brute_force_login_protection = false
# set cookies as https only. default is false
;https_flag_cookies = false
#################################### Snapshots ########################### #################################### Snapshots ###########################
[snapshots] [snapshots]
# snapshot sharing options # snapshot sharing options
@ -415,7 +429,7 @@ log_queries =
#################################### Explore ############################# #################################### Explore #############################
[explore] [explore]
# Enable the Explore section # Enable the Explore section
;enabled = false ;enabled = true
#################################### Internal Grafana Metrics ########################## #################################### Internal Grafana Metrics ##########################
# Metrics available at HTTP API Url /metrics # Metrics available at HTTP API Url /metrics
@ -495,3 +509,8 @@ log_queries =
# Path to a valid Grafana Enterprise license.jwt file # Path to a valid Grafana Enterprise license.jwt file
;license_path = ;license_path =
[panels]
;enable_alpha = false
# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
;disable_sanitize_html = false

View File

@ -54,7 +54,8 @@ services:
# - GF_DATABASE_SSL_MODE=disable # - GF_DATABASE_SSL_MODE=disable
# - GF_SESSION_PROVIDER=postgres # - GF_SESSION_PROVIDER=postgres
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable # - 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 - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
- GF_LOGIN_ROTATE_TOKEN_MINUTES=2
ports: ports:
- 3000 - 3000
depends_on: depends_on:

View File

@ -0,0 +1,69 @@
# Grafana load test
Runs load tests and checks using [k6](https://k6.io/).
## Prerequisites
Docker
## Run
Run load test for 15 minutes:
```bash
$ ./run.sh
```
Run load test for custom duration:
```bash
$ ./run.sh -d 10s
```
Example output:
```bash
/\ |‾‾| /‾‾/ /‾/
/\ / \ | |_/ / / /
/ \/ \ | | / ‾‾\
/ \ | |‾\ \ | (_) |
/ __________ \ |__| \__\ \___/ .io
execution: local
output: -
script: src/auth_token_test.js
duration: 15m0s, iterations: -
vus: 2, max: 2
done [==========================================================] 15m0s / 15m0s
█ user auth token test
█ user authenticates thru ui with username and password
✓ response status is 200
✓ response has cookie 'grafana_session' with 32 characters
█ batch tsdb requests
✓ response status is 200
checks.....................: 100.00% ✓ 32844 ✗ 0
data_received..............: 411 MB 457 kB/s
data_sent..................: 12 MB 14 kB/s
group_duration.............: avg=95.64ms min=16.42ms med=94.35ms max=307.52ms p(90)=137.78ms p(95)=146.75ms
http_req_blocked...........: avg=1.27ms min=942ns med=610.08µs max=48.32ms p(90)=2.92ms p(95)=4.25ms
http_req_connecting........: avg=1.06ms min=0s med=456.79µs max=47.19ms p(90)=2.55ms p(95)=3.78ms
http_req_duration..........: avg=58.16ms min=1ms med=52.59ms max=293.35ms p(90)=109.53ms p(95)=120.19ms
http_req_receiving.........: avg=38.98µs min=6.43µs med=32.55µs max=16.2ms p(90)=64.63µs p(95)=78.8µs
http_req_sending...........: avg=328.66µs min=8.09µs med=110.77µs max=44.13ms p(90)=552.65µs p(95)=1.09ms
http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...........: avg=57.79ms min=935.02µs med=52.15ms max=293.06ms p(90)=109.04ms p(95)=119.71ms
http_reqs..................: 34486 38.317775/s
iteration_duration.........: avg=1.09s min=1.81µs med=1.09s max=1.3s p(90)=1.13s p(95)=1.14s
iterations.................: 1642 1.824444/s
vus........................: 2 min=2 max=2
vus_max....................: 2 min=2 max=2
```

View File

@ -0,0 +1,71 @@
import { sleep, check, group } from 'k6';
import { createClient, createBasicAuthClient } from './modules/client.js';
import { createTestOrgIfNotExists, createTestdataDatasourceIfNotExists } from './modules/util.js';
export let options = {
noCookiesReset: true
};
let endpoint = __ENV.URL || 'http://localhost:3000';
const client = createClient(endpoint);
export const setup = () => {
const basicAuthClient = createBasicAuthClient(endpoint, 'admin', 'admin');
const orgId = createTestOrgIfNotExists(basicAuthClient);
const datasourceId = createTestdataDatasourceIfNotExists(basicAuthClient);
client.withOrgId(orgId);
return {
orgId: orgId,
datasourceId: datasourceId,
};
}
export default (data) => {
group("user auth token test", () => {
if (__ITER === 0) {
group("user authenticates thru ui with username and password", () => {
let res = client.ui.login('admin', 'admin');
check(res, {
'response status is 200': (r) => r.status === 200,
'response has cookie \'grafana_session\' with 32 characters': (r) => r.cookies.grafana_session[0].value.length === 32,
});
});
}
if (__ITER !== 0) {
group("batch tsdb requests", () => {
const batchCount = 20;
const requests = [];
const payload = {
from: '1547765247624',
to: '1547768847624',
queries: [{
refId: 'A',
scenarioId: 'random_walk',
intervalMs: 10000,
maxDataPoints: 433,
datasourceId: data.datasourceId,
}]
};
requests.push({ method: 'GET', url: '/api/annotations?dashboardId=2074&from=1548078832772&to=1548082432772' });
for (let n = 0; n < batchCount; n++) {
requests.push({ method: 'POST', url: '/api/tsdb/query', body: payload });
}
let responses = client.batch(requests);
for (let n = 0; n < batchCount; n++) {
check(responses[n], {
'response status is 200': (r) => r.status === 200,
});
}
});
}
});
sleep(1)
}
export const teardown = (data) => {}

View File

@ -0,0 +1,187 @@
import http from "k6/http";
import encoding from 'k6/encoding';
export const UIEndpoint = class UIEndpoint {
constructor(httpClient) {
this.httpClient = httpClient;
}
login(username, pwd) {
const payload = { user: username, password: pwd };
return this.httpClient.formPost('/login', payload);
}
}
export const DatasourcesEndpoint = class DatasourcesEndpoint {
constructor(httpClient) {
this.httpClient = httpClient;
}
getById(id) {
return this.httpClient.get(`/datasources/${id}`);
}
getByName(name) {
return this.httpClient.get(`/datasources/name/${name}`);
}
create(payload) {
return this.httpClient.post(`/datasources`, JSON.stringify(payload));
}
delete(id) {
return this.httpClient.delete(`/datasources/${id}`);
}
}
export const OrganizationsEndpoint = class OrganizationsEndpoint {
constructor(httpClient) {
this.httpClient = httpClient;
}
getById(id) {
return this.httpClient.get(`/orgs/${id}`);
}
getByName(name) {
return this.httpClient.get(`/orgs/name/${name}`);
}
create(name) {
let payload = {
name: name,
};
return this.httpClient.post(`/orgs`, JSON.stringify(payload));
}
delete(id) {
return this.httpClient.delete(`/orgs/${id}`);
}
}
export const GrafanaClient = class GrafanaClient {
constructor(httpClient) {
httpClient.onBeforeRequest = this.onBeforeRequest;
this.raw = httpClient;
this.ui = new UIEndpoint(httpClient);
this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api'));
this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api'));
}
batch(requests) {
return this.raw.batch(requests);
}
withOrgId(orgId) {
this.orgId = orgId;
}
onBeforeRequest(params) {
if (this.orgId && this.orgId > 0) {
params = params.headers || {};
params.headers["X-Grafana-Org-Id"] = this.orgId;
}
}
}
export const BaseClient = class BaseClient {
constructor(url, subUrl) {
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
if (subUrl.endsWith('/')) {
subUrl = subUrl.substring(0, subUrl.length - 1);
}
this.url = url + subUrl;
this.onBeforeRequest = () => {};
}
withUrl(subUrl) {
let c = new BaseClient(this.url, subUrl);
c.onBeforeRequest = this.onBeforeRequest;
return c;
}
beforeRequest(params) {
}
get(url, params) {
params = params || {};
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.get(this.url + url, params);
}
formPost(url, body, params) {
params = params || {};
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.post(this.url + url, body, params);
}
post(url, body, params) {
params = params || {};
params.headers = params.headers || {};
params.headers['Content-Type'] = 'application/json';
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.post(this.url + url, body, params);
}
delete(url, params) {
params = params || {};
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.del(this.url + url, null, params);
}
batch(requests) {
for (let n = 0; n < requests.length; n++) {
let params = requests[n].params || {};
params.headers = params.headers || {};
params.headers['Content-Type'] = 'application/json';
this.beforeRequest(params);
this.onBeforeRequest(params);
requests[n].params = params;
requests[n].url = this.url + requests[n].url;
if (requests[n].body) {
requests[n].body = JSON.stringify(requests[n].body);
}
}
return http.batch(requests);
}
}
export class BasicAuthClient extends BaseClient {
constructor(url, subUrl, username, password) {
super(url, subUrl);
this.username = username;
this.password = password;
}
withUrl(subUrl) {
let c = new BasicAuthClient(this.url, subUrl, this.username, this.password);
c.onBeforeRequest = this.onBeforeRequest;
return c;
}
beforeRequest(params) {
params = params || {};
params.headers = params.headers || {};
let token = `${this.username}:${this.password}`;
params.headers['Authorization'] = `Basic ${encoding.b64encode(token)}`;
}
}
export const createClient = (url) => {
return new GrafanaClient(new BaseClient(url, ''));
}
export const createBasicAuthClient = (url, username, password) => {
return new GrafanaClient(new BasicAuthClient(url, '', username, password));
}

View File

@ -0,0 +1,35 @@
export const createTestOrgIfNotExists = (client) => {
let orgId = 0;
let res = client.orgs.getByName('k6');
if (res.status === 404) {
res = client.orgs.create('k6');
if (res.status !== 200) {
throw new Error('Expected 200 response status when creating org');
}
orgId = res.json().orgId;
} else {
orgId = res.json().id;
}
client.withOrgId(orgId);
return orgId;
}
export const createTestdataDatasourceIfNotExists = (client) => {
const payload = {
access: 'proxy',
isDefault: false,
name: 'k6-testdata',
type: 'testdata',
};
let res = client.datasources.getByName(payload.name);
if (res.status === 404) {
res = client.datasources.create(payload);
if (res.status !== 200) {
throw new Error('Expected 200 response status when creating datasource');
}
}
return res.json().id;
}

24
devenv/docker/loadtest/run.sh Executable file
View File

@ -0,0 +1,24 @@
#/bin/bash
PWD=$(pwd)
run() {
duration='15m'
url='http://localhost:3000'
while getopts ":d:u:" o; do
case "${o}" in
d)
duration=${OPTARG}
;;
u)
url=${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
}
run "$@"

View File

@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"defaultRegion": "us-west-1" "defaultRegion": "us-west-1"
}, },
"secureJsonData": { "secureJsonData": {
"accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded "accessKey": "Ol4pIDpeKSA6XikgOl4p",
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs"
} }
} }
``` ```

View File

@ -83,3 +83,28 @@ Content-Type: application/json
{"message": "Logged in"} {"message": "Logged in"}
``` ```
# Health API
## Returns health information about Grafana
`GET /api/health`
**Example Request**
```http
GET /api/health
Accept: application/json
```
**Example Response**:
```http
HTTP/1.1 200 OK
{
"commit": "087143285",
"database": "ok",
"version": "5.1.3"
}
```

View File

@ -391,6 +391,12 @@ value is `true`.
If you want to track Grafana usage via Google analytics specify *your* Universal If you want to track Grafana usage via Google analytics specify *your* Universal
Analytics ID here. By default this feature is disabled. Analytics ID here. By default this feature is disabled.
### check_for_updates
Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used
in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor
send any sensitive information.
<hr /> <hr />
## [dashboards] ## [dashboards]
@ -589,3 +595,14 @@ Default setting for how Grafana handles nodata or null values in alerting. (aler
Alert notifications can include images, but rendering many images at the same time can overload the server. Alert notifications can include images, but rendering many images at the same time can overload the server.
This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
value is `5`. value is `5`.
## [panels]
### enable_alpha
Set to true if you want to test panels that are not yet ready for general usage.
### disable_sanitize_html
If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. Default
is false. This settings was introduced in Grafana v6.0.

View File

@ -52,6 +52,7 @@ Filter Option | Example | Raw | Interpolated | Description
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string `csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
`distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB. `distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
`lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression. `lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1). Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1).

View File

@ -5,7 +5,7 @@
"company": "Grafana Labs" "company": "Grafana Labs"
}, },
"name": "grafana", "name": "grafana",
"version": "5.5.0-pre1", "version": "6.0.0-pre1",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "http://github.com/grafana/grafana.git" "url": "http://github.com/grafana/grafana.git"
@ -188,7 +188,8 @@
"slate-react": "^0.12.4", "slate-react": "^0.12.4",
"tether": "^1.4.0", "tether": "^1.4.0",
"tether-drop": "https://github.com/torkelo/drop/tarball/master", "tether-drop": "https://github.com/torkelo/drop/tarball/master",
"tinycolor2": "^1.4.1" "tinycolor2": "^1.4.1",
"xss": "^1.0.3"
}, },
"resolutions": { "resolutions": {
"caniuse-db": "1.0.30000772", "caniuse-db": "1.0.30000772",

View File

@ -7,12 +7,12 @@ interface Props {
autoHide?: boolean; autoHide?: boolean;
autoHideTimeout?: number; autoHideTimeout?: number;
autoHideDuration?: number; autoHideDuration?: number;
autoMaxHeight?: string; autoHeightMax?: string;
hideTracksWhenNotNeeded?: boolean; hideTracksWhenNotNeeded?: boolean;
renderTrackHorizontal?: React.FunctionComponent<any>; renderTrackHorizontal?: React.FunctionComponent<any>;
renderTrackVertical?: React.FunctionComponent<any>; renderTrackVertical?: React.FunctionComponent<any>;
scrollTop?: number; scrollTop?: number;
setScrollTop: (value: React.MouseEvent<HTMLElement>) => void; setScrollTop: (event: any) => void;
autoHeightMin?: number | string; autoHeightMin?: number | string;
} }
@ -22,13 +22,13 @@ interface Props {
export class CustomScrollbar extends PureComponent<Props> { export class CustomScrollbar extends PureComponent<Props> {
static defaultProps: Partial<Props> = { static defaultProps: Partial<Props> = {
customClassName: 'custom-scrollbars', customClassName: 'custom-scrollbars',
autoHide: true, autoHide: false,
autoHideTimeout: 200, autoHideTimeout: 200,
autoHideDuration: 200, autoHideDuration: 200,
autoMaxHeight: '100%',
hideTracksWhenNotNeeded: false,
setScrollTop: () => {}, setScrollTop: () => {},
hideTracksWhenNotNeeded: false,
autoHeightMin: '0', autoHeightMin: '0',
autoHeightMax: '100%',
}; };
private ref: React.RefObject<Scrollbars>; private ref: React.RefObject<Scrollbars>;
@ -59,16 +59,32 @@ export class CustomScrollbar extends PureComponent<Props> {
} }
render() { render() {
const { customClassName, children, autoMaxHeight, renderTrackHorizontal, renderTrackVertical } = this.props; const {
customClassName,
children,
autoHeightMax,
autoHeightMin,
setScrollTop,
autoHide,
autoHideTimeout,
hideTracksWhenNotNeeded,
renderTrackHorizontal,
renderTrackVertical,
} = this.props;
return ( return (
<Scrollbars <Scrollbars
ref={this.ref} ref={this.ref}
className={customClassName} className={customClassName}
onScroll={setScrollTop}
autoHeight={true} autoHeight={true}
autoHide={autoHide}
autoHideTimeout={autoHideTimeout}
hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
// These autoHeightMin & autoHeightMax options affect firefox and chrome differently. // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
// Before these where set to inhert but that caused problems with cut of legends in firefox // Before these where set to inhert but that caused problems with cut of legends in firefox
autoHeightMax={autoMaxHeight} autoHeightMax={autoHeightMax}
autoHeightMin={autoHeightMin}
renderTrackHorizontal={renderTrackHorizontal || (props => <div {...props} className="track-horizontal" />)} renderTrackHorizontal={renderTrackHorizontal || (props => <div {...props} className="track-horizontal" />)}
renderTrackVertical={renderTrackVertical || (props => <div {...props} className="track-vertical" />)} renderTrackVertical={renderTrackVertical || (props => <div {...props} className="track-vertical" />)}
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />} renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}

View File

@ -7,7 +7,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
Object { Object {
"height": "auto", "height": "auto",
"maxHeight": "100%", "maxHeight": "100%",
"minHeight": 0, "minHeight": "0",
"overflow": "hidden", "overflow": "hidden",
"position": "relative", "position": "relative",
"width": "100%", "width": "100%",
@ -24,7 +24,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
"marginBottom": 0, "marginBottom": 0,
"marginRight": 0, "marginRight": 0,
"maxHeight": "calc(100% + 0px)", "maxHeight": "calc(100% + 0px)",
"minHeight": 0, "minHeight": "calc(0 + 0px)",
"overflow": "scroll", "overflow": "scroll",
"position": "relative", "position": "relative",
"right": undefined, "right": undefined,

View File

@ -98,83 +98,6 @@ describe('Get thresholds formatted', () => {
}); });
}); });
describe('Format value with value mappings', () => {
it('should return undefined with no valuemappings', () => {
const valueMappings: ValueMapping[] = [];
const value = '10';
const { instance } = setup({ valueMappings });
const result = instance.getFirstFormattedValueMapping(valueMappings, value);
expect(result).toBeUndefined();
});
it('should return undefined with no matching valuemappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
const { instance } = setup({ valueMappings });
const result = instance.getFirstFormattedValueMapping(valueMappings, value);
expect(result).toBeUndefined();
});
it('should return first matching mapping with lowest id', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' },
];
const value = '10';
const { instance } = setup({ valueMappings });
const result = instance.getFirstFormattedValueMapping(valueMappings, value);
expect(result.text).toEqual('1-20');
});
it('should return rangeToText mapping where value equals to', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '10';
const { instance } = setup({ valueMappings });
const result = instance.getFirstFormattedValueMapping(valueMappings, value);
expect(result.text).toEqual('1-10');
});
it('should return rangeToText mapping where value equals from', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '10';
const { instance } = setup({ valueMappings });
const result = instance.getFirstFormattedValueMapping(valueMappings, value);
expect(result.text).toEqual('10-20');
});
it('should return rangeToText mapping where value is between from and to', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '10';
const { instance } = setup({ valueMappings });
const result = instance.getFirstFormattedValueMapping(valueMappings, value);
expect(result.text).toEqual('1-20');
});
});
describe('Format value', () => { describe('Format value', () => {
it('should return if value isNaN', () => { it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = []; const valueMappings: ValueMapping[] = [];

View File

@ -1,10 +1,10 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import $ from 'jquery'; import $ from 'jquery';
import { ValueMapping, Threshold, MappingType, BasicGaugeColor, ValueMap, RangeMap } from '../../types/panel'; import { ValueMapping, Threshold, BasicGaugeColor } from '../../types/panel';
import { TimeSeriesVMs } from '../../types/series'; import { TimeSeriesVMs } from '../../types/series';
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
import { GrafanaTheme } from '../../types'; import { GrafanaTheme } from '../../types';
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette'; import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
type TimeSeriesValue = string | number | null; type TimeSeriesValue = string | number | null;
@ -52,70 +52,6 @@ export class Gauge extends PureComponent<Props> {
this.draw(); this.draw();
} }
addValueToTextMappingText(allValueMappings: ValueMapping[], valueToTextMapping: ValueMap, value: TimeSeriesValue) {
if (!valueToTextMapping.value) {
return allValueMappings;
}
const valueAsNumber = parseFloat(value as string);
const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string);
if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) {
return allValueMappings;
}
if (valueAsNumber !== valueToTextMappingAsNumber) {
return allValueMappings;
}
return allValueMappings.concat(valueToTextMapping);
}
addRangeToTextMappingText(allValueMappings: ValueMapping[], rangeToTextMapping: RangeMap, value: TimeSeriesValue) {
if (!rangeToTextMapping.from || !rangeToTextMapping.to || !value) {
return allValueMappings;
}
const valueAsNumber = parseFloat(value as string);
const fromAsNumber = parseFloat(rangeToTextMapping.from as string);
const toAsNumber = parseFloat(rangeToTextMapping.to as string);
if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) {
return allValueMappings;
}
if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) {
return allValueMappings.concat(rangeToTextMapping);
}
return allValueMappings;
}
getAllFormattedValueMappings(valueMappings: ValueMapping[], value: TimeSeriesValue) {
const allFormattedValueMappings = valueMappings.reduce(
(allValueMappings, valueMapping) => {
if (valueMapping.type === MappingType.ValueToText) {
allValueMappings = this.addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value);
} else if (valueMapping.type === MappingType.RangeToText) {
allValueMappings = this.addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value);
}
return allValueMappings;
},
[] as ValueMapping[]
);
allFormattedValueMappings.sort((t1, t2) => {
return t1.id - t2.id;
});
return allFormattedValueMappings;
}
getFirstFormattedValueMapping(valueMappings: ValueMapping[], value: TimeSeriesValue) {
return this.getAllFormattedValueMappings(valueMappings, value)[0];
}
formatValue(value: TimeSeriesValue) { formatValue(value: TimeSeriesValue) {
const { decimals, valueMappings, prefix, suffix, unit } = this.props; const { decimals, valueMappings, prefix, suffix, unit } = this.props;
@ -124,7 +60,7 @@ export class Gauge extends PureComponent<Props> {
} }
if (valueMappings.length > 0) { if (valueMappings.length > 0) {
const valueMappedValue = this.getFirstFormattedValueMapping(valueMappings, value); const valueMappedValue = getMappedValue(valueMappings, value);
if (valueMappedValue) { if (valueMappedValue) {
return `${prefix} ${valueMappedValue.text} ${suffix}`; return `${prefix} ${valueMappedValue.text} ${suffix}`;
} }
@ -132,8 +68,9 @@ export class Gauge extends PureComponent<Props> {
const formatFunc = getValueFormat(unit); const formatFunc = getValueFormat(unit);
const formattedValue = formatFunc(value as number, decimals); const formattedValue = formatFunc(value as number, decimals);
const handleNoValueValue = formattedValue || 'no value';
return `${prefix} ${formattedValue} ${suffix}`; return `${prefix} ${handleNoValueValue} ${suffix}`;
} }
getFontColor(value: TimeSeriesValue) { getFontColor(value: TimeSeriesValue) {
@ -197,7 +134,7 @@ export class Gauge extends PureComponent<Props> {
if (timeSeries[0]) { if (timeSeries[0]) {
value = timeSeries[0].stats[stat]; value = timeSeries[0].stats[stat];
} else { } else {
value = 'N/A'; value = null;
} }
const dimension = Math.min(width, height * 1.3); const dimension = Math.min(width, height * 1.3);

View File

@ -61,7 +61,7 @@ interface AsyncProps {
export const MenuList = (props: any) => { export const MenuList = (props: any) => {
return ( return (
<components.MenuList {...props}> <components.MenuList {...props}>
<CustomScrollbar autoHide={false} autoMaxHeight="inherit">{props.children}</CustomScrollbar> <CustomScrollbar autoHide={false} autoHeightMax="inherit">{props.children}</CustomScrollbar>
</components.MenuList> </components.MenuList>
); );
}; };

View File

@ -0,0 +1,81 @@
import { getMappedValue } from './valueMappings';
import { ValueMapping, MappingType } from '../types/panel';
describe('Format value with value mappings', () => {
it('should return undefined with no valuemappings', () => {
const valueMappings: ValueMapping[] = [];
const value = '10';
expect(getMappedValue(valueMappings, value)).toBeUndefined();
});
it('should return undefined with no matching valuemappings', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
expect(getMappedValue(valueMappings, value)).toBeUndefined();
});
it('should return first matching mapping with lowest id', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' },
];
const value = '10';
expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
});
it('should return if value is null and value to text mapping value is null', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: '<NULL>', type: MappingType.ValueToText, value: 'null' },
];
const value = null;
expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>');
});
it('should return if value is null and range to text mapping from and to is null', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '<NULL>', type: MappingType.RangeToText, from: 'null', to: 'null' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = null;
expect(getMappedValue(valueMappings, value).text).toEqual('<NULL>');
});
it('should return rangeToText mapping where value equals to', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '10';
expect(getMappedValue(valueMappings, value).text).toEqual('1-10');
});
it('should return rangeToText mapping where value equals from', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '10';
expect(getMappedValue(valueMappings, value).text).toEqual('10-20');
});
it('should return rangeToText mapping where value is between from and to', () => {
const valueMappings: ValueMapping[] = [
{ id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '10';
expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
});
});

View File

@ -0,0 +1,89 @@
import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types';
export type TimeSeriesValue = string | number | null;
const addValueToTextMappingText = (
allValueMappings: ValueMapping[],
valueToTextMapping: ValueMap,
value: TimeSeriesValue
) => {
if (valueToTextMapping.value === undefined) {
return allValueMappings;
}
if (value === null && valueToTextMapping.value && valueToTextMapping.value.toLowerCase() === 'null') {
return allValueMappings.concat(valueToTextMapping);
}
const valueAsNumber = parseFloat(value as string);
const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string);
if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) {
return allValueMappings;
}
if (valueAsNumber !== valueToTextMappingAsNumber) {
return allValueMappings;
}
return allValueMappings.concat(valueToTextMapping);
};
const addRangeToTextMappingText = (
allValueMappings: ValueMapping[],
rangeToTextMapping: RangeMap,
value: TimeSeriesValue
) => {
if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) {
return allValueMappings;
}
if (
value === null &&
rangeToTextMapping.from &&
rangeToTextMapping.to &&
rangeToTextMapping.from.toLowerCase() === 'null' &&
rangeToTextMapping.to.toLowerCase() === 'null'
) {
return allValueMappings.concat(rangeToTextMapping);
}
const valueAsNumber = parseFloat(value as string);
const fromAsNumber = parseFloat(rangeToTextMapping.from as string);
const toAsNumber = parseFloat(rangeToTextMapping.to as string);
if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) {
return allValueMappings;
}
if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) {
return allValueMappings.concat(rangeToTextMapping);
}
return allValueMappings;
};
const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => {
const allFormattedValueMappings = valueMappings.reduce(
(allValueMappings, valueMapping) => {
if (valueMapping.type === MappingType.ValueToText) {
allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value);
} else if (valueMapping.type === MappingType.RangeToText) {
allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value);
}
return allValueMappings;
},
[] as ValueMapping[]
);
allFormattedValueMappings.sort((t1, t2) => {
return t1.id - t2.id;
});
return allFormattedValueMappings;
};
export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => {
return getAllFormattedValueMappings(valueMappings, value)[0];
};

View File

@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() {
// not logged in views // not logged in views
r.Get("/", reqSignedIn, hs.Index) r.Get("/", reqSignedIn, hs.Index)
r.Get("/logout", Logout) r.Get("/logout", hs.Logout)
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost)) r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost))
r.Get("/login/:name", quota("session"), OAuthLogin) r.Get("/login/:name", quota("session"), hs.OAuthLogin)
r.Get("/login", hs.LoginView) r.Get("/login", hs.LoginView)
r.Get("/invite/:code", hs.Index) r.Get("/invite/:code", hs.Index)
@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/signup", hs.Index) r.Get("/signup", hs.Index)
r.Get("/api/user/signup/options", Wrap(GetSignUpOptions)) r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp)) r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2)) r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2))
// invited // invited
r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode)) r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite)) r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite))
// reset password // reset password
r.Get("/user/password/send-reset-email", hs.Index) r.Get("/user/password/send-reset-email", hs.Index)
@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot)) r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
// api renew session based on remember cookie // api renew session based on remember cookie
r.Get("/api/login/ping", quota("session"), LoginAPIPing) r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
// authed api // authed api
r.Group("/api", func(apiRoute routing.RouteRegister) { r.Group("/api", func(apiRoute routing.RouteRegister) {

View File

@ -5,7 +5,6 @@ import (
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
} }
type scenarioContext struct { type scenarioContext struct {
m *macaron.Macaron m *macaron.Macaron
context *m.ReqContext context *m.ReqContext
resp *httptest.ResponseRecorder resp *httptest.ResponseRecorder
handlerFunc handlerFunc handlerFunc handlerFunc
defaultHandler macaron.Handler defaultHandler macaron.Handler
req *http.Request req *http.Request
url string url string
userAuthTokenService *fakeUserAuthTokenService
} }
func (sc *scenarioContext) exec() { func (sc *scenarioContext) exec() {
@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
Delims: macaron.Delims{Left: "[[", Right: "]]"}, Delims: macaron.Delims{Left: "[[", Right: "]]"},
})) }))
sc.m.Use(middleware.GetContextHandler()) sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(middleware.Sessioner(&session.Options{}, 0)) sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
return sc 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) UserSignedOutHook(c *m.ReqContext) {}

View File

@ -166,6 +166,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl, "externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
"externalUserMngLinkName": setting.ExternalUserMngLinkName, "externalUserMngLinkName": setting.ExternalUserMngLinkName,
"viewersCanEdit": setting.ViewersCanEdit, "viewersCanEdit": setting.ViewersCanEdit,
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
"buildInfo": map[string]interface{}{ "buildInfo": map[string]interface{}{
"version": setting.BuildVersion, "version": setting.BuildVersion,
"commit": setting.BuildCommit, "commit": setting.BuildCommit,

View File

@ -11,14 +11,8 @@ import (
"path" "path"
"time" "time"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
macaron "gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/api/live" "github.com/grafana/grafana/pkg/api/live"
"github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static" httpstatic "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
@ -27,11 +21,16 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry" "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/cache"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
macaron "gopkg.in/macaron.v1"
) )
func init() { func init() {
@ -49,13 +48,14 @@ type HTTPServer struct {
streamManager *live.StreamManager streamManager *live.StreamManager
httpSrv *http.Server httpSrv *http.Server
RouteRegister routing.RouteRegister `inject:""` RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""` Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""` RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""` Cfg *setting.Cfg `inject:""`
HooksService *hooks.HooksService `inject:""` HooksService *hooks.HooksService `inject:""`
CacheService *cache.CacheService `inject:""` CacheService *cache.CacheService `inject:""`
DatasourceCache datasources.CacheService `inject:""` DatasourceCache datasources.CacheService `inject:""`
AuthTokenService auth.UserAuthTokenService `inject:""`
} }
func (hs *HTTPServer) Init() error { func (hs *HTTPServer) Init() error {
@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
hs.macaron = hs.newMacaron() hs.macaron = hs.newMacaron()
hs.registerRoutes() hs.registerRoutes()
session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
return nil return nil
} }
@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
m.Use(hs.healthHandler) m.Use(hs.healthHandler)
m.Use(hs.metricsEndpoint) m.Use(hs.metricsEndpoint)
m.Use(middleware.GetContextHandler()) m.Use(middleware.GetContextHandler(hs.AuthTokenService))
m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
m.Use(middleware.OrgRedirect()) m.Use(middleware.OrgRedirect())
// needs to be after context handler // needs to be after context handler

View File

@ -1,6 +1,8 @@
package api package api
import ( import (
"encoding/hex"
"net/http"
"net/url" "net/url"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
@ -9,12 +11,13 @@ import (
"github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
) )
const ( const (
ViewIndex = "index" ViewIndex = "index"
LoginErrorCookieName = "login_error"
) )
func (hs *HTTPServer) LoginView(c *m.ReqContext) { func (hs *HTTPServer) LoginView(c *m.ReqContext) {
@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
viewData.Settings["loginHint"] = setting.LoginHint viewData.Settings["loginHint"] = setting.LoginHint
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
if loginError, ok := c.Session.Get("loginError").(string); ok { if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
c.Session.Delete("loginError") deleteCookie(c, LoginErrorCookieName)
viewData.Settings["loginError"] = loginError viewData.Settings["loginError"] = loginError
} }
@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
return return
} }
if !tryLoginUsingRememberCookie(c) { if !c.IsSignedIn {
c.HTML(200, ViewIndex, viewData) c.HTML(200, ViewIndex, viewData)
return return
} }
@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool {
return false return false
} }
func tryLoginUsingRememberCookie(c *m.ReqContext) bool { func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
// Check auto-login. if c.IsSignedIn || c.IsAnonymous {
uname := c.GetCookie(setting.CookieUserName) return JSON(200, "Logged in")
if len(uname) == 0 {
return false
} }
isSucceed := false return Error(401, "Unauthorized", nil)
defer func() {
if !isSucceed {
log.Trace("auto-login cookie cleared: %s", uname)
c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
return
}
}()
userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname}
if err := bus.Dispatch(&userQuery); err != nil {
return false
}
user := userQuery.Result
// validate remember me cookie
signingKey := user.Rands + user.Password
if len(signingKey) < 10 {
c.Logger.Error("Invalid user signingKey")
return false
}
if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login {
return false
}
isSucceed = true
loginUserWithUser(user, c)
return true
} }
func LoginAPIPing(c *m.ReqContext) { func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
if !tryLoginUsingRememberCookie(c) {
c.JsonApiErr(401, "Unauthorized", nil)
return
}
c.JsonOK("Logged in")
}
func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
if setting.DisableLoginForm { if setting.DisableLoginForm {
return Error(401, "Login is disabled", nil) return Error(401, "Login is disabled", nil)
} }
@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
user := authQuery.User user := authQuery.User
loginUserWithUser(user, c) hs.loginUserWithUser(user, c)
result := map[string]interface{}{ result := map[string]interface{}{
"message": "Logged in", "message": "Logged in",
@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
return JSON(200, result) return JSON(200, result)
} }
func loginUserWithUser(user *m.User, c *m.ReqContext) { func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
if user == nil { if user == nil {
log.Error(3, "User login with nil user") hs.log.Error("User login with nil user")
} }
c.Resp.Header().Del("Set-Cookie") err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
if err != nil {
days := 86400 * setting.LogInRememberDays hs.log.Error("User auth hook failed", "error", err)
if days > 0 {
c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/")
c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/")
} }
c.Session.RegenerateId(c.Context)
c.Session.Set(session.SESS_KEY_USERID, user.Id)
} }
func Logout(c *m.ReqContext) { func (hs *HTTPServer) Logout(c *m.ReqContext) {
c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/") hs.AuthTokenService.UserSignedOutHook(c)
c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
c.Session.Destory(c.Context)
if setting.SignoutRedirectUrl != "" { if setting.SignoutRedirectUrl != "" {
c.Redirect(setting.SignoutRedirectUrl) c.Redirect(setting.SignoutRedirectUrl)
} else { } else {
c.Redirect(setting.AppSubUrl + "/login") c.Redirect(setting.AppSubUrl + "/login")
} }
} }
func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) {
cookie := ctx.GetCookie(cookieName)
if cookie == "" {
return "", false
}
decoded, err := hex.DecodeString(cookie)
if err != nil {
return "", false
}
decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey)
return string(decryptedError), err == nil
}
func deleteCookie(ctx *m.ReqContext, cookieName string) {
ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/")
}
func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error {
encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey)
if err != nil {
return err
}
http.SetCookie(ctx.Resp, &http.Cookie{
Name: cookieName,
MaxAge: 60,
Value: hex.EncodeToString(encryptedError),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.SecurityHTTPSCookies,
})
return nil
}

View File

@ -3,9 +3,11 @@ package api
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/hex"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
@ -18,12 +20,14 @@ import (
"github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/social" "github.com/grafana/grafana/pkg/social"
) )
var oauthLogger = log.New("oauth") var (
oauthLogger = log.New("oauth")
OauthStateCookieName = "oauth_state"
)
func GenStateString() string { func GenStateString() string {
rnd := make([]byte, 32) rnd := make([]byte, 32)
@ -31,7 +35,7 @@ func GenStateString() string {
return base64.URLEncoding.EncodeToString(rnd) return base64.URLEncoding.EncodeToString(rnd)
} }
func OAuthLogin(ctx *m.ReqContext) { func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
if setting.OAuthService == nil { if setting.OAuthService == nil {
ctx.Handle(404, "OAuth not enabled", nil) ctx.Handle(404, "OAuth not enabled", nil)
return return
@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) {
if errorParam != "" { if errorParam != "" {
errorDesc := ctx.Query("error_description") errorDesc := ctx.Query("error_description")
oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc) oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
return return
} }
code := ctx.Query("code") code := ctx.Query("code")
if code == "" { if code == "" {
state := GenStateString() state := GenStateString()
ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state) hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60)
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" { if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline)) ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
} else { } else {
@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) {
return return
} }
savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string) cookieState := ctx.GetCookie(OauthStateCookieName)
if !ok {
// delete cookie
ctx.Resp.Header().Del("Set-Cookie")
hs.deleteCookie(ctx.Resp, OauthStateCookieName)
if cookieState == "" {
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
return return
} }
queryState := ctx.Query("state") queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret)
if savedState != queryState { oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState)
if cookieState != queryState {
ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil) ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
return return
} }
@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) {
userInfo, err := connect.UserInfo(client, token) userInfo, err := connect.UserInfo(client, token)
if err != nil { if err != nil {
if sErr, ok := err.(*social.Error); ok { if sErr, ok := err.(*social.Error); ok {
redirectWithError(ctx, sErr) hs.redirectWithError(ctx, sErr)
} else { } else {
ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
} }
@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) {
// validate that we got at least an email address // validate that we got at least an email address
if userInfo.Email == "" { if userInfo.Email == "" {
redirectWithError(ctx, login.ErrNoEmail) hs.redirectWithError(ctx, login.ErrNoEmail)
return return
} }
// validate that the email is allowed to login to grafana // validate that the email is allowed to login to grafana
if !connect.IsEmailAllowed(userInfo.Email) { if !connect.IsEmailAllowed(userInfo.Email) {
redirectWithError(ctx, login.ErrEmailNotAllowed) hs.redirectWithError(ctx, login.ErrEmailNotAllowed)
return return
} }
@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) {
ExternalUser: extUser, ExternalUser: extUser,
SignupAllowed: connect.IsSignupAllowed(), SignupAllowed: connect.IsSignupAllowed(),
} }
err = bus.Dispatch(cmd) err = bus.Dispatch(cmd)
if err != nil { if err != nil {
redirectWithError(ctx, err) hs.redirectWithError(ctx, err)
return return
} }
// login // login
loginUserWithUser(cmd.Result, ctx) hs.loginUserWithUser(cmd.Result, ctx)
metrics.M_Api_Login_OAuth.Inc() metrics.M_Api_Login_OAuth.Inc()
@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) {
ctx.Redirect(setting.AppSubUrl + "/") ctx.Redirect(setting.AppSubUrl + "/")
} }
func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) { func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) {
hs.writeCookie(w, name, "", -1)
}
func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: name,
MaxAge: maxAge,
Value: value,
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.SecurityHTTPSCookies,
})
}
func hashStatecode(code, seed string) string {
hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed))
return hex.EncodeToString(hashBytes[:])
}
func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
ctx.Logger.Error(err.Error(), v...) ctx.Logger.Error(err.Error(), v...)
ctx.Session.Set("loginError", err.Error()) hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60)
ctx.Redirect(setting.AppSubUrl + "/login") ctx.Redirect(setting.AppSubUrl + "/login")
} }

View File

@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response {
}) })
} }
func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response { func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
if err := bus.Dispatch(&query); err != nil { if err := bus.Dispatch(&query); err != nil {
@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res
return rsp return rsp
} }
loginUserWithUser(user, c) hs.loginUserWithUser(user, c)
metrics.M_Api_User_SignUpCompleted.Inc() metrics.M_Api_User_SignUpCompleted.Inc()
metrics.M_Api_User_SignUpInvite.Inc() metrics.M_Api_User_SignUpInvite.Inc()

View File

@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response {
return JSON(200, util.DynMap{"status": "SignUpCreated"}) return JSON(200, util.DynMap{"status": "SignUpCreated"})
} }
func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
if !setting.AllowUserSignUp { if !setting.AllowUserSignUp {
return Error(401, "User signup is disabled", nil) return Error(401, "User signup is disabled", nil)
} }
@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
apiResponse["code"] = "redirect-to-select-org" apiResponse["code"] = "redirect-to-select-org"
} }
loginUserWithUser(user, c) hs.loginUserWithUser(user, c)
metrics.M_Api_User_SignUpCompleted.Inc() metrics.M_Api_User_SignUpCompleted.Inc()
return JSON(200, apiResponse) return JSON(200, apiResponse)

View File

@ -7,7 +7,6 @@ import (
"gopkg.in/macaron.v1" "gopkg.in/macaron.v1"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
@ -17,16 +16,6 @@ type AuthOptions struct {
ReqSignedIn bool ReqSignedIn bool
} }
func getRequestUserId(c *m.ReqContext) int64 {
userID := c.Session.Get(session.SESS_KEY_USERID)
if userID != nil {
return userID.(int64)
}
return 0
}
func getApiKey(c *m.ReqContext) string { func getApiKey(c *m.ReqContext) string {
header := c.Req.Header.Get("Authorization") header := c.Req.Header.Get("Authorization")
parts := strings.SplitN(header, " ", 2) parts := strings.SplitN(header, " ", 2)

View File

@ -16,7 +16,9 @@ import (
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue" var (
AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
)
func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
if !setting.AuthProxyEnabled { if !setting.AuthProxyEnabled {
@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
return false return false
} }
defer func() {
if err := ctx.Session.Release(); err != nil {
ctx.Logger.Error("failed to save session data", "error", err)
}
}()
query := &m.GetSignedInUserQuery{OrgId: orgID} query := &m.GetSignedInUserQuery{OrgId: orgID}
// if this session has already been authenticated by authProxy just load the user // if this session has already been authenticated by authProxy just load the user
@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error {
return nil return nil
} }
func getRequestUserId(c *m.ReqContext) int64 {
userID := c.Session.Get(session.SESS_KEY_USERID)
if userID != nil {
return userID.(int64)
}
return 0
}
func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error { func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 { if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
return nil return nil

View File

@ -3,15 +3,15 @@ package middleware
import ( import (
"strconv" "strconv"
"gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models" 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/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
macaron "gopkg.in/macaron.v1"
) )
var ( var (
@ -21,12 +21,12 @@ var (
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
) )
func GetContextHandler() macaron.Handler { func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
return func(c *macaron.Context) { return func(c *macaron.Context) {
ctx := &m.ReqContext{ ctx := &m.ReqContext{
Context: c, Context: c,
SignedInUser: &m.SignedInUser{}, SignedInUser: &m.SignedInUser{},
Session: session.GetSession(), Session: session.GetSession(), // should only be used by auth_proxy
IsSignedIn: false, IsSignedIn: false,
AllowAnonymous: false, AllowAnonymous: false,
SkipCache: false, SkipCache: false,
@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler {
case initContextWithApiKey(ctx): case initContextWithApiKey(ctx):
case initContextWithBasicAuth(ctx, orgId): case initContextWithBasicAuth(ctx, orgId):
case initContextWithAuthProxy(ctx, orgId): case initContextWithAuthProxy(ctx, orgId):
case initContextWithUserSessionCookie(ctx, orgId): case ats.InitContextWithToken(ctx, orgId):
case initContextWithAnonymousUser(ctx): case initContextWithAnonymousUser(ctx):
} }
@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
return true return true
} }
func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool {
// initialize session
if err := ctx.Session.Start(ctx.Context); err != nil {
ctx.Logger.Error("Failed to start session", "error", err)
return false
}
var userId int64
if userId = getRequestUserId(ctx); userId == 0 {
return false
}
query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId}
if err := bus.Dispatch(&query); err != nil {
ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err)
return false
}
ctx.SignedInUser = query.Result
ctx.IsSignedIn = true
return true
}
func initContextWithApiKey(ctx *m.ReqContext) bool { func initContextWithApiKey(ctx *m.ReqContext) bool {
var keyString string var keyString string
if keyString = getApiKey(ctx); keyString == "" { if keyString = getApiKey(ctx); keyString == "" {

View File

@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
ms "github.com/go-macaron/session" msession "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/services/session"
@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) {
So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty) So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
}) })
middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").exec()
So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
})
middlewareScenario("Invalid api key", func(sc *scenarioContext) { middlewareScenario("Invalid api key", func(sc *scenarioContext) {
sc.apiKey = "invalid_key_test" sc.apiKey = "invalid_key_test"
sc.fakeReq("GET", "/").exec() sc.fakeReq("GET", "/").exec()
@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) {
}) })
}) })
middlewareScenario("UserId in session", func(sc *scenarioContext) { middlewareScenario("Auth token service", func(sc *scenarioContext) {
var wasCalled bool
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
c.Session.Set(session.SESS_KEY_USERID, int64(12)) wasCalled = true
}).exec() return false
}
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
sc.fakeReq("GET", "/").exec() sc.fakeReq("GET", "/").exec()
Convey("should init context with user info", func() { Convey("should call middleware", func() {
So(sc.context.IsSignedIn, ShouldBeTrue) So(wasCalled, ShouldBeTrue)
So(sc.context.UserId, ShouldEqual, 12)
}) })
}) })
@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) {
return nil return nil
}) })
setting.SessionOptions = msession.Options{}
sc.fakeReq("GET", "/") sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.exec() sc.exec()
@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
defer bus.ClearBusHandlers() defer bus.ClearBusHandlers()
sc := &scenarioContext{} sc := &scenarioContext{}
viewsPath, _ := filepath.Abs("../../public/views") viewsPath, _ := filepath.Abs("../../public/views")
sc.m = macaron.New() sc.m = macaron.New()
@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"}, Delims: macaron.Delims{Left: "[[", Right: "]]"},
})) }))
sc.m.Use(GetContextHandler()) session.Init(&msession.Options{}, 0)
sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine // mock out gc goroutine
session.StartSessionGC = func() {} session.StartSessionGC = func() {}
sc.m.Use(Sessioner(&ms.Options{}, 0)) setting.SessionOptions = msession.Options{}
sc.m.Use(OrgRedirect()) sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders()) sc.m.Use(AddDefaultResponseHeaders())
@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) {
} }
type scenarioContext struct { type scenarioContext struct {
m *macaron.Macaron m *macaron.Macaron
context *m.ReqContext context *m.ReqContext
resp *httptest.ResponseRecorder resp *httptest.ResponseRecorder
apiKey string apiKey string
authHeader string authHeader string
respJson map[string]interface{} respJson map[string]interface{}
handlerFunc handlerFunc handlerFunc handlerFunc
defaultHandler macaron.Handler defaultHandler macaron.Handler
url string url string
userAuthTokenService *fakeUserAuthTokenService
req *http.Request req *http.Request
} }
@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() {
type scenarioFunc func(c *scenarioContext) type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext) type handlerFunc func(c *m.ReqContext)
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) UserSignedOutHook(c *m.ReqContext) {}

View File

@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"gopkg.in/macaron.v1" "gopkg.in/macaron.v1"
) )

View File

@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) {
Convey("Can redirect to correct org", t, func() { Convey("Can redirect to correct org", t, func() {
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) { middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
c.Session.Set(session.SESS_KEY_USERID, int64(12))
}).exec()
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return nil return nil
}) })
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil ctx.IsSignedIn = true
}) return true
}
sc.m.Get("/", sc.defaultHandler) sc.m.Get("/", sc.defaultHandler)
sc.fakeReq("GET", "/?orgId=3").exec() sc.fakeReq("GET", "/?orgId=3").exec()
@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) {
}) })
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) { middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
c.Session.Set(session.SESS_KEY_USERID, int64(12))
}).exec()
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return fmt.Errorf("") 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 { bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil return nil

View File

@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) {
}) })
middlewareScenario("with user logged in", func(sc *scenarioContext) { middlewareScenario("with user logged in", func(sc *scenarioContext) {
// log us in, so we have a user_id and org_id in the context sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
c.Session.Set(session.SESS_KEY_USERID, int64(12)) ctx.IsSignedIn = true
}).exec() return true
}
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error { bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
query.Result = &m.GlobalQuotaDTO{ query.Result = &m.GlobalQuotaDTO{
Target: query.Target, Target: query.Target,

View File

@ -4,13 +4,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
ms "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
"gopkg.in/macaron.v1" macaron "gopkg.in/macaron.v1"
) )
func TestRecoveryMiddleware(t *testing.T) { func TestRecoveryMiddleware(t *testing.T) {
@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"}, Delims: macaron.Delims{Left: "[[", Right: "]]"},
})) }))
sc.m.Use(GetContextHandler()) sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine // mock out gc goroutine
session.StartSessionGC = func() {} session.StartSessionGC = func() {}
sc.m.Use(Sessioner(&ms.Options{}, 0))
sc.m.Use(OrgRedirect()) sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders()) sc.m.Use(AddDefaultResponseHeaders())

View File

@ -1,21 +0,0 @@
package middleware
import (
ms "github.com/go-macaron/session"
"gopkg.in/macaron.v1"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
)
func Sessioner(options *ms.Options, sessionConnMaxLifetime int64) macaron.Handler {
session.Init(options, sessionConnMaxLifetime)
return func(ctx *m.ReqContext) {
ctx.Next()
if err := ctx.Session.Release(); err != nil {
panic("session(release): " + err.Error())
}
}
}

View File

@ -3,18 +3,18 @@ package models
import ( import (
"strings" "strings"
"github.com/prometheus/client_golang/prometheus"
"gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
"gopkg.in/macaron.v1"
) )
type ReqContext struct { type ReqContext struct {
*macaron.Context *macaron.Context
*SignedInUser *SignedInUser
// This should only be used by the auth_proxy
Session session.SessionStore Session session.SessionStore
IsSignedIn bool IsSignedIn bool

View File

@ -105,8 +105,9 @@ func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
var ( var (
unfinishedWorkTimeout = time.Second * 5 unfinishedWorkTimeout = time.Second * 5
// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file. // TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
alertTimeout = time.Second * 30 alertTimeout = time.Second * 30
alertMaxAttempts = 3 resultHandleTimeout = time.Second * 30
alertMaxAttempts = 3
) )
func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error { func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
@ -116,7 +117,7 @@ func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *J
} }
}() }()
cancelChan := make(chan context.CancelFunc, alertMaxAttempts) cancelChan := make(chan context.CancelFunc, alertMaxAttempts*2)
attemptChan := make(chan int, 1) attemptChan := make(chan int, 1)
// Initialize with first attemptID=1 // Initialize with first attemptID=1
@ -204,6 +205,15 @@ func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancel
} }
} }
// create new context with timeout for notifications
resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), resultHandleTimeout)
cancelChan <- resultHandleCancelFn
// override the context used for evaluation with a new context for notifications.
// This makes it possible for notifiers to execute when datasources
// dont respond within the timeout limit. We should rewrite this so notifications
// dont reuse the evalContext and get its own context.
evalContext.Ctx = resultHandleCtx
evalContext.Rule.State = evalContext.GetNewState() evalContext.Rule.State = evalContext.GetNewState()
e.resultHandler.Handle(evalContext) e.resultHandler.Handle(evalContext)
span.Finish() span.Finish()

View File

@ -0,0 +1,148 @@
// +build integration
package alerting
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
. "github.com/smartystreets/goconvey/convey"
)
func TestEngineTimeouts(t *testing.T) {
Convey("Alerting engine timeout tests", t, func() {
engine := NewEngine()
engine.resultHandler = &FakeResultHandler{}
job := &Job{Running: true, Rule: &Rule{}}
Convey("Should trigger as many retries as needed", func() {
Convey("pended alert for datasource -> result handler should be worked", func() {
// reduce alert timeout to test quickly
originAlertTimeout := alertTimeout
alertTimeout = 2 * time.Second
transportTimeoutInterval := 2 * time.Second
serverBusySleepDuration := 1 * time.Second
evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
engine.evalHandler = evalHandler
engine.resultHandler = resultHandler
engine.processJobWithRetry(context.TODO(), job)
So(evalHandler.EvalSucceed, ShouldEqual, true)
So(resultHandler.ResultHandleSucceed, ShouldEqual, true)
// initialize for other tests.
alertTimeout = originAlertTimeout
engine.resultHandler = &FakeResultHandler{}
})
})
})
}
type FakeCommonTimeoutHandler struct {
TransportTimeoutDuration time.Duration
ServerBusySleepDuration time.Duration
EvalSucceed bool
ResultHandleSucceed bool
}
func NewFakeCommonTimeoutHandler(transportTimeoutDuration time.Duration, serverBusySleepDuration time.Duration) *FakeCommonTimeoutHandler {
return &FakeCommonTimeoutHandler{
TransportTimeoutDuration: transportTimeoutDuration,
ServerBusySleepDuration: serverBusySleepDuration,
EvalSucceed: false,
ResultHandleSucceed: false,
}
}
func (handler *FakeCommonTimeoutHandler) Eval(evalContext *EvalContext) {
// 1. prepare mock server
path := "/evaltimeout"
srv := runBusyServer(path, handler.ServerBusySleepDuration)
defer srv.Close()
// 2. send requests
url := srv.URL + path
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
if res != nil {
defer res.Body.Close()
}
if err != nil {
evalContext.Error = errors.New("Fake evaluation timeout test failure")
return
}
if res.StatusCode == 200 {
handler.EvalSucceed = true
}
evalContext.Error = errors.New("Fake evaluation timeout test failure; wrong response")
}
func (handler *FakeCommonTimeoutHandler) Handle(evalContext *EvalContext) error {
// 1. prepare mock server
path := "/resulthandle"
srv := runBusyServer(path, handler.ServerBusySleepDuration)
defer srv.Close()
// 2. send requests
url := srv.URL + path
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
if res != nil {
defer res.Body.Close()
}
if err != nil {
evalContext.Error = errors.New("Fake result handle timeout test failure")
return evalContext.Error
}
if res.StatusCode == 200 {
handler.ResultHandleSucceed = true
return nil
}
evalContext.Error = errors.New("Fake result handle timeout test failure; wrong response")
return evalContext.Error
}
func runBusyServer(path string, serverBusySleepDuration time.Duration) *httptest.Server {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
time.Sleep(serverBusySleepDuration)
})
return server
}
func sendRequest(context context.Context, url string, transportTimeoutInterval time.Duration) (resp *http.Response, err error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(context)
transport := http.Transport{
Dial: (&net.Dialer{
Timeout: transportTimeoutInterval,
KeepAlive: transportTimeoutInterval,
}).Dial,
}
client := http.Client{
Transport: &transport,
}
return client.Do(req)
}

View File

@ -0,0 +1,266 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"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"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
func init() {
registry.RegisterService(&UserAuthTokenServiceImpl{})
}
var (
getTime = time.Now
UrgentRotateTime = 1 * time.Minute
oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
)
// 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
UserSignedOutHook(c *models.ReqContext)
}
type UserAuthTokenServiceImpl struct {
SQLStore *sqlstore.SqlStore `inject:""`
ServerLockService *serverlock.ServerLockService `inject:""`
Cfg *setting.Cfg `inject:""`
log log.Logger
}
// Init this service
func (s *UserAuthTokenServiceImpl) 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.Info("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,
}
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) UserSignedOutHook(c *models.ReqContext) {
s.writeSessionCookie(c, "", -1)
}
func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
clientIP = util.ParseIPAddress(clientIP)
token, err := util.RandomHex(16)
if err != nil {
return nil, err
}
hashedToken := hashToken(token)
now := getTime().Unix()
userToken := userAuthToken{
UserId: userId,
AuthToken: hashedToken,
PrevAuthToken: hashedToken,
ClientIp: clientIP,
UserAgent: userAgent,
RotatedAt: now,
CreatedAt: now,
UpdatedAt: now,
SeenAt: 0,
AuthTokenSeen: false,
}
_, err = s.SQLStore.NewSession().Insert(&userToken)
if err != nil {
return nil, err
}
userToken.UnhashedToken = token
return &userToken, nil
}
func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
hashedToken := hashToken(unhashedToken)
if setting.Env == setting.DEV {
s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
}
expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
var userToken userAuthToken
exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
if err != nil {
return nil, err
}
if !exists {
return nil, ErrAuthTokenNotFound
}
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 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)
} else {
s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
}
}
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 err != nil {
return nil, err
}
if affectedRows == 1 {
userToken = userTokenCopy
}
if affectedRows == 0 {
s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
} else {
s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
}
}
userToken.UnhashedToken = unhashedToken
return &userToken, nil
}
func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) {
if token == nil {
return false, nil
}
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))
} else {
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)
clientIP = util.ParseIPAddress(clientIP)
newToken, _ := util.RandomHex(16)
hashedToken := hashToken(newToken)
// very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
sql := `
UPDATE user_auth_token
SET
seen_at = 0,
user_agent = ?,
client_ip = ?,
prev_auth_token = case when auth_token_seen = ? then auth_token else prev_auth_token end,
auth_token = ?,
auth_token_seen = ?,
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())
if err != nil {
return false, err
}
affected, _ := res.RowsAffected()
s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId)
if affected > 0 {
token.UnhashedToken = newToken
return true, nil
}
return false, nil
}
func hashToken(token string) string {
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
return hex.EncodeToString(hashBytes[:])
}

View File

@ -0,0 +1,339 @@
package auth
import (
"testing"
"time"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/services/sqlstore"
. "github.com/smartystreets/goconvey/convey"
)
func TestUserAuthToken(t *testing.T) {
Convey("Test user auth token", t, func() {
ctx := createTestContext(t)
userAuthTokenService := ctx.tokenService
userID := int64(10)
t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC)
getTime = func() time.Time {
return t
}
Convey("When creating token", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
So(token.AuthTokenSeen, ShouldBeFalse)
Convey("When lookup unhashed token should return user auth token", func() {
LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldBeNil)
So(LookupToken, ShouldNotBeNil)
So(LookupToken.UserId, ShouldEqual, userID)
So(LookupToken.AuthTokenSeen, ShouldBeTrue)
storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.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)
})
})
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)
So(err, ShouldBeNil)
token, err = ctx.getAuthTokenByID(token.Id)
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")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldBeNil)
stillGood, err := userAuthTokenService.LookupToken(token.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)
})
Convey("can properly rotate tokens", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
prevToken := token.AuthToken
unhashedPrev := token.UnhashedToken
refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
So(refreshed, ShouldBeFalse)
updated, err := ctx.markAuthTokenAsSeen(token.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
token, err = ctx.getAuthTokenByID(token.Id)
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")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
unhashedToken := token.UnhashedToken
token, err = ctx.getAuthTokenByID(token.Id)
So(err, ShouldBeNil)
token.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)
// ability to auth using an old token
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.Id, ShouldEqual, token.Id)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(time.Hour + (2 * time.Minute))
}
lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeFalse)
refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
token, err = ctx.getAuthTokenByID(token.Id)
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
So(token.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")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
getTime = func() time.Time {
return t.Add(10 * time.Minute)
}
prevToken := token.UnhashedToken
refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(20 * time.Minute)
}
current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldBeNil)
So(current, ShouldNotBeNil)
prev, err := userAuthTokenService.LookupToken(prevToken)
So(err, ShouldBeNil)
So(prev, 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")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
So(err, ShouldBeNil)
So(lookedUp, ShouldNotBeNil)
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
})
Convey("Rotate token", func() {
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
So(err, ShouldBeNil)
So(token, ShouldNotBeNil)
prevToken := token.AuthToken
Convey("Should rotate current token and previous token when auth token seen", func() {
updated, err := ctx.markAuthTokenAsSeen(token.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(10 * time.Minute)
}
refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
storedToken, err := ctx.getAuthTokenByID(token.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
So(storedToken.AuthToken, ShouldNotEqual, prevToken)
prevToken = storedToken.AuthToken
updated, err = ctx.markAuthTokenAsSeen(token.Id)
So(err, ShouldBeNil)
So(updated, ShouldBeTrue)
getTime = func() time.Time {
return t.Add(20 * time.Minute)
}
refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
storedToken, err = ctx.getAuthTokenByID(token.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
So(storedToken.AuthToken, ShouldNotEqual, prevToken)
})
Convey("Should rotate current token, but keep previous token when auth token not seen", func() {
token.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")
So(err, ShouldBeNil)
So(refreshed, ShouldBeTrue)
storedToken, err := ctx.getAuthTokenByID(token.Id)
So(err, ShouldBeNil)
So(storedToken, ShouldNotBeNil)
So(storedToken.AuthTokenSeen, ShouldBeFalse)
So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
So(storedToken.AuthToken, ShouldNotEqual, prevToken)
})
})
Reset(func() {
getTime = time.Now
})
})
}
func createTestContext(t *testing.T) *testContext {
t.Helper()
sqlstore := sqlstore.InitTestDB(t)
tokenService := &UserAuthTokenServiceImpl{
SQLStore: sqlstore,
Cfg: &setting.Cfg{
LoginCookieName: "grafana_session",
LoginCookieMaxDays: 7,
LoginDeleteExpiredTokensAfterDays: 30,
LoginCookieRotation: 10,
},
log: log.New("test-logger"),
}
UrgentRotateTime = time.Minute
return &testContext{
sqlstore: sqlstore,
tokenService: tokenService,
}
}
type testContext struct {
sqlstore *sqlstore.SqlStore
tokenService *UserAuthTokenServiceImpl
}
func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
sess := c.sqlstore.NewSession()
var t userAuthToken
found, err := sess.ID(id).Get(&t)
if err != nil || !found {
return nil, err
}
return &t, nil
}
func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
sess := c.sqlstore.NewSession()
res, err := sess.Exec("UPDATE user_auth_token SET auth_token_seen = ? WHERE id = ?", c.sqlstore.Dialect.BooleanStr(true), id)
if err != nil {
return false, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return false, err
}
return rowsAffected == 1, nil
}

View File

@ -0,0 +1,25 @@
package auth
import (
"errors"
)
// Typed errors
var (
ErrAuthTokenNotFound = errors.New("User auth token not found")
)
type userAuthToken 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 `xorm:"-"`
}

View File

@ -0,0 +1,38 @@
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

@ -0,0 +1,36 @@
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

@ -164,11 +164,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand,
User: dto.User, User: dto.User,
} }
if err := bus.Dispatch(&alertCmd); err != nil { return bus.Dispatch(&alertCmd)
return err
}
return nil
} }
func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {

View File

@ -14,8 +14,6 @@ import (
const ( const (
SESS_KEY_USERID = "uid" SESS_KEY_USERID = "uid"
SESS_KEY_OAUTH_STATE = "state"
SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys
SESS_KEY_LASTLDAPSYNC = "last_ldap_sync" SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
) )

View File

@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) {
addLoginAttemptMigrations(mg) addLoginAttemptMigrations(mg)
addUserAuthMigrations(mg) addUserAuthMigrations(mg)
addServerlockMigrations(mg) addServerlockMigrations(mg)
addUserAuthTokenMigrations(mg)
} }
func addMigrationLogMigrations(mg *Migrator) { func addMigrationLogMigrations(mg *Migrator) {

View File

@ -0,0 +1,32 @@
package migrations
import (
. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
func addUserAuthTokenMigrations(mg *Migrator) {
userAuthTokenV1 := Table{
Name: "user_auth_token",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "user_id", Type: DB_BigInt, Nullable: false},
{Name: "auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
{Name: "prev_auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
{Name: "user_agent", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "client_ip", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "auth_token_seen", Type: DB_Bool, Nullable: false},
{Name: "seen_at", Type: DB_Int, Nullable: true},
{Name: "rotated_at", Type: DB_Int, Nullable: false},
{Name: "created_at", Type: DB_Int, Nullable: false},
{Name: "updated_at", Type: DB_Int, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"auth_token"}, Type: UniqueIndex},
{Cols: []string{"prev_auth_token"}, Type: UniqueIndex},
},
}
mg.AddMigration("create user auth token table", NewAddTableMigration(userAuthTokenV1))
mg.AddMigration("add unique index user_auth_token.auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[0]))
mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1]))
}

View File

@ -18,7 +18,7 @@ import (
"github.com/go-macaron/session" "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
"gopkg.in/ini.v1" ini "gopkg.in/ini.v1"
) )
type Scheme string type Scheme string
@ -83,9 +83,6 @@ var (
// Security settings. // Security settings.
SecretKey string SecretKey string
LogInRememberDays int
CookieUserName string
CookieRememberName string
DisableGravatar bool DisableGravatar bool
EmailCodeValidMinutes int EmailCodeValidMinutes int
DataProxyWhiteList map[string]bool DataProxyWhiteList map[string]bool
@ -222,7 +219,15 @@ type Cfg struct {
MetricsEndpointBasicAuthUsername string MetricsEndpointBasicAuthUsername string
MetricsEndpointBasicAuthPassword string MetricsEndpointBasicAuthPassword string
EnableAlphaPanels bool EnableAlphaPanels bool
DisableSanitizeHtml bool
EnterpriseLicensePath string EnterpriseLicensePath string
LoginCookieName string
LoginCookieMaxDays int
LoginCookieRotation int
LoginDeleteExpiredTokensAfterDays int
SecurityHTTPSCookies bool
} }
type CommandLineArgs struct { type CommandLineArgs struct {
@ -546,6 +551,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
ApplicationName = APP_NAME_ENTERPRISE 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)
cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
if cfg.LoginCookieRotation < 2 {
cfg.LoginCookieRotation = 2
}
Env = iniFile.Section("").Key("app_mode").MustString("development") Env = iniFile.Section("").Key("app_mode").MustString("development")
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name") InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath) PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
@ -586,11 +601,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// read security settings // read security settings
security := iniFile.Section("security") security := iniFile.Section("security")
SecretKey = security.Key("secret_key").String() SecretKey = security.Key("secret_key").String()
LogInRememberDays = security.Key("login_remember_days").MustInt()
CookieUserName = security.Key("cookie_username").String()
CookieRememberName = security.Key("cookie_remember_name").String()
DisableGravatar = security.Key("disable_gravatar").MustBool(true) DisableGravatar = security.Key("disable_gravatar").MustBool(true)
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
// read snapshots settings // read snapshots settings
@ -705,10 +718,11 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
AlertingNoDataOrNullValues = alerting.Key("nodata_or_nullvalues").MustString("no_data") AlertingNoDataOrNullValues = alerting.Key("nodata_or_nullvalues").MustString("no_data")
explore := iniFile.Section("explore") explore := iniFile.Section("explore")
ExploreEnabled = explore.Key("enabled").MustBool(false) ExploreEnabled = explore.Key("enabled").MustBool(true)
panels := iniFile.Section("panels") panels := iniFile.Section("panels")
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false) cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
cfg.DisableSanitizeHtml = panels.Key("disable_sanitize_html").MustBool(false)
cfg.readSessionConfig() cfg.readSessionConfig()
cfg.readSmtpSettings() cfg.readSmtpSettings()

View File

@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
return userAndPass[0], userAndPass[1], nil return userAndPass[0], userAndPass[1], nil
} }
func RandomHex(n int) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

29
pkg/util/ip_address.go Normal file
View File

@ -0,0 +1,29 @@
package util
import (
"net"
"strings"
)
// ParseIPAddress parses an IP address and removes port and/or IPV6 format
func ParseIPAddress(input string) string {
s := input
lastIndex := strings.LastIndex(input, ":")
if lastIndex != -1 {
if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" {
s = input[:lastIndex]
}
}
s = strings.Replace(s, "[", "", -1)
s = strings.Replace(s, "]", "", -1)
ip := net.ParseIP(s)
if ip.IsLoopback() {
return "127.0.0.1"
}
return ip.String()
}

View File

@ -0,0 +1,16 @@
package util
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestParseIPAddress(t *testing.T) {
Convey("Test parse ip address", t, func() {
So(ParseIPAddress("192.168.0.140:456"), ShouldEqual, "192.168.0.140")
So(ParseIPAddress("[::1:456]"), ShouldEqual, "127.0.0.1")
So(ParseIPAddress("[::1]"), ShouldEqual, "127.0.0.1")
So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140")
})
}

View File

@ -1,13 +1,17 @@
import { LocationUpdate } from 'app/types'; import { LocationUpdate } from 'app/types';
export enum CoreActionTypes {
UpdateLocation = 'UPDATE_LOCATION',
}
export type Action = UpdateLocationAction; export type Action = UpdateLocationAction;
export interface UpdateLocationAction { export interface UpdateLocationAction {
type: 'UPDATE_LOCATION'; type: CoreActionTypes.UpdateLocation;
payload: LocationUpdate; payload: LocationUpdate;
} }
export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({ export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
type: 'UPDATE_LOCATION', type: CoreActionTypes.UpdateLocation,
payload: location, payload: location,
}); });

View File

@ -10,7 +10,9 @@ const SideMenuDropDown: FC<Props> = props => {
return ( return (
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu"> <ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
<li className="side-menu-header"> <li className="side-menu-header">
<span className="sidemenu-item-text">{link.text}</span> <a className="side-menu-header-link" href={link.url}>
<span className="sidemenu-item-text">{link.text}</span>
</a>
</li> </li>
{link.children && {link.children &&
link.children.map((child, index) => { link.children.map((child, index) => {

View File

@ -8,11 +8,15 @@ exports[`Render should render children 1`] = `
<li <li
className="side-menu-header" className="side-menu-header"
> >
<span <a
className="sidemenu-item-text" className="side-menu-header-link"
> >
link <span
</span> className="sidemenu-item-text"
>
link
</span>
</a>
</li> </li>
<DropDownChild <DropDownChild
child={ child={
@ -49,11 +53,15 @@ exports[`Render should render component 1`] = `
<li <li
className="side-menu-header" className="side-menu-header"
> >
<span <a
className="sidemenu-item-text" className="side-menu-header-link"
> >
link <span
</span> className="sidemenu-item-text"
>
link
</span>
</a>
</li> </li>
</ul> </ul>
`; `;

View File

@ -35,8 +35,9 @@ export class Settings {
loginHint: any; loginHint: any;
loginError: any; loginError: any;
viewersCanEdit: boolean; viewersCanEdit: boolean;
disableSanitizeHtml: boolean;
constructor(options) { constructor(options: Settings) {
const defaults = { const defaults = {
datasources: {}, datasources: {},
windowTitlePrefix: 'Grafana - ', windowTitlePrefix: 'Grafana - ',
@ -52,6 +53,7 @@ export class Settings {
isEnterprise: false, isEnterprise: false,
}, },
viewersCanEdit: false, viewersCanEdit: false,
disableSanitizeHtml: false
}; };
_.extend(this, defaults, options); _.extend(this, defaults, options);

View File

@ -1,4 +1,3 @@
import './inspect_ctrl';
import './json_editor_ctrl'; import './json_editor_ctrl';
import './login_ctrl'; import './login_ctrl';
import './invited_ctrl'; import './invited_ctrl';

View File

@ -1,71 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from '../core_module';
export class InspectCtrl {
/** @ngInject */
constructor($scope, $sanitize) {
const model = $scope.inspector;
$scope.init = function() {
$scope.editor = { index: 0 };
if (!model.error) {
return;
}
if (_.isString(model.error.data)) {
$scope.response = $('<div>' + model.error.data + '</div>').text();
} else if (model.error.data) {
if (model.error.data.response) {
$scope.response = $sanitize(model.error.data.response);
} else {
$scope.response = angular.toJson(model.error.data, true);
}
} else if (model.error.message) {
$scope.message = model.error.message;
}
if (model.error.config && model.error.config.params) {
$scope.request_parameters = _.map(model.error.config.params, (value, key) => {
return { key: key, value: value };
});
}
if (model.error.stack) {
$scope.editor.index = 3;
$scope.stack_trace = model.error.stack;
$scope.message = model.error.message;
}
if (model.error.config && model.error.config.data) {
$scope.editor.index = 2;
if (_.isString(model.error.config.data)) {
$scope.request_parameters = this.getParametersFromQueryString(model.error.config.data);
} else {
$scope.request_parameters = _.map(model.error.config.data, (value, key) => {
return { key: key, value: angular.toJson(value, true) };
});
}
}
};
}
getParametersFromQueryString(queryString) {
const result = [];
const parameters = queryString.split('&');
for (let i = 0; i < parameters.length; i++) {
const keyValue = parameters[i].split('=');
if (keyValue[1].length > 0) {
result.push({
key: keyValue[0],
value: (window as any).unescape(keyValue[1]),
});
}
}
return result;
}
}
coreModule.controller('InspectCtrl', InspectCtrl);

View File

@ -42,7 +42,7 @@ export interface LogSearchMatch {
text: string; text: string;
} }
export interface LogRow { export interface LogRowModel {
duplicates?: number; duplicates?: number;
entry: string; entry: string;
key: string; // timestamp + labels key: string; // timestamp + labels
@ -56,7 +56,7 @@ export interface LogRow {
uniqueLabels?: LogsStreamLabels; uniqueLabels?: LogsStreamLabels;
} }
export interface LogsLabelStat { export interface LogLabelStatsModel {
active?: boolean; active?: boolean;
count: number; count: number;
proportion: number; proportion: number;
@ -78,7 +78,7 @@ export interface LogsMetaItem {
export interface LogsModel { export interface LogsModel {
id: string; // Identify one logs result from another id: string; // Identify one logs result from another
meta?: LogsMetaItem[]; meta?: LogsMetaItem[];
rows: LogRow[]; rows: LogRowModel[];
series?: TimeSeries[]; series?: TimeSeries[];
} }
@ -188,13 +188,13 @@ export const LogsParsers: { [name: string]: LogsParser } = {
}, },
}; };
export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] { export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] {
// Consider only rows that satisfy the matcher // Consider only rows that satisfy the matcher
const rowsWithField = rows.filter(row => extractor.test(row.entry)); const rowsWithField = rows.filter(row => extractor.test(row.entry));
const rowCount = rowsWithField.length; const rowCount = rowsWithField.length;
// Get field value counts for eligible rows // Get field value counts for eligible rows
const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]); const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]);
const sortedCounts = _.chain(countsByValue) const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount })) .map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count') .sortBy('count')
@ -204,13 +204,13 @@ export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabe
return sortedCounts; return sortedCounts;
} }
export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] { export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
// Consider only rows that have the given label // Consider only rows that have the given label
const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
const rowCount = rowsWithLabel.length; const rowCount = rowsWithLabel.length;
// Get label value counts for eligible rows // Get label value counts for eligible rows
const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]); const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]);
const sortedCounts = _.chain(countsByValue) const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount })) .map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count') .sortBy('count')
@ -221,7 +221,7 @@ export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabe
} }
const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g; const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean { function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean {
switch (strategy) { switch (strategy) {
case LogsDedupStrategy.exact: case LogsDedupStrategy.exact:
// Exact still strips dates // Exact still strips dates
@ -243,7 +243,7 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
return logs; return logs;
} }
const dedupedRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => { const dedupedRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
const previous = result[result.length - 1]; const previous = result[result.length - 1];
if (index > 0 && isDuplicateRow(row, previous, strategy)) { if (index > 0 && isDuplicateRow(row, previous, strategy)) {
previous.duplicates++; previous.duplicates++;
@ -278,7 +278,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>)
return logs; return logs;
} }
const filteredRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => { const filteredRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
if (!hiddenLogLevels.has(row.logLevel)) { if (!hiddenLogLevels.has(row.logLevel)) {
result.push(row); result.push(row);
} }
@ -291,7 +291,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>)
}; };
} }
export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] { export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): TimeSeries[] {
// currently interval is rangeMs / resolution, which is too low for showing series as bars. // currently interval is rangeMs / resolution, which is too low for showing series as bars.
// need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain // need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain
// when executing queries & interval calculated and not here but this is a temporary fix. // when executing queries & interval calculated and not here but this is a temporary fix.

View File

@ -1,4 +1,4 @@
import { Action } from 'app/core/actions/location'; import { Action, CoreActionTypes } from 'app/core/actions/location';
import { LocationState } from 'app/types'; import { LocationState } from 'app/types';
import { renderUrl } from 'app/core/utils/url'; import { renderUrl } from 'app/core/utils/url';
import _ from 'lodash'; import _ from 'lodash';
@ -12,7 +12,7 @@ export const initialState: LocationState = {
export const locationReducer = (state = initialState, action: Action): LocationState => { export const locationReducer = (state = initialState, action: Action): LocationState => {
switch (action.type) { switch (action.type) {
case 'UPDATE_LOCATION': { case CoreActionTypes.UpdateLocation: {
const { path, routeParams } = action.payload; const { path, routeParams } = action.payload;
let query = action.payload.query || state.query; let query = action.payload.query || state.query;
@ -24,9 +24,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
return { return {
url: renderUrl(path || state.path, query), url: renderUrl(path || state.path, query),
path: path || state.path, path: path || state.path,
query: { query: { ...query },
...query,
},
routeParams: routeParams || state.routeParams, routeParams: routeParams || state.routeParams,
}; };
} }

View File

@ -236,7 +236,7 @@ export class KeybindingSrv {
shareScope.dashboard = dashboard; shareScope.dashboard = dashboard;
appEvents.emit('show-modal', { appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/partials/shareModal.html', src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: shareScope, scope: shareScope,
}); });
} }

View File

@ -14,3 +14,12 @@ describe('toUrlParams', () => {
expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined='); expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined=');
}); });
}); });
describe('toUrlParams', () => {
it('should encode the same way as angularjs', () => {
const url = toUrlParams({
server: ':@',
});
expect(url).toBe('server=:@');
});
});

View File

@ -84,7 +84,7 @@ export async function getExploreUrl(
} }
const exploreState = JSON.stringify(state); const exploreState = JSON.stringify(state);
url = renderUrl('/explore', { state: exploreState }); url = renderUrl('/explore', { left: exploreState });
} }
return url; return url;
} }

View File

@ -1,4 +1,5 @@
import { TextMatch } from 'app/types/explore'; import { TextMatch } from 'app/types/explore';
import xss from 'xss';
/** /**
* Adapt findMatchesInText for react-highlight-words findChunks handler. * Adapt findMatchesInText for react-highlight-words findChunks handler.
@ -22,7 +23,7 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
} }
const matches = []; const matches = [];
const cleaned = cleanNeedle(needle); const cleaned = cleanNeedle(needle);
let regexp; let regexp: RegExp;
try { try {
regexp = new RegExp(`(?:${cleaned})`, 'g'); regexp = new RegExp(`(?:${cleaned})`, 'g');
} catch (error) { } catch (error) {
@ -42,3 +43,28 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
}); });
return matches; return matches;
} }
const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => {
acc[element] = xss.whiteList[element].concat(['class', 'style']);
return acc;
}, {});
const sanitizeXSS = new xss.FilterXSS({
whiteList: XSSWL
});
/**
* Returns string safe from XSS attacks.
*
* Even though we allow the style-attribute, there's still default filtering applied to it
* Info: https://github.com/leizongmin/js-xss#customize-css-filter
* Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js
*/
export function sanitize (unsanitizedString: string): string {
try {
return sanitizeXSS.process(unsanitizedString);
} catch (error) {
console.log('String could not be sanitized', unsanitizedString);
return unsanitizedString;
}
}

View File

@ -11,6 +11,16 @@ export function renderUrl(path: string, query: UrlQueryMap | undefined): string
return path; return path;
} }
export function encodeURIComponentAsAngularJS(val, pctEncodeSpaces) {
return encodeURIComponent(val).
replace(/%40/gi, '@').
replace(/%3A/gi, ':').
replace(/%24/g, '$').
replace(/%2C/gi, ',').
replace(/%3B/gi, ';').
replace(/%20/g, (pctEncodeSpaces ? '%20' : '+'));
}
export function toUrlParams(a) { export function toUrlParams(a) {
const s = []; const s = [];
const rbracket = /\[\]$/; const rbracket = /\[\]$/;
@ -22,9 +32,9 @@ export function toUrlParams(a) {
const add = (k, v) => { const add = (k, v) => {
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v; v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
if (typeof v !== 'boolean') { if (typeof v !== 'boolean') {
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v); s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true);
} else { } else {
s[s.length] = encodeURIComponent(k); s[s.length] = encodeURIComponentAsAngularJS(k, true);
} }
}; };

View File

@ -1,7 +1,7 @@
import './annotations/all'; import './annotations/all';
import './templating/all'; import './templating/all';
import './plugins/all'; import './plugins/all';
import './dashboard/all'; import './dashboard';
import './playlist/all'; import './playlist/all';
import './panel/all'; import './panel/all';
import './org/all'; import './org/all';

View File

@ -1,13 +0,0 @@
import coreModule from 'app/core/core_module';
export class AlertingSrv {
dashboard: any;
alerts: any[];
init(dashboard, alerts) {
this.dashboard = dashboard;
this.alerts = alerts || [];
}
}
coreModule.service('alertingSrv', AlertingSrv);

View File

@ -1,45 +0,0 @@
import './dashboard_ctrl';
import './alerting_srv';
import './history/history';
import './dashboard_loader_srv';
import './dashnav/dashnav';
import './submenu/submenu';
import './save_as_modal';
import './save_modal';
import './save_provisioned_modal';
import './shareModalCtrl';
import './share_snapshot_ctrl';
import './dashboard_srv';
import './view_state_srv';
import './validation_srv';
import './time_srv';
import './unsaved_changes_srv';
import './unsaved_changes_modal';
import './timepicker/timepicker';
import './upload';
import './export/export_modal';
import './export_data/export_data_modal';
import './ad_hoc_filters';
import './repeat_option/repeat_option';
import './dashgrid/DashboardGridDirective';
import './dashgrid/RowOptions';
import './folder_picker/folder_picker';
import './move_to_folder_modal/move_to_folder';
import './settings/settings';
import './panellinks/module';
import './dashlinks/module';
// angular wrappers
import { react2AngularDirective } from 'app/core/utils/react2angular';
import DashboardPermissions from './permissions/DashboardPermissions';
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);
import coreModule from 'app/core/core_module';
import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
import { DashboardImportCtrl } from './dashboard_import_ctrl';
import { CreateFolderCtrl } from './create_folder_ctrl';
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

View File

@ -0,0 +1 @@
export { AdHocFiltersCtrl } from './AdHocFiltersCtrl';

View File

@ -1,23 +1,23 @@
import React from 'react'; import React from 'react';
import _ from 'lodash'; import _ from 'lodash';
import config from 'app/core/config'; import config from 'app/core/config';
import { PanelModel } from '../panel_model'; import { PanelModel } from '../../panel_model';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
import store from 'app/core/store'; import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { updateLocation } from 'app/core/actions'; import { updateLocation } from 'app/core/actions';
import { store as reduxStore } from 'app/store/store'; import { store as reduxStore } from 'app/store/store';
export interface AddPanelPanelProps { export interface Props {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
} }
export interface AddPanelPanelState { export interface State {
copiedPanelPlugins: any[]; copiedPanelPlugins: any[];
} }
export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelPanelState> { export class AddPanelWidget extends React.Component<Props, State> {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this); this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
@ -133,15 +133,15 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
} }
return ( return (
<div className="panel-container add-panel-container"> <div className="panel-container add-panel-widget-container">
<div className="add-panel"> <div className="add-panel-widget">
<div className="add-panel__header grid-drag-handle"> <div className="add-panel-widget__header grid-drag-handle">
<i className="gicon gicon-add-panel" /> <i className="gicon gicon-add-panel" />
<button className="add-panel__close" onClick={this.handleCloseAddPanel}> <button className="add-panel-widget__close" onClick={this.handleCloseAddPanel}>
<i className="fa fa-close" /> <i className="fa fa-close" />
</button> </button>
</div> </div>
<div className="add-panel-btn-container"> <div className="add-panel-widget__btn-container">
<button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}> <button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
Edit Panel Edit Panel
</button> </button>

View File

@ -1,12 +1,12 @@
.add-panel-container { .add-panel-widget-container {
height: 100%; height: 100%;
} }
.add-panel { .add-panel-widget {
height: 100%; height: 100%;
} }
.add-panel__header { .add-panel-widget__header {
top: 0; top: 0;
position: absolute; position: absolute;
padding: 0 15px; padding: 0 15px;
@ -26,7 +26,7 @@
} }
} }
.add-panel__close { .add-panel-widget__close {
margin-left: auto; margin-left: auto;
background-color: transparent; background-color: transparent;
border: 0; border: 0;
@ -34,7 +34,7 @@
margin-right: -10px; margin-right: -10px;
} }
.add-panel-btn-container { .add-panel-widget__btn-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@ -0,0 +1 @@
export { AddPanelWidget } from './AddPanelWidget';

View File

@ -2,7 +2,7 @@ import angular from 'angular';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import { DashboardExporter } from './exporter'; import { DashboardExporter } from './DashboardExporter';
export class DashExportCtrl { export class DashExportCtrl {
dash: any; dash: any;
@ -66,7 +66,7 @@ export class DashExportCtrl {
export function dashExportDirective() { export function dashExportDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/export/export_modal.html', templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html',
controller: DashExportCtrl, controller: DashExportCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',

View File

@ -6,8 +6,8 @@ jest.mock('app/core/store', () => {
import _ from 'lodash'; import _ from 'lodash';
import config from 'app/core/config'; import config from 'app/core/config';
import { DashboardExporter } from '../export/exporter'; import { DashboardExporter } from './DashboardExporter';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
describe('given dashboard with repeated panels', () => { describe('given dashboard with repeated panels', () => {
let dash, exported; let dash, exported;

View File

@ -1,6 +1,6 @@
import config from 'app/core/config'; import config from 'app/core/config';
import _ from 'lodash'; import _ from 'lodash';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
export class DashboardExporter { export class DashboardExporter {
constructor(private datasourceSrv) {} constructor(private datasourceSrv) {}

View File

@ -0,0 +1,2 @@
export { DashboardExporter } from './DashboardExporter';
export { DashExportCtrl } from './DashExportCtrl';

View File

@ -1,6 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash'; import _ from 'lodash';
import { iconMap } from './editor'; import { iconMap } from './DashLinksEditorCtrl';
function dashLinksContainer() { function dashLinksContainer() {
return { return {

View File

@ -11,7 +11,7 @@ export let iconMap = {
cloud: 'fa-cloud', cloud: 'fa-cloud',
}; };
export class DashLinkEditorCtrl { export class DashLinksEditorCtrl {
dashboard: any; dashboard: any;
iconMap: any; iconMap: any;
mode: any; mode: any;
@ -65,8 +65,8 @@ export class DashLinkEditorCtrl {
function dashLinksEditor() { function dashLinksEditor() {
return { return {
restrict: 'E', restrict: 'E',
controller: DashLinkEditorCtrl, controller: DashLinksEditorCtrl,
templateUrl: 'public/app/features/dashboard/dashlinks/editor.html', templateUrl: 'public/app/features/dashboard/components/DashLinks/editor.html',
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
scope: { scope: {

View File

@ -0,0 +1,2 @@
export { DashLinksContainerCtrl } from './DashLinksContainerCtrl';
export { DashLinksEditorCtrl } from './DashLinksEditorCtrl';

View File

@ -1,7 +1,7 @@
import moment from 'moment'; import moment from 'moment';
import angular from 'angular'; import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core'; import { appEvents, NavModel } from 'app/core/core';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
export class DashNavCtrl { export class DashNavCtrl {
dashboard: DashboardModel; dashboard: DashboardModel;
@ -60,7 +60,7 @@ export class DashNavCtrl {
modalScope.dashboard = this.dashboard; modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', { appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/partials/shareModal.html', src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope, scope: modalScope,
}); });
} }
@ -107,7 +107,7 @@ export class DashNavCtrl {
export function dashNavDirective() { export function dashNavDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/dashnav/dashnav.html', templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl, controller: DashNavCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',

View File

@ -0,0 +1 @@
export { DashNavCtrl } from './DashNavCtrl';

View File

@ -8,11 +8,11 @@ import {
addDashboardPermission, addDashboardPermission,
removeDashboardPermission, removeDashboardPermission,
updateDashboardPermission, updateDashboardPermission,
} from '../state/actions'; } from '../../state/actions';
import PermissionList from 'app/core/components/PermissionList/PermissionList'; import PermissionList from 'app/core/components/PermissionList/PermissionList';
import AddPermission from 'app/core/components/PermissionList/AddPermission'; import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
export interface Props { export interface Props {
dashboardId: number; dashboardId: number;

View File

@ -1,5 +1,5 @@
import { coreModule, appEvents, contextSrv } from 'app/core/core'; import { coreModule, appEvents, contextSrv } from 'app/core/core';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
import $ from 'jquery'; import $ from 'jquery';
import _ from 'lodash'; import _ from 'lodash';
import angular from 'angular'; import angular from 'angular';
@ -230,7 +230,7 @@ export class SettingsCtrl {
export function dashboardSettings() { export function dashboardSettings() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/settings/settings.html', templateUrl: 'public/app/features/dashboard/components/DashboardSettings/template.html',
controller: SettingsCtrl, controller: SettingsCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',

View File

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

View File

@ -51,7 +51,8 @@
on-change="ctrl.onFolderChange($folder)" on-change="ctrl.onFolderChange($folder)"
enable-create-new="true" enable-create-new="true"
is-valid-selection="true" is-valid-selection="true"
label-class="width-7"> label-class="width-7"
dashboard-id="ctrl.dashboard.id">
</folder-picker> </folder-picker>
<gf-form-switch class="gf-form" label="Editable" tooltip="Uncheck, then save and reload to disable all dashboard editing" checked="ctrl.dashboard.editable" label-class="width-7"> <gf-form-switch class="gf-form" label="Editable" tooltip="Uncheck, then save and reload to disable all dashboard editing" checked="ctrl.dashboard.editable" label-class="width-7">
</gf-form-switch> </gf-form-switch>

View File

@ -31,7 +31,7 @@ export class ExportDataModalCtrl {
export function exportDataModal() { export function exportDataModal() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/export_data/export_data_modal.html', templateUrl: 'public/app/features/dashboard/components/ExportDataModal/template.html',
controller: ExportDataModalCtrl, controller: ExportDataModalCtrl,
controllerAs: 'ctrl', controllerAs: 'ctrl',
scope: { scope: {

View File

@ -0,0 +1 @@
export { ExportDataModalCtrl } from './ExportDataModalCtrl';

View File

@ -21,6 +21,7 @@ export class FolderPickerCtrl {
hasValidationError: boolean; hasValidationError: boolean;
validationError: any; validationError: any;
isEditor: boolean; isEditor: boolean;
dashboardId?: number;
/** @ngInject */ /** @ngInject */
constructor(private backendSrv, private validationSrv, private contextSrv) { constructor(private backendSrv, private validationSrv, private contextSrv) {
@ -144,7 +145,13 @@ export class FolderPickerCtrl {
if (this.isEditor) { if (this.isEditor) {
folder = rootFolder; folder = rootFolder;
} else { } else {
folder = result.length > 0 ? result[0] : resetFolder; // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard
const isPersistedDashBoard = this.dashboardId ? true : false;
if (isPersistedDashBoard) {
folder = resetFolder;
} else {
folder = result.length > 0 ? result[0] : resetFolder;
}
} }
} }
@ -161,7 +168,7 @@ export class FolderPickerCtrl {
export function folderPicker() { export function folderPicker() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/folder_picker/folder_picker.html', templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html',
controller: FolderPickerCtrl, controller: FolderPickerCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
@ -176,6 +183,7 @@ export function folderPicker() {
exitFolderCreation: '&', exitFolderCreation: '&',
enableCreateNew: '@', enableCreateNew: '@',
enableReset: '@', enableReset: '@',
dashboardId: '<?',
}, },
}; };
} }

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