mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into configurable-alert-notification
* master: (250 commits) Firing off an action instead of listening to location changes Changes after PR Comments Made ExplorerToolbar connected and refactored away responsabilities from Explore Removed some split complexity Fixed some more styling Fixed close split look and feel Fixed position of Closesplit Fixed small issue with TimePicker dropdown position Simplified some styles and dom elements Fixed some more with the sidemenu open and smaller screens Fixed so heading looks good with closed sidemenu Restructure of component and styling Refactored out ExploreToolbar from Explore Fixed reinitialise of Explore changelog: add notes about closing #13929 changelog: add notes about closing #14558 changelog: add notes about closing #14484 changelog: add notes about closing #13765 changelog: add notes about closing #11503 changelog: add notes about closing #4075 ...
This commit is contained in:
commit
6a8643b3d1
@ -19,7 +19,7 @@ version: 2
|
||||
jobs:
|
||||
mysql-integration-test:
|
||||
docker:
|
||||
- image: circleci/golang:1.11.4
|
||||
- image: circleci/golang:1.11.5
|
||||
- image: circleci/mysql:5.6-ram
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpass
|
||||
@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
postgres-integration-test:
|
||||
docker:
|
||||
- image: circleci/golang:1.11.4
|
||||
- image: circleci/golang:1.11.5
|
||||
- image: circleci/postgres:9.3-ram
|
||||
environment:
|
||||
POSTGRES_USER: grafanatest
|
||||
@ -74,27 +74,16 @@ jobs:
|
||||
|
||||
gometalinter:
|
||||
docker:
|
||||
- image: circleci/golang:1.11.4
|
||||
- image: circleci/golang:1.11.5
|
||||
environment:
|
||||
# we need CGO because of go-sqlite3
|
||||
CGO_ENABLED: 1
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
- run: 'go get -u github.com/alecthomas/gometalinter'
|
||||
- run: 'go get -u github.com/tsenart/deadcode'
|
||||
- run: 'go get -u github.com/jgautheron/goconst/cmd/goconst'
|
||||
- run: 'go get -u github.com/gordonklaus/ineffassign'
|
||||
- run: 'go get -u honnef.co/go/tools/cmd/megacheck'
|
||||
- run: 'go get -u github.com/opennota/check/cmd/structcheck'
|
||||
- run: 'go get -u github.com/mdempsky/unconvert'
|
||||
- run: 'go get -u github.com/opennota/check/cmd/varcheck'
|
||||
- run:
|
||||
name: run linters
|
||||
command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=goconst --enable=gofmt --enable=ineffassign --enable=megacheck --enable=structcheck --enable=unconvert --enable=varcheck ./...'
|
||||
- run:
|
||||
name: run go vet
|
||||
command: 'go vet ./pkg/...'
|
||||
name: Gometalinter tests
|
||||
command: './scripts/gometalinter.sh'
|
||||
|
||||
test-frontend:
|
||||
docker:
|
||||
@ -117,7 +106,7 @@ jobs:
|
||||
|
||||
test-backend:
|
||||
docker:
|
||||
- image: circleci/golang:1.11.4
|
||||
- image: circleci/golang:1.11.5
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
@ -127,7 +116,7 @@ jobs:
|
||||
|
||||
build-all:
|
||||
docker:
|
||||
- image: grafana/build-container:1.2.2
|
||||
- image: grafana/build-container:1.2.3
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
@ -175,7 +164,7 @@ jobs:
|
||||
|
||||
build:
|
||||
docker:
|
||||
- image: grafana/build-container:1.2.2
|
||||
- image: grafana/build-container:1.2.3
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
@ -244,7 +233,7 @@ jobs:
|
||||
|
||||
build-enterprise:
|
||||
docker:
|
||||
- image: grafana/build-container:1.2.2
|
||||
- image: grafana/build-container:1.2.3
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
@ -276,7 +265,7 @@ jobs:
|
||||
|
||||
build-all-enterprise:
|
||||
docker:
|
||||
- image: grafana/build-container:1.2.2
|
||||
- image: grafana/build-container:1.2.3
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
@ -323,7 +312,7 @@ jobs:
|
||||
|
||||
deploy-enterprise-master:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@ -346,7 +335,7 @@ jobs:
|
||||
|
||||
deploy-enterprise-release:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@ -370,15 +359,15 @@ jobs:
|
||||
command: './scripts/build/load-signing-key.sh'
|
||||
- run:
|
||||
name: Update Debian repository
|
||||
command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
|
||||
- run:
|
||||
name: Update RPM repository
|
||||
command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
|
||||
|
||||
|
||||
deploy-master:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: .
|
||||
@ -408,7 +397,7 @@ jobs:
|
||||
|
||||
deploy-release:
|
||||
docker:
|
||||
- image: grafana/grafana-ci-deploy:1.1.0
|
||||
- image: grafana/grafana-ci-deploy:1.2.0
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
@ -433,10 +422,10 @@ jobs:
|
||||
command: './scripts/build/load-signing-key.sh'
|
||||
- run:
|
||||
name: Update Debian repository
|
||||
command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
|
||||
- run:
|
||||
name: Update RPM repository
|
||||
command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
|
||||
command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
|
27
CHANGELOG.md
27
CHANGELOG.md
@ -1,29 +1,44 @@
|
||||
# 5.5.0 (unreleased)
|
||||
# 6.0.0-beta1 (unreleased)
|
||||
|
||||
### New Features
|
||||
* **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)
|
||||
* **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)
|
||||
|
||||
### 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 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)
|
||||
* **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)
|
||||
* **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)
|
||||
* **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)
|
||||
* **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)
|
||||
* **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)
|
||||
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
|
||||
* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
|
||||
* **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
|
||||
* **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)
|
||||
* **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)
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Golang build container
|
||||
FROM golang:1.11.4
|
||||
FROM golang:1.11.5
|
||||
|
||||
WORKDIR $GOPATH/src/github.com/grafana/grafana
|
||||
|
||||
@ -19,11 +19,13 @@ COPY package.json package.json
|
||||
RUN go run build.go build
|
||||
|
||||
# Node build container
|
||||
FROM node:8
|
||||
FROM node:10.14.2
|
||||
|
||||
WORKDIR /usr/src/app/
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
COPY packages packages
|
||||
|
||||
RUN yarn install --pure-lockfile --no-progress
|
||||
|
||||
COPY Gruntfile.js tsconfig.json tslint.json ./
|
||||
|
18
ROADMAP.md
18
ROADMAP.md
@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan.
|
||||
|
||||
### Short term (1-2 months)
|
||||
- PRs & Bugs
|
||||
- Multi-Stat panel
|
||||
- React Panel Support
|
||||
- React Query Editor Support
|
||||
- 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)
|
||||
- React Panels
|
||||
- Change visualization (panel type) on the fly.
|
||||
- Templating Query Editor UI Plugin hook
|
||||
- Backend plugins
|
||||
- Drilldown links
|
||||
- Dashboards as code workflows
|
||||
- React migration
|
||||
- New panels
|
||||
|
||||
### Long term (4 - 8 months)
|
||||
- Alerting improvements (silence, per series tracking, etc)
|
||||
- Progress on React migration
|
||||
|
||||
### In a distant future far far away
|
||||
- Meta queries
|
||||
|
@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
|
||||
environment:
|
||||
nodejs_version: "8"
|
||||
GOPATH: C:\gopath
|
||||
GOVERSION: 1.11.4
|
||||
GOVERSION: 1.11.5
|
||||
|
||||
install:
|
||||
- rmdir c:\go /s /q
|
||||
|
16
build.go
16
build.go
@ -46,6 +46,8 @@ var (
|
||||
binaries []string = []string{"grafana-server", "grafana-cli"}
|
||||
isDev bool = false
|
||||
enterprise bool = false
|
||||
skipRpmGen bool = false
|
||||
skipDebGen bool = false
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -67,6 +69,8 @@ func main() {
|
||||
flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
|
||||
flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
|
||||
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()
|
||||
|
||||
buildId = shortenBuildId(buildIdRaw)
|
||||
@ -165,6 +169,7 @@ func makeLatestDistCopies() {
|
||||
".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm",
|
||||
".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.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",
|
||||
}
|
||||
|
||||
@ -239,6 +244,8 @@ func createDebPackages() {
|
||||
previousPkgArch := pkgArch
|
||||
if pkgArch == "armv7" {
|
||||
pkgArch = "armhf"
|
||||
} else if pkgArch == "armv6" {
|
||||
pkgArch = "armel"
|
||||
}
|
||||
createPackage(linuxPackageOptions{
|
||||
packageType: "deb",
|
||||
@ -289,8 +296,13 @@ func createRpmPackages() {
|
||||
}
|
||||
|
||||
func createLinuxPackages() {
|
||||
createDebPackages()
|
||||
createRpmPackages()
|
||||
if !skipDebGen {
|
||||
createDebPackages()
|
||||
}
|
||||
|
||||
if !skipRpmGen {
|
||||
createRpmPackages()
|
||||
}
|
||||
}
|
||||
|
||||
func createPackage(options linuxPackageOptions) {
|
||||
|
@ -106,6 +106,22 @@ path = grafana.db
|
||||
# For "sqlite3" only. cache mode setting used for connecting to the database
|
||||
cache_mode = private
|
||||
|
||||
#################################### Login ###############################
|
||||
|
||||
[login]
|
||||
|
||||
# Login cookie name
|
||||
cookie_name = grafana_session
|
||||
|
||||
# How many days an session can be unused before we inactivate it
|
||||
login_remember_days = 7
|
||||
|
||||
# How often should the login token be rotated. default to '10m'
|
||||
rotate_token_minutes = 10
|
||||
|
||||
# How long should Grafana keep expired tokens before deleting them
|
||||
delete_expired_token_after_days = 30
|
||||
|
||||
#################################### Session #############################
|
||||
[session]
|
||||
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
|
||||
@ -175,11 +191,6 @@ admin_password = admin
|
||||
# used for signing
|
||||
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 = false
|
||||
|
||||
@ -189,6 +200,9 @@ data_source_proxy_whitelist =
|
||||
# disable protection against brute force login attempts
|
||||
disable_brute_force_login_protection = false
|
||||
|
||||
# set cookies as https only. default is false
|
||||
https_flag_cookies = false
|
||||
|
||||
#################################### Snapshots ###########################
|
||||
[snapshots]
|
||||
# snapshot sharing options
|
||||
@ -570,6 +584,7 @@ callback_url =
|
||||
|
||||
[panels]
|
||||
enable_alpha = false
|
||||
disable_sanitize_html = false
|
||||
|
||||
[enterprise]
|
||||
license_path =
|
||||
|
@ -102,6 +102,22 @@ log_queries =
|
||||
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
|
||||
;cache_mode = private
|
||||
|
||||
#################################### Login ###############################
|
||||
|
||||
[login]
|
||||
|
||||
# Login cookie name
|
||||
;cookie_name = grafana_session
|
||||
|
||||
# How many days an session can be unused before we inactivate it
|
||||
;login_remember_days = 7
|
||||
|
||||
# How often should the login token be rotated. default to '10'
|
||||
;rotate_token_minutes = 10
|
||||
|
||||
# How long should Grafana keep expired tokens before deleting them
|
||||
;delete_expired_token_after_days = 30
|
||||
|
||||
#################################### Session ####################################
|
||||
[session]
|
||||
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
|
||||
@ -162,11 +178,6 @@ log_queries =
|
||||
# used for signing
|
||||
;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 = false
|
||||
|
||||
@ -176,6 +187,9 @@ log_queries =
|
||||
# disable protection against brute force login attempts
|
||||
;disable_brute_force_login_protection = false
|
||||
|
||||
# set cookies as https only. default is false
|
||||
;https_flag_cookies = false
|
||||
|
||||
#################################### Snapshots ###########################
|
||||
[snapshots]
|
||||
# snapshot sharing options
|
||||
@ -495,3 +509,8 @@ log_queries =
|
||||
# Path to a valid Grafana Enterprise license.jwt file
|
||||
;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
|
||||
|
||||
|
1250
devenv/dev-dashboards/panel_tests_gauge.json
Normal file
1250
devenv/dev-dashboards/panel_tests_gauge.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -54,7 +54,8 @@ services:
|
||||
# - GF_DATABASE_SSL_MODE=disable
|
||||
# - GF_SESSION_PROVIDER=postgres
|
||||
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
|
||||
- GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
|
||||
- GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
|
||||
- GF_LOGIN_ROTATE_TOKEN_MINUTES=2
|
||||
ports:
|
||||
- 3000
|
||||
depends_on:
|
||||
|
69
devenv/docker/loadtest/README.md
Normal file
69
devenv/docker/loadtest/README.md
Normal 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
|
||||
```
|
71
devenv/docker/loadtest/auth_token_test.js
Normal file
71
devenv/docker/loadtest/auth_token_test.js
Normal 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) => {}
|
187
devenv/docker/loadtest/modules/client.js
Normal file
187
devenv/docker/loadtest/modules/client.js
Normal 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));
|
||||
}
|
35
devenv/docker/loadtest/modules/util.js
Normal file
35
devenv/docker/loadtest/modules/util.js
Normal 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
24
devenv/docker/loadtest/run.sh
Executable 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 "$@"
|
@ -47,7 +47,7 @@ authentication:
|
||||
|
||||
```bash
|
||||
[auth.gitlab]
|
||||
enabled = false
|
||||
enabled = true
|
||||
allow_sign_up = false
|
||||
client_id = GITLAB_APPLICATION_ID
|
||||
client_secret = GITLAB_SECRET
|
||||
|
@ -38,7 +38,7 @@ Name | Description
|
||||
|
||||
### IAM Roles
|
||||
|
||||
Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If you grafana
|
||||
Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If your Grafana
|
||||
server is running on AWS you can use IAM Roles and authentication will be handled automatically.
|
||||
|
||||
Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
|
||||
|
@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
"defaultRegion": "us-west-1"
|
||||
},
|
||||
"secureJsonData": {
|
||||
"accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded
|
||||
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded
|
||||
"accessKey": "Ol4pIDpeKSA6XikgOl4p",
|
||||
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -82,4 +82,29 @@ HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"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"
|
||||
}
|
||||
```
|
||||
|
@ -391,6 +391,12 @@ value is `true`.
|
||||
If you want to track Grafana usage via Google analytics specify *your* Universal
|
||||
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 />
|
||||
|
||||
## [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.
|
||||
This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
|
||||
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.
|
||||
|
||||
|
||||
|
@ -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
|
||||
`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.
|
||||
`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).
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
"company": "Grafana Labs"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "5.5.0-pre1",
|
||||
"version": "6.0.0-pre1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
@ -188,7 +188,8 @@
|
||||
"slate-react": "^0.12.4",
|
||||
"tether": "^1.4.0",
|
||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||
"tinycolor2": "^1.4.1"
|
||||
"tinycolor2": "^1.4.1",
|
||||
"xss": "^1.0.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"caniuse-db": "1.0.30000772",
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import _ from 'lodash';
|
||||
import Scrollbars from 'react-custom-scrollbars';
|
||||
|
||||
interface Props {
|
||||
@ -6,8 +7,11 @@ interface Props {
|
||||
autoHide?: boolean;
|
||||
autoHideTimeout?: number;
|
||||
autoHideDuration?: number;
|
||||
autoMaxHeight?: string;
|
||||
autoHeightMax?: string;
|
||||
hideTracksWhenNotNeeded?: boolean;
|
||||
scrollTop?: number;
|
||||
setScrollTop: (event: any) => void;
|
||||
autoHeightMin?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -16,24 +20,67 @@ interface Props {
|
||||
export class CustomScrollbar extends PureComponent<Props> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
customClassName: 'custom-scrollbars',
|
||||
autoHide: true,
|
||||
autoHide: false,
|
||||
autoHideTimeout: 200,
|
||||
autoHideDuration: 200,
|
||||
autoMaxHeight: '100%',
|
||||
setScrollTop: () => {},
|
||||
hideTracksWhenNotNeeded: false,
|
||||
autoHeightMin: '0',
|
||||
autoHeightMax: '100%',
|
||||
};
|
||||
|
||||
private ref: React.RefObject<Scrollbars>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.ref = React.createRef<Scrollbars>();
|
||||
}
|
||||
|
||||
updateScroll() {
|
||||
const ref = this.ref.current;
|
||||
|
||||
if (ref && !_.isNil(this.props.scrollTop)) {
|
||||
if (this.props.scrollTop > 10000) {
|
||||
ref.scrollToBottom();
|
||||
} else {
|
||||
ref.scrollTop(this.props.scrollTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateScroll();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateScroll();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { customClassName, children, autoMaxHeight } = this.props;
|
||||
const {
|
||||
customClassName,
|
||||
children,
|
||||
autoHeightMax,
|
||||
autoHeightMin,
|
||||
setScrollTop,
|
||||
autoHide,
|
||||
autoHideTimeout,
|
||||
hideTracksWhenNotNeeded,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
ref={this.ref}
|
||||
className={customClassName}
|
||||
onScroll={setScrollTop}
|
||||
autoHeight={true}
|
||||
autoHide={autoHide}
|
||||
autoHideTimeout={autoHideTimeout}
|
||||
hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
|
||||
// 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
|
||||
autoHeightMin={'0'}
|
||||
autoHeightMax={autoMaxHeight}
|
||||
autoHeightMax={autoHeightMax}
|
||||
autoHeightMin={autoHeightMin}
|
||||
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
|
||||
renderTrackVertical={props => <div {...props} className="track-vertical" />}
|
||||
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
||||
|
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { FormField, Props } from './FormField';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
label: 'Test',
|
||||
labelWidth: 11,
|
||||
value: 10,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
return shallow(<FormField {...props} />);
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup();
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
25
packages/grafana-ui/src/components/FormField/FormField.tsx
Normal file
25
packages/grafana-ui/src/components/FormField/FormField.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { InputHTMLAttributes, FunctionComponent } from 'react';
|
||||
import { FormLabel } from '..';
|
||||
|
||||
export interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
labelWidth?: number;
|
||||
inputWidth?: number;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
labelWidth: 6,
|
||||
inputWidth: 12,
|
||||
};
|
||||
|
||||
const FormField: FunctionComponent<Props> = ({ label, labelWidth, inputWidth, ...inputProps }) => {
|
||||
return (
|
||||
<div className="form-field">
|
||||
<FormLabel width={labelWidth}>{label}</FormLabel>
|
||||
<input type="text" className={`gf-form-input width-${inputWidth}`} {...inputProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormField.defaultProps = defaultProps;
|
||||
export { FormField };
|
12
packages/grafana-ui/src/components/FormField/_FormField.scss
Normal file
12
packages/grafana-ui/src/components/FormField/_FormField.scss
Normal file
@ -0,0 +1,12 @@
|
||||
.form-field {
|
||||
margin-bottom: $gf-form-margin;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
|
||||
&--grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="form-field"
|
||||
>
|
||||
<Component
|
||||
width={11}
|
||||
>
|
||||
Test
|
||||
</Component>
|
||||
<input
|
||||
className="gf-form-input width-12"
|
||||
onChange={[MockFunction]}
|
||||
type="text"
|
||||
value={10}
|
||||
/>
|
||||
</div>
|
||||
`;
|
42
packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
Normal file
42
packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React, { FunctionComponent, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Tooltip } from '..';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
htmlFor?: string;
|
||||
isFocused?: boolean;
|
||||
isInvalid?: boolean;
|
||||
tooltip?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const FormLabel: FunctionComponent<Props> = ({
|
||||
children,
|
||||
isFocused,
|
||||
isInvalid,
|
||||
className,
|
||||
htmlFor,
|
||||
tooltip,
|
||||
width,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = classNames(`gf-form-label width-${width ? width : '10'}`, className, {
|
||||
'gf-form-label--is-focused': isFocused,
|
||||
'gf-form-label--is-invalid': isInvalid,
|
||||
});
|
||||
|
||||
return (
|
||||
<label className={classes} {...rest} htmlFor={htmlFor}>
|
||||
{children}
|
||||
{tooltip && (
|
||||
<Tooltip placement="auto" content={tooltip}>
|
||||
<div className="gf-form-help-icon--right-normal">
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
147
packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
Normal file
147
packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Gauge, Props } from './Gauge';
|
||||
import { TimeSeriesVMs } from '../../types/series';
|
||||
import { ValueMapping, MappingType } from '../../types';
|
||||
|
||||
jest.mock('jquery', () => ({
|
||||
plot: jest.fn(),
|
||||
}));
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
maxValue: 100,
|
||||
valueMappings: [],
|
||||
minValue: 0,
|
||||
prefix: '',
|
||||
showThresholdMarkers: true,
|
||||
showThresholdLabels: false,
|
||||
suffix: '',
|
||||
thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
|
||||
unit: 'none',
|
||||
stat: 'avg',
|
||||
height: 300,
|
||||
width: 300,
|
||||
timeSeries: {} as TimeSeriesVMs,
|
||||
decimals: 0,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<Gauge {...props} />);
|
||||
const instance = wrapper.instance() as Gauge;
|
||||
|
||||
return {
|
||||
instance,
|
||||
wrapper,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Get font color', () => {
|
||||
it('should get first threshold color when only one threshold', () => {
|
||||
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
|
||||
|
||||
expect(instance.getFontColor(49)).toEqual('#7EB26D');
|
||||
});
|
||||
|
||||
it('should get the threshold color if value is same as a threshold', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(instance.getFontColor(50)).toEqual('#EAB839');
|
||||
});
|
||||
|
||||
it('should get the nearest threshold color between thresholds', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(instance.getFontColor(55)).toEqual('#EAB839');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get thresholds formatted', () => {
|
||||
it('should return first thresholds color for min and max', () => {
|
||||
const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
|
||||
|
||||
expect(instance.getFormattedThresholds()).toEqual([
|
||||
{ value: 0, color: '#7EB26D' },
|
||||
{ value: 100, color: '#7EB26D' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get the correct formatted values when thresholds are added', () => {
|
||||
const { instance } = setup({
|
||||
thresholds: [
|
||||
{ index: 2, value: 75, color: '#6ED0E0' },
|
||||
{ index: 1, value: 50, color: '#EAB839' },
|
||||
{ index: 0, value: -Infinity, color: '#7EB26D' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(instance.getFormattedThresholds()).toEqual([
|
||||
{ value: 0, color: '#7EB26D' },
|
||||
{ value: 50, color: '#7EB26D' },
|
||||
{ value: 75, color: '#EAB839' },
|
||||
{ value: 100, color: '#6ED0E0' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Format value', () => {
|
||||
it('should return if value isNaN', () => {
|
||||
const valueMappings: ValueMapping[] = [];
|
||||
const value = 'N/A';
|
||||
const { instance } = setup({ valueMappings });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual('N/A');
|
||||
});
|
||||
|
||||
it('should return formatted value if there are no value mappings', () => {
|
||||
const valueMappings: ValueMapping[] = [];
|
||||
const value = '6';
|
||||
const { instance } = setup({ valueMappings, decimals: 1 });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 6.0 ');
|
||||
});
|
||||
|
||||
it('should return formatted value if there are no matching value mappings', () => {
|
||||
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, decimals: 1 });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 10.0 ');
|
||||
});
|
||||
|
||||
it('should return mapped value if there are matching value mappings', () => {
|
||||
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 = '11';
|
||||
const { instance } = setup({ valueMappings, decimals: 1 });
|
||||
|
||||
const result = instance.formatValue(value);
|
||||
|
||||
expect(result).toEqual(' 1-20 ');
|
||||
});
|
||||
});
|
@ -1,12 +1,12 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import $ from 'jquery';
|
||||
import { BasicGaugeColor, Threshold, TimeSeriesVMs, MappingType, ValueMapping } from '@grafana/ui';
|
||||
|
||||
import config from '../core/config';
|
||||
import kbn from '../core/utils/kbn';
|
||||
import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
|
||||
import { TimeSeriesVMs } from '../../types/series';
|
||||
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
|
||||
import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
|
||||
|
||||
export interface Props {
|
||||
baseColor: string;
|
||||
decimals: number;
|
||||
height: number;
|
||||
valueMappings: ValueMapping[];
|
||||
@ -21,13 +21,13 @@ export interface Props {
|
||||
suffix: string;
|
||||
unit: string;
|
||||
width: number;
|
||||
theme?: ThemeName;
|
||||
}
|
||||
|
||||
export class Gauge extends PureComponent<Props> {
|
||||
canvasElement: any;
|
||||
|
||||
static defaultProps = {
|
||||
baseColor: BasicGaugeColor.Green,
|
||||
maxValue: 100,
|
||||
valueMappings: [],
|
||||
minValue: 0,
|
||||
@ -38,6 +38,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
thresholds: [],
|
||||
unit: 'none',
|
||||
stat: 'avg',
|
||||
theme: ThemeNames.Dark,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@ -48,88 +49,93 @@ export class Gauge extends PureComponent<Props> {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
formatWithMappings(mappings, value) {
|
||||
const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
|
||||
const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
|
||||
|
||||
const valueMap = valueMaps.map(mapping => {
|
||||
if (mapping.value && value === mapping.value) {
|
||||
return mapping.text;
|
||||
}
|
||||
})[0];
|
||||
|
||||
const rangeMap = rangeMaps.map(mapping => {
|
||||
if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
|
||||
return mapping.text;
|
||||
}
|
||||
})[0];
|
||||
|
||||
return { rangeMap, valueMap };
|
||||
}
|
||||
|
||||
formatValue(value) {
|
||||
formatValue(value: TimeSeriesValue) {
|
||||
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
|
||||
|
||||
const formatFunc = kbn.valueFormats[unit];
|
||||
const formattedValue = formatFunc(value, decimals);
|
||||
if (isNaN(value as number)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (valueMappings.length > 0) {
|
||||
const { rangeMap, valueMap } = this.formatWithMappings(valueMappings, formattedValue);
|
||||
|
||||
if (valueMap) {
|
||||
return `${prefix} ${valueMap} ${suffix}`;
|
||||
} else if (rangeMap) {
|
||||
return `${prefix} ${rangeMap} ${suffix}`;
|
||||
const valueMappedValue = getMappedValue(valueMappings, value);
|
||||
if (valueMappedValue) {
|
||||
return `${prefix} ${valueMappedValue.text} ${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNaN(value)) {
|
||||
return '-';
|
||||
}
|
||||
const formatFunc = getValueFormat(unit);
|
||||
const formattedValue = formatFunc(value as number, decimals);
|
||||
const handleNoValueValue = formattedValue || 'no value';
|
||||
|
||||
return `${prefix} ${formattedValue} ${suffix}`;
|
||||
return `${prefix} ${handleNoValueValue} ${suffix}`;
|
||||
}
|
||||
|
||||
getFontColor(value) {
|
||||
const { baseColor, maxValue, thresholds } = this.props;
|
||||
getFontColor(value: TimeSeriesValue) {
|
||||
const { thresholds } = this.props;
|
||||
|
||||
if (thresholds.length > 0) {
|
||||
const atThreshold = thresholds.filter(threshold => value <= threshold.value);
|
||||
|
||||
if (atThreshold.length > 0) {
|
||||
return atThreshold[0].color;
|
||||
} else if (value <= maxValue) {
|
||||
return BasicGaugeColor.Red;
|
||||
}
|
||||
if (thresholds.length === 1) {
|
||||
return thresholds[0].color;
|
||||
}
|
||||
|
||||
return baseColor;
|
||||
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||
if (atThreshold) {
|
||||
return atThreshold.color;
|
||||
}
|
||||
|
||||
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||
|
||||
if (belowThreshold.length > 0) {
|
||||
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
|
||||
return nearestThreshold.color;
|
||||
}
|
||||
|
||||
return BasicGaugeColor.Red;
|
||||
}
|
||||
|
||||
getFormattedThresholds() {
|
||||
const { maxValue, minValue, thresholds } = this.props;
|
||||
|
||||
const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
|
||||
const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
|
||||
|
||||
const formattedThresholds = [
|
||||
...thresholdsSortedByIndex.map(threshold => {
|
||||
if (threshold.index === 0) {
|
||||
return { value: minValue, color: threshold.color };
|
||||
}
|
||||
|
||||
const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
|
||||
return { value: threshold.value, color: previousThreshold.color };
|
||||
}),
|
||||
{ value: maxValue, color: lastThreshold.color },
|
||||
];
|
||||
|
||||
return formattedThresholds;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const {
|
||||
baseColor,
|
||||
maxValue,
|
||||
minValue,
|
||||
timeSeries,
|
||||
showThresholdLabels,
|
||||
showThresholdMarkers,
|
||||
thresholds,
|
||||
width,
|
||||
height,
|
||||
stat,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
let value: string | number = '';
|
||||
let value: TimeSeriesValue = '';
|
||||
|
||||
if (timeSeries[0]) {
|
||||
value = timeSeries[0].stats[stat];
|
||||
} else {
|
||||
value = 'N/A';
|
||||
value = null;
|
||||
}
|
||||
|
||||
const dimension = Math.min(width, height * 1.3);
|
||||
const backgroundColor = config.bootData.user.lightTheme ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
|
||||
const backgroundColor = theme === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
|
||||
const fontScale = parseInt('80', 10) / 100;
|
||||
const fontSize = Math.min(dimension / 5, 100) * fontScale;
|
||||
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
|
||||
@ -137,17 +143,6 @@ export class Gauge extends PureComponent<Props> {
|
||||
const thresholdMarkersWidth = gaugeWidth / 5;
|
||||
const thresholdLabelFontSize = fontSize / 2.5;
|
||||
|
||||
const formattedThresholds = [
|
||||
{ value: minValue, color: BasicGaugeColor.Green },
|
||||
...thresholds.map((threshold, index) => {
|
||||
return {
|
||||
value: threshold.value,
|
||||
color: index === 0 ? threshold.color : thresholds[index].color,
|
||||
};
|
||||
}),
|
||||
{ value: maxValue, color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor },
|
||||
];
|
||||
|
||||
const options = {
|
||||
series: {
|
||||
gauges: {
|
||||
@ -164,7 +159,7 @@ export class Gauge extends PureComponent<Props> {
|
||||
layout: { margin: 0, thresholdWidth: 0 },
|
||||
cell: { border: { width: 0 } },
|
||||
threshold: {
|
||||
values: formattedThresholds,
|
||||
values: this.getFormattedThresholds(),
|
||||
label: {
|
||||
show: showThresholdLabels,
|
||||
margin: thresholdMarkersWidth + 1,
|
@ -1,23 +0,0 @@
|
||||
import React, { SFC, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
className?: string;
|
||||
isFocused?: boolean;
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
||||
export const GfFormLabel: SFC<Props> = ({ children, isFocused, isInvalid, className, htmlFor, ...rest }) => {
|
||||
const classes = classNames('gf-form-label', className, {
|
||||
'gf-form-label--is-focused': isFocused,
|
||||
'gf-form-label--is-invalid': isInvalid,
|
||||
});
|
||||
|
||||
return (
|
||||
<label className={classes} {...rest} htmlFor={htmlFor}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
};
|
@ -1,25 +0,0 @@
|
||||
import React, { SFC, ReactNode } from 'react';
|
||||
import { Tooltip } from '../Tooltip/Tooltip';
|
||||
|
||||
interface Props {
|
||||
tooltip?: string;
|
||||
for?: string;
|
||||
children: ReactNode;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Label: SFC<Props> = props => {
|
||||
return (
|
||||
<span className={`gf-form-label width-${props.width ? props.width : '10'}`}>
|
||||
<span>{props.children}</span>
|
||||
{props.tooltip && (
|
||||
<Tooltip placement="auto" content={props.tooltip}>
|
||||
<div className="gf-form-help-icon--right-normal">
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
.panel-options-group__header {
|
||||
padding: 4px 20px;
|
||||
padding: 4px 8px;
|
||||
font-size: 1.1rem;
|
||||
background: $panel-options-group-header-bg;
|
||||
position: relative;
|
||||
|
@ -16,7 +16,7 @@ import SelectOptionGroup from './SelectOptionGroup';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
import resetSelectStyles from './resetSelectStyles';
|
||||
import { CustomScrollbar } from '@grafana/ui';
|
||||
import { CustomScrollbar } from '..';
|
||||
|
||||
export interface SelectOptionItem {
|
||||
label?: string;
|
||||
@ -61,7 +61,7 @@ interface AsyncProps {
|
||||
export const MenuList = (props: any) => {
|
||||
return (
|
||||
<components.MenuList {...props}>
|
||||
<CustomScrollbar autoHide={false} autoMaxHeight="inherit">{props.children}</CustomScrollbar>
|
||||
<CustomScrollbar autoHide={false} autoHeightMax="inherit">{props.children}</CustomScrollbar>
|
||||
</components.MenuList>
|
||||
);
|
||||
};
|
||||
@ -202,7 +202,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
|
||||
classNamePrefix="gf-form-select-box"
|
||||
className={selectClassNames}
|
||||
components={{
|
||||
Option,
|
||||
Option: SelectOption,
|
||||
SingleValue,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
|
@ -102,6 +102,7 @@ $select-input-bg-disabled: $input-bg-disabled;
|
||||
.gf-form-select-box__value-container {
|
||||
display: table-cell;
|
||||
padding: 6px 10px;
|
||||
vertical-align: middle;
|
||||
> div {
|
||||
display: inline-block;
|
||||
}
|
||||
|
@ -19,9 +19,15 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const thresholds: Threshold[] =
|
||||
props.thresholds.length > 0 ? props.thresholds : [{ index: 0, value: -Infinity, color: colors[0] }];
|
||||
const addDefaultThreshold = this.props.thresholds.length === 0;
|
||||
const thresholds: Threshold[] = addDefaultThreshold
|
||||
? [{ index: 0, value: -Infinity, color: colors[0] }]
|
||||
: props.thresholds;
|
||||
this.state = { thresholds };
|
||||
|
||||
if (addDefaultThreshold) {
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
onAddThreshold = (index: number) => {
|
||||
@ -62,7 +68,7 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
},
|
||||
]),
|
||||
},
|
||||
() => this.updateGauge()
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
@ -85,7 +91,7 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
thresholds: newThresholds.filter(t => t !== threshold),
|
||||
};
|
||||
},
|
||||
() => this.updateGauge()
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
@ -99,7 +105,7 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
const value = isNaN(parsedValue) ? null : parsedValue;
|
||||
|
||||
const newThresholds = thresholds.map(t => {
|
||||
if (t === threshold) {
|
||||
if (t === threshold && t.index !== 0) {
|
||||
t = { ...t, value: value as number };
|
||||
}
|
||||
|
||||
@ -124,11 +130,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
{
|
||||
thresholds: newThresholds,
|
||||
},
|
||||
() => this.updateGauge()
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
|
||||
onBlur = () => {
|
||||
this.setState(prevState => {
|
||||
const sortThresholds = this.sortThresholds([...prevState.thresholds]);
|
||||
@ -139,10 +144,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
return { thresholds: sortThresholds };
|
||||
});
|
||||
|
||||
this.updateGauge();
|
||||
this.onChange();
|
||||
};
|
||||
|
||||
updateGauge = () => {
|
||||
onChange = () => {
|
||||
this.props.onChange(this.state.thresholds);
|
||||
};
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import React, { ChangeEvent, PureComponent } from 'react';
|
||||
|
||||
import { MappingType, ValueMapping } from '../../types/panel';
|
||||
import { Label } from '../Label/Label';
|
||||
import { Select } from '../Select/Select';
|
||||
import { MappingType, ValueMapping } from '../../types';
|
||||
import { FormField, FormLabel, Select } from '..';
|
||||
|
||||
export interface Props {
|
||||
valueMapping: ValueMapping;
|
||||
@ -32,19 +31,19 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
this.state = { ...props.valueMapping };
|
||||
}
|
||||
|
||||
onMappingValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMappingValueChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ value: event.target.value });
|
||||
};
|
||||
|
||||
onMappingFromChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMappingFromChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ from: event.target.value });
|
||||
};
|
||||
|
||||
onMappingToChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMappingToChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ to: event.target.value });
|
||||
};
|
||||
|
||||
onMappingTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMappingTextChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ text: event.target.value });
|
||||
};
|
||||
|
||||
@ -62,30 +61,28 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
if (type === MappingType.RangeToText) {
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>From</Label>
|
||||
<FormField
|
||||
label="From"
|
||||
labelWidth={4}
|
||||
inputWidth={8}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingFromChange}
|
||||
value={from}
|
||||
/>
|
||||
<FormField
|
||||
label="To"
|
||||
labelWidth={4}
|
||||
inputWidth={8}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingToChange}
|
||||
value={to}
|
||||
/>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<FormLabel width={4}>Text</FormLabel>
|
||||
<input
|
||||
className="gf-form-input width-8"
|
||||
value={from}
|
||||
className="gf-form-input"
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingFromChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>To</Label>
|
||||
<input
|
||||
className="gf-form-input width-8"
|
||||
value={to}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingToChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>Text</Label>
|
||||
<input
|
||||
className="gf-form-input width-10"
|
||||
value={text}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingTextChange}
|
||||
/>
|
||||
</div>
|
||||
@ -95,17 +92,16 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form">
|
||||
<Label width={4}>Value</Label>
|
||||
<input
|
||||
className="gf-form-input width-8"
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingValueChange}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="Value"
|
||||
labelWidth={4}
|
||||
onBlur={this.updateMapping}
|
||||
onChange={this.onMappingValueChange}
|
||||
value={value}
|
||||
inputWidth={8}
|
||||
/>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<Label width={4}>Text</Label>
|
||||
<FormLabel width={4}>Text</FormLabel>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onBlur={this.updateMapping}
|
||||
@ -123,7 +119,7 @@ export default class MappingRow extends PureComponent<Props, State> {
|
||||
return (
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<Label width={5}>Type</Label>
|
||||
<FormLabel width={5}>Type</FormLabel>
|
||||
<Select
|
||||
placeholder="Choose type"
|
||||
isSearchable={false}
|
||||
|
@ -7,3 +7,4 @@
|
||||
@import 'PanelOptionsGrid/PanelOptionsGrid';
|
||||
@import 'ColorPicker/ColorPicker';
|
||||
@import 'ValueMappingsEditor/ValueMappingsEditor';
|
||||
@import "FormField/FormField";
|
||||
|
@ -2,7 +2,6 @@ export { DeleteButton } from './DeleteButton/DeleteButton';
|
||||
export { Tooltip } from './Tooltip/Tooltip';
|
||||
export { Portal } from './Portal/Portal';
|
||||
export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
|
||||
export { Label } from './Label/Label';
|
||||
|
||||
// Select
|
||||
export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
|
||||
@ -10,13 +9,17 @@ export { IndicatorsContainer } from './Select/IndicatorsContainer';
|
||||
export { NoOptionsMessage } from './Select/NoOptionsMessage';
|
||||
export { default as resetSelectStyles } from './Select/resetSelectStyles';
|
||||
|
||||
// Forms
|
||||
export { FormLabel } from './FormLabel/FormLabel';
|
||||
export { FormField } from './FormField/FormField';
|
||||
|
||||
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||
export { ColorPicker } from './ColorPicker/ColorPicker';
|
||||
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
|
||||
export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
|
||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||
export { GfFormLabel } from './GfFormLabel/GfFormLabel';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
|
89
packages/grafana-ui/src/types/datasource.ts
Normal file
89
packages/grafana-ui/src/types/datasource.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { TimeRange, RawTimeRange } from './time';
|
||||
import { TimeSeries } from './series';
|
||||
import { PluginMeta } from './plugin';
|
||||
|
||||
export interface DataQueryResponse {
|
||||
data: TimeSeries[];
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
/**
|
||||
* A - Z
|
||||
*/
|
||||
refId: string;
|
||||
|
||||
/**
|
||||
* true if query is disabled (ie not executed / sent to TSDB)
|
||||
*/
|
||||
hide?: boolean;
|
||||
|
||||
/**
|
||||
* Unique, guid like, string used in explore mode
|
||||
*/
|
||||
key?: string;
|
||||
|
||||
/**
|
||||
* For mixed data sources the selected datasource is on the query level.
|
||||
* For non mixed scenarios this is undefined.
|
||||
*/
|
||||
datasource?: string | null;
|
||||
}
|
||||
|
||||
export interface DataQueryOptions<TQuery extends DataQuery = DataQuery> {
|
||||
timezone: string;
|
||||
range: TimeRange;
|
||||
rangeRaw: RawTimeRange;
|
||||
targets: TQuery[];
|
||||
panelId: number;
|
||||
dashboardId: number;
|
||||
cacheTimeout?: string;
|
||||
interval: string;
|
||||
intervalMs: number;
|
||||
maxDataPoints: number;
|
||||
scopedVars: object;
|
||||
}
|
||||
|
||||
export interface QueryFix {
|
||||
type: string;
|
||||
label: string;
|
||||
action?: QueryFixAction;
|
||||
}
|
||||
|
||||
export interface QueryFixAction {
|
||||
type: string;
|
||||
query?: string;
|
||||
preventSubmit?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryHint {
|
||||
type: string;
|
||||
label: string;
|
||||
fix?: QueryFix;
|
||||
}
|
||||
|
||||
export interface DataSourceSettings {
|
||||
id: number;
|
||||
orgId: number;
|
||||
name: string;
|
||||
typeLogoUrl: string;
|
||||
type: string;
|
||||
access: string;
|
||||
url: string;
|
||||
password: string;
|
||||
user: string;
|
||||
database: string;
|
||||
basicAuth: boolean;
|
||||
basicAuthPassword: string;
|
||||
basicAuthUser: string;
|
||||
isDefault: boolean;
|
||||
jsonData: { authType: string; defaultRegion: string };
|
||||
readOnly: boolean;
|
||||
withCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface DataSourceSelectItem {
|
||||
name: string;
|
||||
value: string | null;
|
||||
meta: PluginMeta;
|
||||
sort: string;
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
export * from './series';
|
||||
export * from './time';
|
||||
export * from './panel';
|
||||
export * from './plugin';
|
||||
export * from './datasource';
|
||||
|
@ -66,3 +66,10 @@ export interface RangeMap extends BaseMap {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export type ThemeName = 'dark' | 'light';
|
||||
|
||||
export enum ThemeNames {
|
||||
Dark = 'dark',
|
||||
Light = 'light',
|
||||
}
|
||||
|
118
packages/grafana-ui/src/types/plugin.ts
Normal file
118
packages/grafana-ui/src/types/plugin.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { ComponentClass } from 'react';
|
||||
import { PanelProps, PanelOptionsProps } from './panel';
|
||||
import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint } from './datasource';
|
||||
|
||||
export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
|
||||
/**
|
||||
* min interval range
|
||||
*/
|
||||
interval?: string;
|
||||
|
||||
/**
|
||||
* Imports queries from a different datasource
|
||||
*/
|
||||
importQueries?(queries: TQuery[], originMeta: PluginMeta): Promise<TQuery[]>;
|
||||
|
||||
/**
|
||||
* Initializes a datasource after instantiation
|
||||
*/
|
||||
init?: () => void;
|
||||
|
||||
/**
|
||||
* Main metrics / data query action
|
||||
*/
|
||||
query(options: DataQueryOptions<TQuery>): Promise<DataQueryResponse>;
|
||||
|
||||
/**
|
||||
* Test & verify datasource settings & connection details
|
||||
*/
|
||||
testDatasource(): Promise<any>;
|
||||
|
||||
/**
|
||||
* Get hints for query improvements
|
||||
*/
|
||||
getQueryHints?(query: TQuery, results: any[], ...rest: any): QueryHint[];
|
||||
|
||||
/**
|
||||
* Set after constructor is called by Grafana
|
||||
*/
|
||||
name?: string;
|
||||
meta?: PluginMeta;
|
||||
pluginExports?: PluginExports;
|
||||
}
|
||||
|
||||
export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
|
||||
datasource: DSType;
|
||||
query: TQuery;
|
||||
onExecuteQuery?: () => void;
|
||||
onQueryChange?: (value: TQuery) => void;
|
||||
}
|
||||
|
||||
export interface PluginExports {
|
||||
Datasource?: DataSourceApi;
|
||||
QueryCtrl?: any;
|
||||
QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi,DataQuery>>;
|
||||
ConfigCtrl?: any;
|
||||
AnnotationsQueryCtrl?: any;
|
||||
VariableQueryEditor?: any;
|
||||
ExploreQueryField?: any;
|
||||
ExploreStartPage?: any;
|
||||
|
||||
// Panel plugin
|
||||
PanelCtrl?: any;
|
||||
Panel?: ComponentClass<PanelProps>;
|
||||
PanelOptions?: ComponentClass<PanelOptionsProps>;
|
||||
PanelDefaults?: any;
|
||||
}
|
||||
|
||||
export interface PluginMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
info: PluginMetaInfo;
|
||||
includes: PluginInclude[];
|
||||
|
||||
// Datasource-specific
|
||||
metrics?: boolean;
|
||||
tables?: boolean;
|
||||
logs?: boolean;
|
||||
explore?: boolean;
|
||||
annotations?: boolean;
|
||||
mixed?: boolean;
|
||||
hasQueryHelp?: boolean;
|
||||
queryOptions?: PluginMetaQueryOptions;
|
||||
}
|
||||
|
||||
interface PluginMetaQueryOptions {
|
||||
cacheTimeout?: boolean;
|
||||
maxDataPoints?: boolean;
|
||||
minInterval?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginInclude {
|
||||
type: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PluginMetaInfoLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginMetaInfo {
|
||||
author: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
description: string;
|
||||
links: PluginMetaInfoLink[];
|
||||
logos: {
|
||||
large: string;
|
||||
small: string;
|
||||
};
|
||||
screenshots: any[];
|
||||
updated: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
|
@ -21,9 +21,12 @@ export interface TimeSeriesVM {
|
||||
color: string;
|
||||
data: TimeSeriesValue[][];
|
||||
stats: TimeSeriesStats;
|
||||
allIsNull: boolean;
|
||||
allIsZero: boolean;
|
||||
}
|
||||
|
||||
export interface TimeSeriesStats {
|
||||
[key: string]: number | null;
|
||||
total: number | null;
|
||||
max: number | null;
|
||||
min: number | null;
|
||||
@ -36,8 +39,6 @@ export interface TimeSeriesStats {
|
||||
range: number | null;
|
||||
timeStep: number;
|
||||
count: number;
|
||||
allIsNull: boolean;
|
||||
allIsZero: boolean;
|
||||
}
|
||||
|
||||
export enum NullValueMode {
|
||||
|
@ -1,18 +1,19 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
|
||||
import { colors } from './colors';
|
||||
|
||||
// Types
|
||||
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
|
||||
|
||||
interface Options {
|
||||
timeSeries: TimeSeries[];
|
||||
nullValueMode: NullValueMode;
|
||||
colorPalette: string[];
|
||||
}
|
||||
|
||||
export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: Options): TimeSeriesVMs {
|
||||
export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
|
||||
const vmSeries = timeSeries.map((item, index) => {
|
||||
const colorIndex = index % colorPalette.length;
|
||||
const colorIndex = index % colors.length;
|
||||
const label = item.target;
|
||||
const result = [];
|
||||
|
||||
@ -49,8 +50,8 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof currentValue !== 'number') {
|
||||
continue;
|
||||
if (currentValue !== null && typeof currentValue !== 'number') {
|
||||
throw {message: 'Time series contains non number values'};
|
||||
}
|
||||
|
||||
// Due to missing values we could have different timeStep all along the series
|
||||
@ -150,7 +151,9 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
||||
return {
|
||||
data: result,
|
||||
label: label,
|
||||
color: colorPalette[colorIndex],
|
||||
color: colors[colorIndex],
|
||||
allIsZero,
|
||||
allIsNull,
|
||||
stats: {
|
||||
total,
|
||||
min,
|
||||
@ -164,8 +167,6 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
||||
range,
|
||||
count,
|
||||
first,
|
||||
allIsZero,
|
||||
allIsNull,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
81
packages/grafana-ui/src/utils/valueMappings.test.ts
Normal file
81
packages/grafana-ui/src/utils/valueMappings.test.ts
Normal 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');
|
||||
});
|
||||
});
|
89
packages/grafana-ui/src/utils/valueMappings.ts
Normal file
89
packages/grafana-ui/src/utils/valueMappings.ts
Normal 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];
|
||||
};
|
@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
|
||||
// not logged in views
|
||||
r.Get("/", reqSignedIn, hs.Index)
|
||||
r.Get("/logout", Logout)
|
||||
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
|
||||
r.Get("/login/:name", quota("session"), OAuthLogin)
|
||||
r.Get("/logout", hs.Logout)
|
||||
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost))
|
||||
r.Get("/login/:name", quota("session"), hs.OAuthLogin)
|
||||
r.Get("/login", hs.LoginView)
|
||||
r.Get("/invite/:code", hs.Index)
|
||||
|
||||
@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/signup", hs.Index)
|
||||
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/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
|
||||
r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2))
|
||||
|
||||
// invited
|
||||
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
|
||||
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))
|
||||
|
||||
// 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
|
||||
r.Group("/api", func(apiRoute routing.RouteRegister) {
|
||||
|
@ -5,7 +5,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-macaron/session"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
m *macaron.Macaron
|
||||
context *m.ReqContext
|
||||
resp *httptest.ResponseRecorder
|
||||
handlerFunc handlerFunc
|
||||
defaultHandler macaron.Handler
|
||||
req *http.Request
|
||||
url string
|
||||
m *macaron.Macaron
|
||||
context *m.ReqContext
|
||||
resp *httptest.ResponseRecorder
|
||||
handlerFunc handlerFunc
|
||||
defaultHandler macaron.Handler
|
||||
req *http.Request
|
||||
url string
|
||||
userAuthTokenService *fakeUserAuthTokenService
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) exec() {
|
||||
@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
|
||||
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
||||
}))
|
||||
|
||||
sc.m.Use(middleware.GetContextHandler())
|
||||
sc.m.Use(middleware.Sessioner(&session.Options{}, 0))
|
||||
sc.userAuthTokenService = newFakeUserAuthTokenService()
|
||||
sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
|
||||
|
||||
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) {}
|
||||
|
@ -336,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
|
||||
"id": 123123,
|
||||
"gridPos": map[string]interface{}{
|
||||
"x": 0,
|
||||
"y": 3,
|
||||
"y": 0,
|
||||
"w": 24,
|
||||
"h": 4,
|
||||
},
|
||||
|
@ -165,6 +165,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
|
||||
"externalUserMngInfo": setting.ExternalUserMngInfo,
|
||||
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
|
||||
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
|
||||
"viewersCanEdit": setting.ViewersCanEdit,
|
||||
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
|
||||
"buildInfo": map[string]interface{}{
|
||||
"version": setting.BuildVersion,
|
||||
"commit": setting.BuildCommit,
|
||||
|
@ -11,14 +11,8 @@ import (
|
||||
"path"
|
||||
"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/routing"
|
||||
httpstatic "github.com/grafana/grafana/pkg/api/static"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
@ -27,11 +21,16 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/cache"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/session"
|
||||
"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() {
|
||||
@ -49,13 +48,14 @@ type HTTPServer struct {
|
||||
streamManager *live.StreamManager
|
||||
httpSrv *http.Server
|
||||
|
||||
RouteRegister routing.RouteRegister `inject:""`
|
||||
Bus bus.Bus `inject:""`
|
||||
RenderService rendering.Service `inject:""`
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
HooksService *hooks.HooksService `inject:""`
|
||||
CacheService *cache.CacheService `inject:""`
|
||||
DatasourceCache datasources.CacheService `inject:""`
|
||||
RouteRegister routing.RouteRegister `inject:""`
|
||||
Bus bus.Bus `inject:""`
|
||||
RenderService rendering.Service `inject:""`
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
HooksService *hooks.HooksService `inject:""`
|
||||
CacheService *cache.CacheService `inject:""`
|
||||
DatasourceCache datasources.CacheService `inject:""`
|
||||
AuthTokenService auth.UserAuthTokenService `inject:""`
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) Init() error {
|
||||
@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
|
||||
hs.macaron = hs.newMacaron()
|
||||
hs.registerRoutes()
|
||||
|
||||
session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
|
||||
|
||||
m.Use(hs.healthHandler)
|
||||
m.Use(hs.metricsEndpoint)
|
||||
m.Use(middleware.GetContextHandler())
|
||||
m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
|
||||
m.Use(middleware.GetContextHandler(hs.AuthTokenService))
|
||||
m.Use(middleware.OrgRedirect())
|
||||
|
||||
// needs to be after context handler
|
||||
|
@ -140,7 +140,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
|
||||
Children: dashboardChildNavs,
|
||||
})
|
||||
|
||||
if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) {
|
||||
if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || setting.ViewersCanEdit) {
|
||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
Text: "Explore",
|
||||
Id: "explore",
|
||||
|
126
pkg/api/login.go
126
pkg/api/login.go
@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
@ -9,12 +11,13 @@ import (
|
||||
"github.com/grafana/grafana/pkg/login"
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
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/util"
|
||||
)
|
||||
|
||||
const (
|
||||
ViewIndex = "index"
|
||||
ViewIndex = "index"
|
||||
LoginErrorCookieName = "login_error"
|
||||
)
|
||||
|
||||
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["disableLoginForm"] = setting.DisableLoginForm
|
||||
|
||||
if loginError, ok := c.Session.Get("loginError").(string); ok {
|
||||
c.Session.Delete("loginError")
|
||||
if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
|
||||
deleteCookie(c, LoginErrorCookieName)
|
||||
viewData.Settings["loginError"] = loginError
|
||||
}
|
||||
|
||||
@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if !tryLoginUsingRememberCookie(c) {
|
||||
if !c.IsSignedIn {
|
||||
c.HTML(200, ViewIndex, viewData)
|
||||
return
|
||||
}
|
||||
@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func tryLoginUsingRememberCookie(c *m.ReqContext) bool {
|
||||
// Check auto-login.
|
||||
uname := c.GetCookie(setting.CookieUserName)
|
||||
if len(uname) == 0 {
|
||||
return false
|
||||
func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
|
||||
if c.IsSignedIn || c.IsAnonymous {
|
||||
return JSON(200, "Logged in")
|
||||
}
|
||||
|
||||
isSucceed := false
|
||||
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
|
||||
return Error(401, "Unauthorized", nil)
|
||||
}
|
||||
|
||||
func LoginAPIPing(c *m.ReqContext) {
|
||||
if !tryLoginUsingRememberCookie(c) {
|
||||
c.JsonApiErr(401, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
c.JsonOK("Logged in")
|
||||
}
|
||||
|
||||
func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
||||
func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
||||
if setting.DisableLoginForm {
|
||||
return Error(401, "Login is disabled", nil)
|
||||
}
|
||||
@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
||||
|
||||
user := authQuery.User
|
||||
|
||||
loginUserWithUser(user, c)
|
||||
hs.loginUserWithUser(user, c)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"message": "Logged in",
|
||||
@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
||||
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 {
|
||||
log.Error(3, "User login with nil user")
|
||||
hs.log.Error("User login with nil user")
|
||||
}
|
||||
|
||||
c.Resp.Header().Del("Set-Cookie")
|
||||
|
||||
days := 86400 * setting.LogInRememberDays
|
||||
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+"/")
|
||||
err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
|
||||
if err != nil {
|
||||
hs.log.Error("User auth hook failed", "error", err)
|
||||
}
|
||||
|
||||
c.Session.RegenerateId(c.Context)
|
||||
c.Session.Set(session.SESS_KEY_USERID, user.Id)
|
||||
}
|
||||
|
||||
func Logout(c *m.ReqContext) {
|
||||
c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
|
||||
c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
|
||||
c.Session.Destory(c.Context)
|
||||
func (hs *HTTPServer) Logout(c *m.ReqContext) {
|
||||
hs.AuthTokenService.UserSignedOutHook(c)
|
||||
|
||||
if setting.SignoutRedirectUrl != "" {
|
||||
c.Redirect(setting.SignoutRedirectUrl)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
@ -3,9 +3,11 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
@ -18,12 +20,14 @@ import (
|
||||
"github.com/grafana/grafana/pkg/login"
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
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/social"
|
||||
)
|
||||
|
||||
var oauthLogger = log.New("oauth")
|
||||
var (
|
||||
oauthLogger = log.New("oauth")
|
||||
OauthStateCookieName = "oauth_state"
|
||||
)
|
||||
|
||||
func GenStateString() string {
|
||||
rnd := make([]byte, 32)
|
||||
@ -31,7 +35,7 @@ func GenStateString() string {
|
||||
return base64.URLEncoding.EncodeToString(rnd)
|
||||
}
|
||||
|
||||
func OAuthLogin(ctx *m.ReqContext) {
|
||||
func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
|
||||
if setting.OAuthService == nil {
|
||||
ctx.Handle(404, "OAuth not enabled", nil)
|
||||
return
|
||||
@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) {
|
||||
if errorParam != "" {
|
||||
errorDesc := ctx.Query("error_description")
|
||||
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
|
||||
}
|
||||
|
||||
code := ctx.Query("code")
|
||||
if code == "" {
|
||||
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 == "" {
|
||||
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
|
||||
} else {
|
||||
@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) {
|
||||
return
|
||||
}
|
||||
|
||||
savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string)
|
||||
if !ok {
|
||||
cookieState := ctx.GetCookie(OauthStateCookieName)
|
||||
|
||||
// delete cookie
|
||||
ctx.Resp.Header().Del("Set-Cookie")
|
||||
hs.deleteCookie(ctx.Resp, OauthStateCookieName)
|
||||
|
||||
if cookieState == "" {
|
||||
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
|
||||
return
|
||||
}
|
||||
|
||||
queryState := ctx.Query("state")
|
||||
if savedState != queryState {
|
||||
queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret)
|
||||
oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState)
|
||||
if cookieState != queryState {
|
||||
ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
|
||||
return
|
||||
}
|
||||
@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) {
|
||||
userInfo, err := connect.UserInfo(client, token)
|
||||
if err != nil {
|
||||
if sErr, ok := err.(*social.Error); ok {
|
||||
redirectWithError(ctx, sErr)
|
||||
hs.redirectWithError(ctx, sErr)
|
||||
} else {
|
||||
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
|
||||
if userInfo.Email == "" {
|
||||
redirectWithError(ctx, login.ErrNoEmail)
|
||||
hs.redirectWithError(ctx, login.ErrNoEmail)
|
||||
return
|
||||
}
|
||||
|
||||
// validate that the email is allowed to login to grafana
|
||||
if !connect.IsEmailAllowed(userInfo.Email) {
|
||||
redirectWithError(ctx, login.ErrEmailNotAllowed)
|
||||
hs.redirectWithError(ctx, login.ErrEmailNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) {
|
||||
ExternalUser: extUser,
|
||||
SignupAllowed: connect.IsSignupAllowed(),
|
||||
}
|
||||
|
||||
err = bus.Dispatch(cmd)
|
||||
if err != nil {
|
||||
redirectWithError(ctx, err)
|
||||
hs.redirectWithError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
// login
|
||||
loginUserWithUser(cmd.Result, ctx)
|
||||
hs.loginUserWithUser(cmd.Result, ctx)
|
||||
|
||||
metrics.M_Api_Login_OAuth.Inc()
|
||||
|
||||
@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) {
|
||||
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.Session.Set("loginError", err.Error())
|
||||
hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60)
|
||||
|
||||
ctx.Redirect(setting.AppSubUrl + "/login")
|
||||
}
|
||||
|
@ -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}
|
||||
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res
|
||||
return rsp
|
||||
}
|
||||
|
||||
loginUserWithUser(user, c)
|
||||
hs.loginUserWithUser(user, c)
|
||||
|
||||
metrics.M_Api_User_SignUpCompleted.Inc()
|
||||
metrics.M_Api_User_SignUpInvite.Inc()
|
||||
|
@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response {
|
||||
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 {
|
||||
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"
|
||||
}
|
||||
|
||||
loginUserWithUser(user, c)
|
||||
hs.loginUserWithUser(user, c)
|
||||
metrics.M_Api_User_SignUpCompleted.Inc()
|
||||
|
||||
return JSON(200, apiResponse)
|
||||
|
@ -7,7 +7,6 @@ import (
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
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/util"
|
||||
)
|
||||
@ -17,16 +16,6 @@ type AuthOptions struct {
|
||||
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 {
|
||||
header := c.Req.Header.Get("Authorization")
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
|
@ -16,7 +16,9 @@ import (
|
||||
"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 {
|
||||
if !setting.AuthProxyEnabled {
|
||||
@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
|
||||
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}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
|
||||
return nil
|
||||
|
@ -3,15 +3,15 @@ package middleware
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/apikeygen"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/session"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
macaron "gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -21,12 +21,12 @@ var (
|
||||
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
|
||||
)
|
||||
|
||||
func GetContextHandler() macaron.Handler {
|
||||
func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
|
||||
return func(c *macaron.Context) {
|
||||
ctx := &m.ReqContext{
|
||||
Context: c,
|
||||
SignedInUser: &m.SignedInUser{},
|
||||
Session: session.GetSession(),
|
||||
Session: session.GetSession(), // should only be used by auth_proxy
|
||||
IsSignedIn: false,
|
||||
AllowAnonymous: false,
|
||||
SkipCache: false,
|
||||
@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler {
|
||||
case initContextWithApiKey(ctx):
|
||||
case initContextWithBasicAuth(ctx, orgId):
|
||||
case initContextWithAuthProxy(ctx, orgId):
|
||||
case initContextWithUserSessionCookie(ctx, orgId):
|
||||
case ats.InitContextWithToken(ctx, orgId):
|
||||
case initContextWithAnonymousUser(ctx):
|
||||
}
|
||||
|
||||
@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
|
||||
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 {
|
||||
var keyString string
|
||||
if keyString = getApiKey(ctx); keyString == "" {
|
||||
|
@ -7,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
ms "github.com/go-macaron/session"
|
||||
msession "github.com/go-macaron/session"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/session"
|
||||
@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
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) {
|
||||
sc.apiKey = "invalid_key_test"
|
||||
sc.fakeReq("GET", "/").exec()
|
||||
@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
middlewareScenario("UserId in session", 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.GetSignedInUserQuery) error {
|
||||
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
middlewareScenario("Auth token service", func(sc *scenarioContext) {
|
||||
var wasCalled bool
|
||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||
wasCalled = true
|
||||
return false
|
||||
}
|
||||
|
||||
sc.fakeReq("GET", "/").exec()
|
||||
|
||||
Convey("should init context with user info", func() {
|
||||
So(sc.context.IsSignedIn, ShouldBeTrue)
|
||||
So(sc.context.UserId, ShouldEqual, 12)
|
||||
Convey("should call middleware", func() {
|
||||
So(wasCalled, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
|
||||
@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
setting.SessionOptions = msession.Options{}
|
||||
sc.fakeReq("GET", "/")
|
||||
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
|
||||
sc.exec()
|
||||
@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
sc := &scenarioContext{}
|
||||
|
||||
viewsPath, _ := filepath.Abs("../../public/views")
|
||||
|
||||
sc.m = macaron.New()
|
||||
@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) {
|
||||
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
|
||||
session.StartSessionGC = func() {}
|
||||
sc.m.Use(Sessioner(&ms.Options{}, 0))
|
||||
setting.SessionOptions = msession.Options{}
|
||||
|
||||
sc.m.Use(OrgRedirect())
|
||||
sc.m.Use(AddDefaultResponseHeaders())
|
||||
|
||||
@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) {
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
m *macaron.Macaron
|
||||
context *m.ReqContext
|
||||
resp *httptest.ResponseRecorder
|
||||
apiKey string
|
||||
authHeader string
|
||||
respJson map[string]interface{}
|
||||
handlerFunc handlerFunc
|
||||
defaultHandler macaron.Handler
|
||||
url string
|
||||
m *macaron.Macaron
|
||||
context *m.ReqContext
|
||||
resp *httptest.ResponseRecorder
|
||||
apiKey string
|
||||
authHeader string
|
||||
respJson map[string]interface{}
|
||||
handlerFunc handlerFunc
|
||||
defaultHandler macaron.Handler
|
||||
url string
|
||||
userAuthTokenService *fakeUserAuthTokenService
|
||||
|
||||
req *http.Request
|
||||
}
|
||||
@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() {
|
||||
|
||||
type scenarioFunc func(c *scenarioContext)
|
||||
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) {}
|
||||
|
@ -9,7 +9,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
|
@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/session"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) {
|
||||
|
||||
Convey("Can redirect to correct org", t, func() {
|
||||
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
|
||||
sc.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 {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
||||
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||
return nil
|
||||
})
|
||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||
ctx.IsSignedIn = true
|
||||
return true
|
||||
}
|
||||
|
||||
sc.m.Get("/", sc.defaultHandler)
|
||||
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) {
|
||||
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 {
|
||||
return fmt.Errorf("")
|
||||
})
|
||||
|
||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||
ctx.IsSignedIn = true
|
||||
return true
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
||||
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||
return nil
|
||||
|
@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) {
|
||||
})
|
||||
|
||||
middlewareScenario("with user logged in", func(sc *scenarioContext) {
|
||||
// log us in, so we have a user_id and org_id in the context
|
||||
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
|
||||
c.Session.Set(session.SESS_KEY_USERID, int64(12))
|
||||
}).exec()
|
||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||
ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
|
||||
ctx.IsSignedIn = true
|
||||
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 {
|
||||
query.Result = &m.GlobalQuotaDTO{
|
||||
Target: query.Target,
|
||||
|
@ -4,13 +4,12 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
ms "github.com/go-macaron/session"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/session"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"gopkg.in/macaron.v1"
|
||||
macaron "gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
func TestRecoveryMiddleware(t *testing.T) {
|
||||
@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
|
||||
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
||||
}))
|
||||
|
||||
sc.m.Use(GetContextHandler())
|
||||
sc.userAuthTokenService = newFakeUserAuthTokenService()
|
||||
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
|
||||
// mock out gc goroutine
|
||||
session.StartSessionGC = func() {}
|
||||
sc.m.Use(Sessioner(&ms.Options{}, 0))
|
||||
sc.m.Use(OrgRedirect())
|
||||
sc.m.Use(AddDefaultResponseHeaders())
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -3,18 +3,18 @@ package models
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/services/session"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
type ReqContext struct {
|
||||
*macaron.Context
|
||||
*SignedInUser
|
||||
|
||||
// This should only be used by the auth_proxy
|
||||
Session session.SessionStore
|
||||
|
||||
IsSignedIn bool
|
||||
|
@ -105,8 +105,9 @@ func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
|
||||
var (
|
||||
unfinishedWorkTimeout = time.Second * 5
|
||||
// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
|
||||
alertTimeout = time.Second * 30
|
||||
alertMaxAttempts = 3
|
||||
alertTimeout = time.Second * 30
|
||||
resultHandleTimeout = time.Second * 30
|
||||
alertMaxAttempts = 3
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
// 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()
|
||||
e.resultHandler.Handle(evalContext)
|
||||
span.Finish()
|
||||
|
148
pkg/services/alerting/engine_integration_test.go
Normal file
148
pkg/services/alerting/engine_integration_test.go
Normal 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)
|
||||
}
|
266
pkg/services/auth/auth_token.go
Normal file
266
pkg/services/auth/auth_token.go
Normal 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[:])
|
||||
}
|
339
pkg/services/auth/auth_token_test.go
Normal file
339
pkg/services/auth/auth_token_test.go
Normal 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
|
||||
}
|
25
pkg/services/auth/model.go
Normal file
25
pkg/services/auth/model.go
Normal 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:"-"`
|
||||
}
|
38
pkg/services/auth/session_cleanup.go
Normal file
38
pkg/services/auth/session_cleanup.go
Normal 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
|
||||
}
|
36
pkg/services/auth/session_cleanup_test.go
Normal file
36
pkg/services/auth/session_cleanup_test.go
Normal 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)
|
||||
})
|
||||
}
|
@ -164,11 +164,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand,
|
||||
User: dto.User,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&alertCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return bus.Dispatch(&alertCmd)
|
||||
}
|
||||
|
||||
func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
|
||||
|
@ -14,8 +14,6 @@ import (
|
||||
|
||||
const (
|
||||
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"
|
||||
)
|
||||
|
||||
|
@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) {
|
||||
addLoginAttemptMigrations(mg)
|
||||
addUserAuthMigrations(mg)
|
||||
addServerlockMigrations(mg)
|
||||
addUserAuthTokenMigrations(mg)
|
||||
}
|
||||
|
||||
func addMigrationLogMigrations(mg *Migrator) {
|
||||
|
32
pkg/services/sqlstore/migrations/user_auth_token_mig.go
Normal file
32
pkg/services/sqlstore/migrations/user_auth_token_mig.go
Normal 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]))
|
||||
}
|
@ -18,7 +18,7 @@ import (
|
||||
"github.com/go-macaron/session"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"gopkg.in/ini.v1"
|
||||
ini "gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
type Scheme string
|
||||
@ -83,9 +83,6 @@ var (
|
||||
|
||||
// Security settings.
|
||||
SecretKey string
|
||||
LogInRememberDays int
|
||||
CookieUserName string
|
||||
CookieRememberName string
|
||||
DisableGravatar bool
|
||||
EmailCodeValidMinutes int
|
||||
DataProxyWhiteList map[string]bool
|
||||
@ -222,7 +219,15 @@ type Cfg struct {
|
||||
MetricsEndpointBasicAuthUsername string
|
||||
MetricsEndpointBasicAuthPassword string
|
||||
EnableAlphaPanels bool
|
||||
DisableSanitizeHtml bool
|
||||
EnterpriseLicensePath string
|
||||
|
||||
LoginCookieName string
|
||||
LoginCookieMaxDays int
|
||||
LoginCookieRotation int
|
||||
LoginDeleteExpiredTokensAfterDays int
|
||||
|
||||
SecurityHTTPSCookies bool
|
||||
}
|
||||
|
||||
type CommandLineArgs struct {
|
||||
@ -546,6 +551,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
ApplicationName = APP_NAME_ENTERPRISE
|
||||
}
|
||||
|
||||
//login
|
||||
login := iniFile.Section("login")
|
||||
cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
|
||||
cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
|
||||
cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
|
||||
cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
|
||||
if cfg.LoginCookieRotation < 2 {
|
||||
cfg.LoginCookieRotation = 2
|
||||
}
|
||||
|
||||
Env = iniFile.Section("").Key("app_mode").MustString("development")
|
||||
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
|
||||
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
|
||||
@ -586,11 +601,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
// read security settings
|
||||
security := iniFile.Section("security")
|
||||
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)
|
||||
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
|
||||
cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
|
||||
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
|
||||
|
||||
// read snapshots settings
|
||||
@ -709,6 +722,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
|
||||
panels := iniFile.Section("panels")
|
||||
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
|
||||
cfg.DisableSanitizeHtml = panels.Key("disable_sanitize_html").MustBool(false)
|
||||
|
||||
cfg.readSessionConfig()
|
||||
cfg.readSmtpSettings()
|
||||
|
@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
|
||||
|
||||
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
29
pkg/util/ip_address.go
Normal 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()
|
||||
}
|
16
pkg/util/ip_address_test.go
Normal file
16
pkg/util/ip_address_test.go
Normal 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")
|
||||
})
|
||||
}
|
@ -1,13 +1,17 @@
|
||||
import { LocationUpdate } from 'app/types';
|
||||
|
||||
export enum CoreActionTypes {
|
||||
UpdateLocation = 'UPDATE_LOCATION',
|
||||
}
|
||||
|
||||
export type Action = UpdateLocationAction;
|
||||
|
||||
export interface UpdateLocationAction {
|
||||
type: 'UPDATE_LOCATION';
|
||||
type: CoreActionTypes.UpdateLocation;
|
||||
payload: LocationUpdate;
|
||||
}
|
||||
|
||||
export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
|
||||
type: 'UPDATE_LOCATION',
|
||||
type: CoreActionTypes.UpdateLocation,
|
||||
payload: location,
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import Transition from 'react-transition-group/Transition';
|
||||
|
||||
interface Props {
|
||||
@ -8,7 +8,7 @@ interface Props {
|
||||
unmountOnExit?: boolean;
|
||||
}
|
||||
|
||||
export const FadeIn: SFC<Props> = props => {
|
||||
export const FadeIn: FC<Props> = props => {
|
||||
const defaultStyle = {
|
||||
transition: `opacity ${props.duration}ms linear`,
|
||||
opacity: 0,
|
||||
|
50
public/app/core/components/Footer/Footer.tsx
Normal file
50
public/app/core/components/Footer/Footer.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
buildVersion: string;
|
||||
buildCommit: string;
|
||||
newGrafanaVersionExists: boolean;
|
||||
newGrafanaVersion: string;
|
||||
}
|
||||
|
||||
export const Footer: FC<Props> = React.memo(({appName, buildVersion, buildCommit, newGrafanaVersionExists, newGrafanaVersion}) => {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="text-center">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="http://docs.grafana.org" target="_blank">
|
||||
<i className="fa fa-file-code-o" /> Docs
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://grafana.com/services/support" target="_blank">
|
||||
<i className="fa fa-support" /> Support Plans
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://community.grafana.com/" target="_blank">
|
||||
<i className="fa fa-comments-o" /> Community
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://grafana.com" target="_blank">{appName}</a> <span>v{buildVersion} (commit: {buildCommit})</span>
|
||||
</li>
|
||||
{newGrafanaVersionExists && (
|
||||
<li>
|
||||
<Tooltip placement="auto" content={newGrafanaVersion}>
|
||||
<a href="https://grafana.com/get" target="_blank">
|
||||
New version available!
|
||||
</a>
|
||||
</Tooltip>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
});
|
||||
|
||||
export default Footer;
|
@ -1,4 +1,4 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
|
||||
|
||||
@ -12,7 +12,7 @@ interface Props {
|
||||
onLayoutModeChanged: (mode: LayoutMode) => {};
|
||||
}
|
||||
|
||||
const LayoutSelector: SFC<Props> = props => {
|
||||
const LayoutSelector: FC<Props> = props => {
|
||||
const { mode, onLayoutModeChanged } = props;
|
||||
return (
|
||||
<div className="layout-selector">
|
||||
|
75
public/app/core/components/Page/Page.tsx
Normal file
75
public/app/core/components/Page/Page.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
// Libraries
|
||||
import React, { Component } from 'react';
|
||||
import config from 'app/core/config';
|
||||
import { NavModel } from 'app/types';
|
||||
import { getTitleFromNavModel } from 'app/core/selectors/navModel';
|
||||
|
||||
// Components
|
||||
import PageHeader from '../PageHeader/PageHeader';
|
||||
import Footer from '../Footer/Footer';
|
||||
import PageContents from './PageContents';
|
||||
import { CustomScrollbar } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
children: JSX.Element[] | JSX.Element;
|
||||
navModel: NavModel;
|
||||
}
|
||||
|
||||
class Page extends Component<Props> {
|
||||
private bodyClass = 'is-react';
|
||||
private body = document.body;
|
||||
static Header = PageHeader;
|
||||
static Contents = PageContents;
|
||||
|
||||
componentDidMount() {
|
||||
this.body.classList.add(this.bodyClass);
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.title !== this.props.title) {
|
||||
this.updateTitle();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.body.classList.remove(this.bodyClass);
|
||||
}
|
||||
|
||||
updateTitle = () => {
|
||||
const title = this.getPageTitle;
|
||||
document.title = title ? title + ' - Grafana' : 'Grafana';
|
||||
}
|
||||
|
||||
get getPageTitle () {
|
||||
const { navModel } = this.props;
|
||||
if (navModel) {
|
||||
return getTitleFromNavModel(navModel) || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { navModel } = this.props;
|
||||
const { buildInfo } = config;
|
||||
return (
|
||||
<div className="page-scrollbar-wrapper">
|
||||
<CustomScrollbar autoHeightMin={'100%'}>
|
||||
<div className="page-scrollbar-content">
|
||||
<PageHeader model={navModel} />
|
||||
{this.props.children}
|
||||
<Footer
|
||||
appName="Grafana"
|
||||
buildCommit={buildInfo.commit}
|
||||
buildVersion={buildInfo.version}
|
||||
newGrafanaVersion={buildInfo.latestVersion}
|
||||
newGrafanaVersionExists={buildInfo.hasUpdate} />
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Page;
|
26
public/app/core/components/Page/PageContents.tsx
Normal file
26
public/app/core/components/Page/PageContents.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
// Libraries
|
||||
import React, { Component } from 'react';
|
||||
|
||||
// Components
|
||||
import PageLoader from '../PageLoader/PageLoader';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
children: JSX.Element[] | JSX.Element;
|
||||
}
|
||||
|
||||
class PageContents extends Component<Props> {
|
||||
|
||||
render() {
|
||||
const { isLoading } = this.props;
|
||||
|
||||
return (
|
||||
<div className="page-container page-body">
|
||||
{isLoading && <PageLoader />}
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PageContents;
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { FormEvent } from 'react';
|
||||
import { NavModel, NavModelItem } from 'app/types';
|
||||
import classNames from 'classnames';
|
||||
import appEvents from 'app/core/app_events';
|
||||
@ -12,8 +12,8 @@ const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string
|
||||
return navItem.active === true;
|
||||
});
|
||||
|
||||
const gotoUrl = evt => {
|
||||
const element = evt.target;
|
||||
const gotoUrl = (evt: FormEvent) => {
|
||||
const element = evt.target as HTMLSelectElement;
|
||||
const url = element.options[element.selectedIndex].value;
|
||||
appEvents.emit('location-change', { href: url });
|
||||
};
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
pageName?: string;
|
||||
}
|
||||
|
||||
const PageLoader: SFC<Props> = ({ pageName }) => {
|
||||
const PageLoader: FC<Props> = ({ pageName }) => {
|
||||
const loadingText = `Loading ${pageName}...`;
|
||||
return (
|
||||
<div className="page-loader-wrapper">
|
||||
|
@ -6,7 +6,7 @@ import _ from 'lodash';
|
||||
import { Select } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { DataSourceSelectItem } from 'app/types';
|
||||
import { DataSourceSelectItem } from '@grafana/ui/src/types';
|
||||
|
||||
export interface Props {
|
||||
onChange: (ds: DataSourceSelectItem) => void;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { Select, Label } from '@grafana/ui';
|
||||
|
||||
import { FormLabel, Select } from '@grafana/ui';
|
||||
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { DashboardSearchHit } from 'app/types';
|
||||
@ -99,12 +99,12 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<Label
|
||||
<FormLabel
|
||||
width={11}
|
||||
tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
|
||||
>
|
||||
Home Dashboard
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
|
||||
getOptionValue={i => i.id}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { SFC, ReactNode, PureComponent } from 'react';
|
||||
import React, { FC, ReactNode, PureComponent } from 'react';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
interface ToggleButtonGroupProps {
|
||||
@ -29,7 +29,7 @@ interface ToggleButtonProps {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const ToggleButton: SFC<ToggleButtonProps> = ({
|
||||
export const ToggleButton: FC<ToggleButtonProps> = ({
|
||||
children,
|
||||
selected,
|
||||
className = '',
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
export interface Props {
|
||||
child: any;
|
||||
}
|
||||
|
||||
const DropDownChild: SFC<Props> = props => {
|
||||
const DropDownChild: FC<Props> = props => {
|
||||
const { child } = props;
|
||||
const listItemClassName = child.divider ? 'divider' : '';
|
||||
|
||||
|
@ -1,16 +1,18 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import DropDownChild from './DropDownChild';
|
||||
|
||||
interface Props {
|
||||
link: any;
|
||||
}
|
||||
|
||||
const SideMenuDropDown: SFC<Props> = props => {
|
||||
const SideMenuDropDown: FC<Props> = props => {
|
||||
const { link } = props;
|
||||
return (
|
||||
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<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>
|
||||
{link.children &&
|
||||
link.children.map((child, index) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
const SignIn: SFC<any> = () => {
|
||||
const SignIn: FC<any> = () => {
|
||||
const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`;
|
||||
return (
|
||||
<div className="sidemenu-item">
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import _ from 'lodash';
|
||||
import TopSectionItem from './TopSectionItem';
|
||||
import config from '../../config';
|
||||
|
||||
const TopSection: SFC<any> = () => {
|
||||
const TopSection: FC<any> = () => {
|
||||
const navTree = _.cloneDeep(config.bootData.navTree);
|
||||
const mainLinks = _.filter(navTree, item => !item.hideFromMenu);
|
||||
|
||||
|
@ -1,11 +1,11 @@
|
||||
import React, { SFC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import SideMenuDropDown from './SideMenuDropDown';
|
||||
|
||||
export interface Props {
|
||||
link: any;
|
||||
}
|
||||
|
||||
const TopSectionItem: SFC<Props> = props => {
|
||||
const TopSectionItem: FC<Props> = props => {
|
||||
const { link } = props;
|
||||
return (
|
||||
<div className="sidemenu-item dropdown">
|
||||
|
@ -8,11 +8,15 @@ exports[`Render should render children 1`] = `
|
||||
<li
|
||||
className="side-menu-header"
|
||||
>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
<a
|
||||
className="side-menu-header-link"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<DropDownChild
|
||||
child={
|
||||
@ -49,11 +53,15 @@ exports[`Render should render component 1`] = `
|
||||
<li
|
||||
className="side-menu-header"
|
||||
>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
<a
|
||||
className="side-menu-header-link"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<span
|
||||
className="sidemenu-item-text"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
|
@ -6,6 +6,8 @@ export interface BuildInfo {
|
||||
commit: string;
|
||||
isEnterprise: boolean;
|
||||
env: string;
|
||||
latestVersion: string;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
export class Settings {
|
||||
@ -32,8 +34,10 @@ export class Settings {
|
||||
disableUserSignUp: boolean;
|
||||
loginHint: any;
|
||||
loginError: any;
|
||||
viewersCanEdit: boolean;
|
||||
disableSanitizeHtml: boolean;
|
||||
|
||||
constructor(options) {
|
||||
constructor(options: Settings) {
|
||||
const defaults = {
|
||||
datasources: {},
|
||||
windowTitlePrefix: 'Grafana - ',
|
||||
@ -48,6 +52,8 @@ export class Settings {
|
||||
env: 'production',
|
||||
isEnterprise: false,
|
||||
},
|
||||
viewersCanEdit: false,
|
||||
disableSanitizeHtml: false
|
||||
};
|
||||
|
||||
_.extend(this, defaults, options);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user