mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'upstream/azure-monitor' into azure-monitor
This commit is contained in:
commit
15d42ba414
@ -19,7 +19,7 @@ version: 2
|
|||||||
jobs:
|
jobs:
|
||||||
mysql-integration-test:
|
mysql-integration-test:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/golang:1.11.4
|
- image: circleci/golang:1.11.5
|
||||||
- image: circleci/mysql:5.6-ram
|
- image: circleci/mysql:5.6-ram
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: rootpass
|
MYSQL_ROOT_PASSWORD: rootpass
|
||||||
@ -39,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
postgres-integration-test:
|
postgres-integration-test:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/golang:1.11.4
|
- image: circleci/golang:1.11.5
|
||||||
- image: circleci/postgres:9.3-ram
|
- image: circleci/postgres:9.3-ram
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: grafanatest
|
POSTGRES_USER: grafanatest
|
||||||
@ -74,27 +74,16 @@ jobs:
|
|||||||
|
|
||||||
gometalinter:
|
gometalinter:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/golang:1.11.4
|
- image: circleci/golang:1.11.5
|
||||||
environment:
|
environment:
|
||||||
# we need CGO because of go-sqlite3
|
# we need CGO because of go-sqlite3
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- 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:
|
- run:
|
||||||
name: run linters
|
name: Gometalinter tests
|
||||||
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 ./...'
|
command: './scripts/gometalinter.sh'
|
||||||
- run:
|
|
||||||
name: run go vet
|
|
||||||
command: 'go vet ./pkg/...'
|
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
docker:
|
docker:
|
||||||
@ -117,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/golang:1.11.4
|
- image: circleci/golang:1.11.5
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@ -127,7 +116,7 @@ jobs:
|
|||||||
|
|
||||||
build-all:
|
build-all:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/build-container:1.2.2
|
- image: grafana/build-container:1.2.3
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@ -158,9 +147,6 @@ jobs:
|
|||||||
- run:
|
- run:
|
||||||
name: sha-sum packages
|
name: sha-sum packages
|
||||||
command: 'go run build.go sha-dist'
|
command: 'go run build.go sha-dist'
|
||||||
- run:
|
|
||||||
name: Build Grafana.com master publisher
|
|
||||||
command: 'go build -o scripts/publish scripts/build/publish.go'
|
|
||||||
- run:
|
- run:
|
||||||
name: Test and build Grafana.com release publisher
|
name: Test and build Grafana.com release publisher
|
||||||
command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .'
|
command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .'
|
||||||
@ -169,13 +155,12 @@ jobs:
|
|||||||
paths:
|
paths:
|
||||||
- dist/grafana*
|
- dist/grafana*
|
||||||
- scripts/*.sh
|
- scripts/*.sh
|
||||||
- scripts/publish
|
|
||||||
- scripts/build/release_publisher/release_publisher
|
- scripts/build/release_publisher/release_publisher
|
||||||
- scripts/build/publish.sh
|
- scripts/build/publish.sh
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/build-container:1.2.2
|
- image: grafana/build-container:1.2.3
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@ -244,7 +229,7 @@ jobs:
|
|||||||
|
|
||||||
build-enterprise:
|
build-enterprise:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/build-container:1.2.2
|
- image: grafana/build-container:1.2.3
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@ -276,7 +261,7 @@ jobs:
|
|||||||
|
|
||||||
build-all-enterprise:
|
build-all-enterprise:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/build-container:1.2.2
|
- image: grafana/build-container:1.2.3
|
||||||
working_directory: /go/src/github.com/grafana/grafana
|
working_directory: /go/src/github.com/grafana/grafana
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@ -323,7 +308,7 @@ jobs:
|
|||||||
|
|
||||||
deploy-enterprise-master:
|
deploy-enterprise-master:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/grafana-ci-deploy:1.1.0
|
- image: grafana/grafana-ci-deploy:1.2.0
|
||||||
steps:
|
steps:
|
||||||
- attach_workspace:
|
- attach_workspace:
|
||||||
at: .
|
at: .
|
||||||
@ -346,7 +331,7 @@ jobs:
|
|||||||
|
|
||||||
deploy-enterprise-release:
|
deploy-enterprise-release:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/grafana-ci-deploy:1.1.0
|
- image: grafana/grafana-ci-deploy:1.2.0
|
||||||
steps:
|
steps:
|
||||||
- attach_workspace:
|
- attach_workspace:
|
||||||
at: .
|
at: .
|
||||||
@ -370,15 +355,15 @@ jobs:
|
|||||||
command: './scripts/build/load-signing-key.sh'
|
command: './scripts/build/load-signing-key.sh'
|
||||||
- run:
|
- run:
|
||||||
name: Update Debian repository
|
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:
|
- run:
|
||||||
name: Update RPM repository
|
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:
|
deploy-master:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/grafana-ci-deploy:1.1.0
|
- image: grafana/grafana-ci-deploy:1.2.0
|
||||||
steps:
|
steps:
|
||||||
- attach_workspace:
|
- attach_workspace:
|
||||||
at: .
|
at: .
|
||||||
@ -404,11 +389,12 @@ jobs:
|
|||||||
name: Publish to Grafana.com
|
name: Publish to Grafana.com
|
||||||
command: |
|
command: |
|
||||||
rm dist/grafana-master-$(echo "${CIRCLE_SHA1}" | cut -b1-7).linux-x64.tar.gz
|
rm dist/grafana-master-$(echo "${CIRCLE_SHA1}" | cut -b1-7).linux-x64.tar.gz
|
||||||
./scripts/publish -apiKey ${GRAFANA_COM_API_KEY}
|
rm dist/*latest*
|
||||||
|
cd dist && ../scripts/build/release_publisher/release_publisher -apikey ${GRAFANA_COM_API_KEY} -from-local
|
||||||
|
|
||||||
deploy-release:
|
deploy-release:
|
||||||
docker:
|
docker:
|
||||||
- image: grafana/grafana-ci-deploy:1.1.0
|
- image: grafana/grafana-ci-deploy:1.2.0
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- attach_workspace:
|
- attach_workspace:
|
||||||
@ -433,10 +419,10 @@ jobs:
|
|||||||
command: './scripts/build/load-signing-key.sh'
|
command: './scripts/build/load-signing-key.sh'
|
||||||
- run:
|
- run:
|
||||||
name: Update Debian repository
|
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:
|
- run:
|
||||||
name: Update RPM repository
|
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:
|
workflows:
|
||||||
version: 2
|
version: 2
|
||||||
|
29
CHANGELOG.md
29
CHANGELOG.md
@ -1,29 +1,46 @@
|
|||||||
# 5.5.0 (unreleased)
|
# 6.0.0-beta1 (unreleased)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
|
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
|
||||||
* **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
|
* **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
|
||||||
|
* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl)
|
||||||
* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
|
* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
|
||||||
|
|
||||||
### Minor
|
### Minor
|
||||||
|
|
||||||
|
* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
|
||||||
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
|
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
|
||||||
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
|
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
|
||||||
|
* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
|
||||||
|
* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
|
||||||
|
* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
|
||||||
|
* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
|
||||||
|
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
|
||||||
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
|
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
|
||||||
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
|
|
||||||
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
|
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
|
||||||
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK]req(https://github.com/IntegersOfK)
|
|
||||||
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
|
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
|
||||||
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
|
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
|
||||||
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
|
|
||||||
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
|
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
|
||||||
|
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
|
||||||
|
* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
|
||||||
|
* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
|
||||||
|
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
|
||||||
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
|
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
|
||||||
* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
|
* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
|
||||||
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
|
* **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484)
|
||||||
|
* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
|
||||||
|
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
|
||||||
|
* **Dataproxy**: Add global datasource proxy timeout setting [#5699](https://github.com/grafana/grafana/issues/5699), thx [@RangerRick](https://github.com/RangerRick)
|
||||||
|
* **Database**: Support specifying database host using IPV6 for backend database and sql datasources [#13711](https://github.com/grafana/grafana/issues/13711), thx [@ellisvlad](https://github.com/ellisvlad)
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
|
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
|
||||||
* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
|
* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
|
||||||
|
* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy)
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
|
||||||
|
* **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991)
|
||||||
|
|
||||||
# 5.4.3 (2019-01-14)
|
# 5.4.3 (2019-01-14)
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Our Pledge
|
## Our Pledge
|
||||||
|
|
||||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
## Our Standards
|
## Our Standards
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Golang build container
|
# Golang build container
|
||||||
FROM golang:1.11.4
|
FROM golang:1.11.5
|
||||||
|
|
||||||
WORKDIR $GOPATH/src/github.com/grafana/grafana
|
WORKDIR $GOPATH/src/github.com/grafana/grafana
|
||||||
|
|
||||||
@ -19,11 +19,13 @@ COPY package.json package.json
|
|||||||
RUN go run build.go build
|
RUN go run build.go build
|
||||||
|
|
||||||
# Node build container
|
# Node build container
|
||||||
FROM node:8
|
FROM node:10.14.2
|
||||||
|
|
||||||
WORKDIR /usr/src/app/
|
WORKDIR /usr/src/app/
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
|
COPY packages packages
|
||||||
|
|
||||||
RUN yarn install --pure-lockfile --no-progress
|
RUN yarn install --pure-lockfile --no-progress
|
||||||
|
|
||||||
COPY Gruntfile.js tsconfig.json tslint.json ./
|
COPY Gruntfile.js tsconfig.json tslint.json ./
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Plugin Development
|
# Plugin Development
|
||||||
|
|
||||||
This document is not meant as complete guide for developing plugins but more as a changelog for changes in
|
This document is not meant as a complete guide for developing plugins but more as a changelog for changes in
|
||||||
Grafana that can impact plugin development. When ever you as plugin author encounter an issue with your plugin after
|
Grafana that can impact plugin development. Whenever you as a plugin author encounter an issue with your plugin after
|
||||||
upgrading Grafana please check here before creating an issue.
|
upgrading Grafana please check here before creating an issue.
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
10
README.md
10
README.md
@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra
|
|||||||
Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
|
Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
|
||||||
|
|
||||||
## Run from master
|
## Run from master
|
||||||
If you want to build a package yourself, or contribute - Here is a guide for how to do that. You can always find
|
If you want to build a package yourself, or contribute - here is a guide for how to do that. You can always find
|
||||||
the latest master builds [here](https://grafana.com/grafana/download)
|
the latest master builds [here](https://grafana.com/grafana/download)
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
@ -71,7 +71,7 @@ Open grafana in your browser (default: `http://localhost:3000`) and login with a
|
|||||||
|
|
||||||
### Building a Docker image
|
### Building a Docker image
|
||||||
|
|
||||||
There are two different ways to build a Grafana docker image. If you're machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker.
|
There are two different ways to build a Grafana docker image. If your machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker.
|
||||||
|
|
||||||
Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev`
|
Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev`
|
||||||
|
|
||||||
@ -90,7 +90,7 @@ Choose this option to build on platforms other than linux/amd64 and/or not have
|
|||||||
|
|
||||||
The resulting image will be tagged as `grafana/grafana:dev`
|
The resulting image will be tagged as `grafana/grafana:dev`
|
||||||
|
|
||||||
Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Preferences -> Advanced), otherwize you may faild at `grunt build`
|
Notice: If you are using Docker for MacOS, be sure to set the memory limit to be larger than 2 GiB (at docker -> Preferences -> Advanced), otherwise `grunt build` may fail.
|
||||||
|
|
||||||
### Dev config
|
### Dev config
|
||||||
|
|
||||||
@ -129,8 +129,8 @@ GRAFANA_TEST_DB=postgres go test ./pkg/...
|
|||||||
|
|
||||||
## Contribute
|
## Contribute
|
||||||
|
|
||||||
If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
|
If you have any ideas for improvement or have found a bug, do not hesitate to open an issue.
|
||||||
And if you have time clone this repo and submit a pull request and help me make Grafana
|
And if you have time, clone this repo and submit a pull request to help me make Grafana
|
||||||
the kickass metrics & devops dashboard we all dream about!
|
the kickass metrics & devops dashboard we all dream about!
|
||||||
|
|
||||||
Read the [contributing](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guide then check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are easy and that we would like help with.
|
Read the [contributing](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guide then check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are easy and that we would like help with.
|
||||||
|
16
ROADMAP.md
16
ROADMAP.md
@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan.
|
|||||||
|
|
||||||
### Short term (1-2 months)
|
### Short term (1-2 months)
|
||||||
- PRs & Bugs
|
- PRs & Bugs
|
||||||
- Multi-Stat panel
|
- React Panel Support
|
||||||
|
- React Query Editor Support
|
||||||
- Metrics & Log Explore UI
|
- Metrics & Log Explore UI
|
||||||
|
- Grafana UI library shared between grafana & plugins
|
||||||
|
- Seperate visualization from panels
|
||||||
|
- More reuse between Explore & dashboard
|
||||||
|
- Explore logging support for more data sources
|
||||||
|
|
||||||
### Mid term (2-4 months)
|
### Mid term (2-4 months)
|
||||||
- React Panels
|
- Drilldown links
|
||||||
- Change visualization (panel type) on the fly.
|
- Dashboards as code workflows
|
||||||
- Templating Query Editor UI Plugin hook
|
- React migration
|
||||||
- Backend plugins
|
- New panels
|
||||||
|
|
||||||
### Long term (4 - 8 months)
|
### Long term (4 - 8 months)
|
||||||
- Alerting improvements (silence, per series tracking, etc)
|
- Alerting improvements (silence, per series tracking, etc)
|
||||||
- Progress on React migration
|
|
||||||
|
|
||||||
### In a distant future far far away
|
### In a distant future far far away
|
||||||
- Meta queries
|
- Meta queries
|
||||||
|
@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
|
|||||||
environment:
|
environment:
|
||||||
nodejs_version: "8"
|
nodejs_version: "8"
|
||||||
GOPATH: C:\gopath
|
GOPATH: C:\gopath
|
||||||
GOVERSION: 1.11.4
|
GOVERSION: 1.11.5
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- rmdir c:\go /s /q
|
- rmdir c:\go /s /q
|
||||||
|
12
build.go
12
build.go
@ -46,6 +46,8 @@ var (
|
|||||||
binaries []string = []string{"grafana-server", "grafana-cli"}
|
binaries []string = []string{"grafana-server", "grafana-cli"}
|
||||||
isDev bool = false
|
isDev bool = false
|
||||||
enterprise bool = false
|
enterprise bool = false
|
||||||
|
skipRpmGen bool = false
|
||||||
|
skipDebGen bool = false
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -67,6 +69,8 @@ func main() {
|
|||||||
flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
|
flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
|
||||||
flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
|
flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
|
||||||
flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
|
flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
|
||||||
|
flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)")
|
||||||
|
flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
buildId = shortenBuildId(buildIdRaw)
|
buildId = shortenBuildId(buildIdRaw)
|
||||||
@ -165,6 +169,7 @@ func makeLatestDistCopies() {
|
|||||||
".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm",
|
".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm",
|
||||||
".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
|
".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
|
||||||
".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
|
".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
|
||||||
|
".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz",
|
||||||
".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
|
".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,6 +244,8 @@ func createDebPackages() {
|
|||||||
previousPkgArch := pkgArch
|
previousPkgArch := pkgArch
|
||||||
if pkgArch == "armv7" {
|
if pkgArch == "armv7" {
|
||||||
pkgArch = "armhf"
|
pkgArch = "armhf"
|
||||||
|
} else if pkgArch == "armv6" {
|
||||||
|
pkgArch = "armel"
|
||||||
}
|
}
|
||||||
createPackage(linuxPackageOptions{
|
createPackage(linuxPackageOptions{
|
||||||
packageType: "deb",
|
packageType: "deb",
|
||||||
@ -289,9 +296,14 @@ func createRpmPackages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createLinuxPackages() {
|
func createLinuxPackages() {
|
||||||
|
if !skipDebGen {
|
||||||
createDebPackages()
|
createDebPackages()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !skipRpmGen {
|
||||||
createRpmPackages()
|
createRpmPackages()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createPackage(options linuxPackageOptions) {
|
func createPackage(options linuxPackageOptions) {
|
||||||
packageRoot, _ := ioutil.TempDir("", "grafana-linux-pack")
|
packageRoot, _ := ioutil.TempDir("", "grafana-linux-pack")
|
||||||
|
@ -106,6 +106,22 @@ path = grafana.db
|
|||||||
# For "sqlite3" only. cache mode setting used for connecting to the database
|
# For "sqlite3" only. cache mode setting used for connecting to the database
|
||||||
cache_mode = private
|
cache_mode = private
|
||||||
|
|
||||||
|
#################################### Login ###############################
|
||||||
|
|
||||||
|
[login]
|
||||||
|
|
||||||
|
# Login cookie name
|
||||||
|
cookie_name = grafana_session
|
||||||
|
|
||||||
|
# How many days an session can be unused before we inactivate it
|
||||||
|
login_remember_days = 7
|
||||||
|
|
||||||
|
# How often should the login token be rotated. default to '10m'
|
||||||
|
rotate_token_minutes = 10
|
||||||
|
|
||||||
|
# How long should Grafana keep expired tokens before deleting them
|
||||||
|
delete_expired_token_after_days = 30
|
||||||
|
|
||||||
#################################### Session #############################
|
#################################### Session #############################
|
||||||
[session]
|
[session]
|
||||||
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
|
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
|
||||||
@ -143,6 +159,9 @@ conn_max_lifetime = 14400
|
|||||||
# This enables data proxy logging, default is false
|
# This enables data proxy logging, default is false
|
||||||
logging = false
|
logging = false
|
||||||
|
|
||||||
|
# How long the data proxy should wait before timing out default is 30 (seconds)
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
#################################### Analytics ###########################
|
#################################### Analytics ###########################
|
||||||
[analytics]
|
[analytics]
|
||||||
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
|
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
|
||||||
@ -175,11 +194,6 @@ admin_password = admin
|
|||||||
# used for signing
|
# used for signing
|
||||||
secret_key = SW2YcwTIb9zpOOhoPsMm
|
secret_key = SW2YcwTIb9zpOOhoPsMm
|
||||||
|
|
||||||
# Auto-login remember days
|
|
||||||
login_remember_days = 7
|
|
||||||
cookie_username = grafana_user
|
|
||||||
cookie_remember_name = grafana_remember
|
|
||||||
|
|
||||||
# disable gravatar profile images
|
# disable gravatar profile images
|
||||||
disable_gravatar = false
|
disable_gravatar = false
|
||||||
|
|
||||||
@ -189,6 +203,9 @@ data_source_proxy_whitelist =
|
|||||||
# disable protection against brute force login attempts
|
# disable protection against brute force login attempts
|
||||||
disable_brute_force_login_protection = false
|
disable_brute_force_login_protection = false
|
||||||
|
|
||||||
|
# set cookies as https only. default is false
|
||||||
|
https_flag_cookies = false
|
||||||
|
|
||||||
#################################### Snapshots ###########################
|
#################################### Snapshots ###########################
|
||||||
[snapshots]
|
[snapshots]
|
||||||
# snapshot sharing options
|
# snapshot sharing options
|
||||||
@ -490,7 +507,7 @@ concurrent_render_limit = 5
|
|||||||
#################################### Explore #############################
|
#################################### Explore #############################
|
||||||
[explore]
|
[explore]
|
||||||
# Enable the Explore section
|
# Enable the Explore section
|
||||||
enabled = false
|
enabled = true
|
||||||
|
|
||||||
#################################### Internal Grafana Metrics ############
|
#################################### Internal Grafana Metrics ############
|
||||||
# Metrics available at HTTP API Url /metrics
|
# Metrics available at HTTP API Url /metrics
|
||||||
@ -570,6 +587,7 @@ callback_url =
|
|||||||
|
|
||||||
[panels]
|
[panels]
|
||||||
enable_alpha = false
|
enable_alpha = false
|
||||||
|
disable_sanitize_html = false
|
||||||
|
|
||||||
[enterprise]
|
[enterprise]
|
||||||
license_path =
|
license_path =
|
||||||
|
@ -102,6 +102,22 @@ log_queries =
|
|||||||
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
|
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
|
||||||
;cache_mode = private
|
;cache_mode = private
|
||||||
|
|
||||||
|
#################################### Login ###############################
|
||||||
|
|
||||||
|
[login]
|
||||||
|
|
||||||
|
# Login cookie name
|
||||||
|
;cookie_name = grafana_session
|
||||||
|
|
||||||
|
# How many days an session can be unused before we inactivate it
|
||||||
|
;login_remember_days = 7
|
||||||
|
|
||||||
|
# How often should the login token be rotated. default to '10'
|
||||||
|
;rotate_token_minutes = 10
|
||||||
|
|
||||||
|
# How long should Grafana keep expired tokens before deleting them
|
||||||
|
;delete_expired_token_after_days = 30
|
||||||
|
|
||||||
#################################### Session ####################################
|
#################################### Session ####################################
|
||||||
[session]
|
[session]
|
||||||
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
|
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
|
||||||
@ -130,6 +146,9 @@ log_queries =
|
|||||||
# This enables data proxy logging, default is false
|
# This enables data proxy logging, default is false
|
||||||
;logging = false
|
;logging = false
|
||||||
|
|
||||||
|
# How long the data proxy should wait before timing out default is 30 (seconds)
|
||||||
|
;timeout = 30
|
||||||
|
|
||||||
#################################### Analytics ####################################
|
#################################### Analytics ####################################
|
||||||
[analytics]
|
[analytics]
|
||||||
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
|
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
|
||||||
@ -162,11 +181,6 @@ log_queries =
|
|||||||
# used for signing
|
# used for signing
|
||||||
;secret_key = SW2YcwTIb9zpOOhoPsMm
|
;secret_key = SW2YcwTIb9zpOOhoPsMm
|
||||||
|
|
||||||
# Auto-login remember days
|
|
||||||
;login_remember_days = 7
|
|
||||||
;cookie_username = grafana_user
|
|
||||||
;cookie_remember_name = grafana_remember
|
|
||||||
|
|
||||||
# disable gravatar profile images
|
# disable gravatar profile images
|
||||||
;disable_gravatar = false
|
;disable_gravatar = false
|
||||||
|
|
||||||
@ -176,6 +190,9 @@ log_queries =
|
|||||||
# disable protection against brute force login attempts
|
# disable protection against brute force login attempts
|
||||||
;disable_brute_force_login_protection = false
|
;disable_brute_force_login_protection = false
|
||||||
|
|
||||||
|
# set cookies as https only. default is false
|
||||||
|
;https_flag_cookies = false
|
||||||
|
|
||||||
#################################### Snapshots ###########################
|
#################################### Snapshots ###########################
|
||||||
[snapshots]
|
[snapshots]
|
||||||
# snapshot sharing options
|
# snapshot sharing options
|
||||||
@ -415,7 +432,7 @@ log_queries =
|
|||||||
#################################### Explore #############################
|
#################################### Explore #############################
|
||||||
[explore]
|
[explore]
|
||||||
# Enable the Explore section
|
# Enable the Explore section
|
||||||
;enabled = false
|
;enabled = true
|
||||||
|
|
||||||
#################################### Internal Grafana Metrics ##########################
|
#################################### Internal Grafana Metrics ##########################
|
||||||
# Metrics available at HTTP API Url /metrics
|
# Metrics available at HTTP API Url /metrics
|
||||||
@ -495,3 +512,8 @@ log_queries =
|
|||||||
# Path to a valid Grafana Enterprise license.jwt file
|
# Path to a valid Grafana Enterprise license.jwt file
|
||||||
;license_path =
|
;license_path =
|
||||||
|
|
||||||
|
[panels]
|
||||||
|
;enable_alpha = false
|
||||||
|
# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
|
||||||
|
;disable_sanitize_html = false
|
||||||
|
|
||||||
|
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_DATABASE_SSL_MODE=disable
|
||||||
# - GF_SESSION_PROVIDER=postgres
|
# - GF_SESSION_PROVIDER=postgres
|
||||||
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
|
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
|
||||||
- GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
|
- GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
|
||||||
|
- GF_LOGIN_ROTATE_TOKEN_MINUTES=2
|
||||||
ports:
|
ports:
|
||||||
- 3000
|
- 3000
|
||||||
depends_on:
|
depends_on:
|
||||||
|
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
|
```bash
|
||||||
[auth.gitlab]
|
[auth.gitlab]
|
||||||
enabled = false
|
enabled = true
|
||||||
allow_sign_up = false
|
allow_sign_up = false
|
||||||
client_id = GITLAB_APPLICATION_ID
|
client_id = GITLAB_APPLICATION_ID
|
||||||
client_secret = GITLAB_SECRET
|
client_secret = GITLAB_SECRET
|
||||||
|
@ -38,7 +38,7 @@ Name | Description
|
|||||||
|
|
||||||
### IAM Roles
|
### 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.
|
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)
|
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"
|
"defaultRegion": "us-west-1"
|
||||||
},
|
},
|
||||||
"secureJsonData": {
|
"secureJsonData": {
|
||||||
"accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded
|
"accessKey": "Ol4pIDpeKSA6XikgOl4p",
|
||||||
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded
|
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -83,3 +83,28 @@ Content-Type: application/json
|
|||||||
|
|
||||||
{"message": "Logged in"}
|
{"message": "Logged in"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# Health API
|
||||||
|
|
||||||
|
## Returns health information about Grafana
|
||||||
|
|
||||||
|
`GET /api/health`
|
||||||
|
|
||||||
|
**Example Request**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/health
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response**:
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
|
||||||
|
{
|
||||||
|
"commit": "087143285",
|
||||||
|
"database": "ok",
|
||||||
|
"version": "5.1.3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
@ -391,6 +391,12 @@ value is `true`.
|
|||||||
If you want to track Grafana usage via Google analytics specify *your* Universal
|
If you want to track Grafana usage via Google analytics specify *your* Universal
|
||||||
Analytics ID here. By default this feature is disabled.
|
Analytics ID here. By default this feature is disabled.
|
||||||
|
|
||||||
|
### check_for_updates
|
||||||
|
|
||||||
|
Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used
|
||||||
|
in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor
|
||||||
|
send any sensitive information.
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
## [dashboards]
|
## [dashboards]
|
||||||
@ -589,3 +595,14 @@ Default setting for how Grafana handles nodata or null values in alerting. (aler
|
|||||||
Alert notifications can include images, but rendering many images at the same time can overload the server.
|
Alert notifications can include images, but rendering many images at the same time can overload the server.
|
||||||
This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
|
This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
|
||||||
value is `5`.
|
value is `5`.
|
||||||
|
|
||||||
|
## [panels]
|
||||||
|
|
||||||
|
### enable_alpha
|
||||||
|
Set to true if you want to test panels that are not yet ready for general usage.
|
||||||
|
|
||||||
|
### disable_sanitize_html
|
||||||
|
If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. Default
|
||||||
|
is false. This settings was introduced in Grafana v6.0.
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,6 +52,7 @@ Filter Option | Example | Raw | Interpolated | Description
|
|||||||
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
|
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
|
||||||
`distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
|
`distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
|
||||||
`lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
|
`lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
|
||||||
|
`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
|
||||||
|
|
||||||
Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1).
|
Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1).
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
"company": "Grafana Labs"
|
"company": "Grafana Labs"
|
||||||
},
|
},
|
||||||
"name": "grafana",
|
"name": "grafana",
|
||||||
"version": "5.5.0-pre1",
|
"version": "6.0.0-pre1",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "http://github.com/grafana/grafana.git"
|
"url": "http://github.com/grafana/grafana.git"
|
||||||
@ -190,7 +190,8 @@
|
|||||||
"slate-react": "^0.12.4",
|
"slate-react": "^0.12.4",
|
||||||
"tether": "^1.4.0",
|
"tether": "^1.4.0",
|
||||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||||
"tinycolor2": "^1.4.1"
|
"tinycolor2": "^1.4.1",
|
||||||
|
"xss": "^1.0.3"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"caniuse-db": "1.0.30000772",
|
"caniuse-db": "1.0.30000772",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
import Scrollbars from 'react-custom-scrollbars';
|
import Scrollbars from 'react-custom-scrollbars';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -6,8 +7,11 @@ interface Props {
|
|||||||
autoHide?: boolean;
|
autoHide?: boolean;
|
||||||
autoHideTimeout?: number;
|
autoHideTimeout?: number;
|
||||||
autoHideDuration?: number;
|
autoHideDuration?: number;
|
||||||
autoMaxHeight?: string;
|
autoHeightMax?: string;
|
||||||
hideTracksWhenNotNeeded?: boolean;
|
hideTracksWhenNotNeeded?: boolean;
|
||||||
|
scrollTop?: number;
|
||||||
|
setScrollTop: (event: any) => void;
|
||||||
|
autoHeightMin?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,24 +20,67 @@ interface Props {
|
|||||||
export class CustomScrollbar extends PureComponent<Props> {
|
export class CustomScrollbar extends PureComponent<Props> {
|
||||||
static defaultProps: Partial<Props> = {
|
static defaultProps: Partial<Props> = {
|
||||||
customClassName: 'custom-scrollbars',
|
customClassName: 'custom-scrollbars',
|
||||||
autoHide: true,
|
autoHide: false,
|
||||||
autoHideTimeout: 200,
|
autoHideTimeout: 200,
|
||||||
autoHideDuration: 200,
|
autoHideDuration: 200,
|
||||||
autoMaxHeight: '100%',
|
setScrollTop: () => {},
|
||||||
hideTracksWhenNotNeeded: false,
|
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() {
|
render() {
|
||||||
const { customClassName, children, autoMaxHeight } = this.props;
|
const {
|
||||||
|
customClassName,
|
||||||
|
children,
|
||||||
|
autoHeightMax,
|
||||||
|
autoHeightMin,
|
||||||
|
setScrollTop,
|
||||||
|
autoHide,
|
||||||
|
autoHideTimeout,
|
||||||
|
hideTracksWhenNotNeeded,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scrollbars
|
<Scrollbars
|
||||||
|
ref={this.ref}
|
||||||
className={customClassName}
|
className={customClassName}
|
||||||
|
onScroll={setScrollTop}
|
||||||
autoHeight={true}
|
autoHeight={true}
|
||||||
|
autoHide={autoHide}
|
||||||
|
autoHideTimeout={autoHideTimeout}
|
||||||
|
hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
|
||||||
// These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
|
// These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
|
||||||
// Before these where set to inhert but that caused problems with cut of legends in firefox
|
// Before these where set to inhert but that caused problems with cut of legends in firefox
|
||||||
autoHeightMin={'0'}
|
autoHeightMax={autoHeightMax}
|
||||||
autoHeightMax={autoMaxHeight}
|
autoHeightMin={autoHeightMin}
|
||||||
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
|
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
|
||||||
renderTrackVertical={props => <div {...props} className="track-vertical" />}
|
renderTrackVertical={props => <div {...props} className="track-vertical" />}
|
||||||
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
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 React, { PureComponent } from 'react';
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import { BasicGaugeColor, Threshold, TimeSeriesVMs, MappingType, ValueMapping } from '@grafana/ui';
|
|
||||||
|
|
||||||
import config from '../core/config';
|
import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
|
||||||
import kbn from '../core/utils/kbn';
|
import { TimeSeriesVMs } from '../../types/series';
|
||||||
|
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
|
||||||
|
import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
baseColor: string;
|
|
||||||
decimals: number;
|
decimals: number;
|
||||||
height: number;
|
height: number;
|
||||||
valueMappings: ValueMapping[];
|
valueMappings: ValueMapping[];
|
||||||
@ -21,13 +21,13 @@ export interface Props {
|
|||||||
suffix: string;
|
suffix: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
width: number;
|
width: number;
|
||||||
|
theme?: ThemeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Gauge extends PureComponent<Props> {
|
export class Gauge extends PureComponent<Props> {
|
||||||
canvasElement: any;
|
canvasElement: any;
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
baseColor: BasicGaugeColor.Green,
|
|
||||||
maxValue: 100,
|
maxValue: 100,
|
||||||
valueMappings: [],
|
valueMappings: [],
|
||||||
minValue: 0,
|
minValue: 0,
|
||||||
@ -38,6 +38,7 @@ export class Gauge extends PureComponent<Props> {
|
|||||||
thresholds: [],
|
thresholds: [],
|
||||||
unit: 'none',
|
unit: 'none',
|
||||||
stat: 'avg',
|
stat: 'avg',
|
||||||
|
theme: ThemeNames.Dark,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@ -48,88 +49,93 @@ export class Gauge extends PureComponent<Props> {
|
|||||||
this.draw();
|
this.draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
formatWithMappings(mappings, value) {
|
formatValue(value: TimeSeriesValue) {
|
||||||
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) {
|
|
||||||
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
|
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
|
||||||
|
|
||||||
const formatFunc = kbn.valueFormats[unit];
|
if (isNaN(value as number)) {
|
||||||
const formattedValue = formatFunc(value, decimals);
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
if (valueMappings.length > 0) {
|
if (valueMappings.length > 0) {
|
||||||
const { rangeMap, valueMap } = this.formatWithMappings(valueMappings, formattedValue);
|
const valueMappedValue = getMappedValue(valueMappings, value);
|
||||||
|
if (valueMappedValue) {
|
||||||
if (valueMap) {
|
return `${prefix} ${valueMappedValue.text} ${suffix}`;
|
||||||
return `${prefix} ${valueMap} ${suffix}`;
|
|
||||||
} else if (rangeMap) {
|
|
||||||
return `${prefix} ${rangeMap} ${suffix}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(value)) {
|
const formatFunc = getValueFormat(unit);
|
||||||
return '-';
|
const formattedValue = formatFunc(value as number, decimals);
|
||||||
|
const handleNoValueValue = formattedValue || 'no value';
|
||||||
|
|
||||||
|
return `${prefix} ${handleNoValueValue} ${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${prefix} ${formattedValue} ${suffix}`;
|
getFontColor(value: TimeSeriesValue) {
|
||||||
|
const { thresholds } = this.props;
|
||||||
|
|
||||||
|
if (thresholds.length === 1) {
|
||||||
|
return thresholds[0].color;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFontColor(value) {
|
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||||
const { baseColor, maxValue, thresholds } = this.props;
|
if (atThreshold) {
|
||||||
|
return atThreshold.color;
|
||||||
|
}
|
||||||
|
|
||||||
if (thresholds.length > 0) {
|
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||||
const atThreshold = thresholds.filter(threshold => value <= threshold.value);
|
|
||||||
|
if (belowThreshold.length > 0) {
|
||||||
|
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
|
||||||
|
return nearestThreshold.color;
|
||||||
|
}
|
||||||
|
|
||||||
if (atThreshold.length > 0) {
|
|
||||||
return atThreshold[0].color;
|
|
||||||
} else if (value <= maxValue) {
|
|
||||||
return BasicGaugeColor.Red;
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseColor;
|
const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
|
||||||
|
return { value: threshold.value, color: previousThreshold.color };
|
||||||
|
}),
|
||||||
|
{ value: maxValue, color: lastThreshold.color },
|
||||||
|
];
|
||||||
|
|
||||||
|
return formattedThresholds;
|
||||||
}
|
}
|
||||||
|
|
||||||
draw() {
|
draw() {
|
||||||
const {
|
const {
|
||||||
baseColor,
|
|
||||||
maxValue,
|
maxValue,
|
||||||
minValue,
|
minValue,
|
||||||
timeSeries,
|
timeSeries,
|
||||||
showThresholdLabels,
|
showThresholdLabels,
|
||||||
showThresholdMarkers,
|
showThresholdMarkers,
|
||||||
thresholds,
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
stat,
|
stat,
|
||||||
|
theme,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
let value: string | number = '';
|
let value: TimeSeriesValue = '';
|
||||||
|
|
||||||
if (timeSeries[0]) {
|
if (timeSeries[0]) {
|
||||||
value = timeSeries[0].stats[stat];
|
value = timeSeries[0].stats[stat];
|
||||||
} else {
|
} else {
|
||||||
value = 'N/A';
|
value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimension = Math.min(width, height * 1.3);
|
const dimension = Math.min(width, height * 1.3);
|
||||||
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 fontScale = parseInt('80', 10) / 100;
|
||||||
const fontSize = Math.min(dimension / 5, 100) * fontScale;
|
const fontSize = Math.min(dimension / 5, 100) * fontScale;
|
||||||
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
|
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
|
||||||
@ -137,17 +143,6 @@ export class Gauge extends PureComponent<Props> {
|
|||||||
const thresholdMarkersWidth = gaugeWidth / 5;
|
const thresholdMarkersWidth = gaugeWidth / 5;
|
||||||
const thresholdLabelFontSize = fontSize / 2.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 = {
|
const options = {
|
||||||
series: {
|
series: {
|
||||||
gauges: {
|
gauges: {
|
||||||
@ -164,7 +159,7 @@ export class Gauge extends PureComponent<Props> {
|
|||||||
layout: { margin: 0, thresholdWidth: 0 },
|
layout: { margin: 0, thresholdWidth: 0 },
|
||||||
cell: { border: { width: 0 } },
|
cell: { border: { width: 0 } },
|
||||||
threshold: {
|
threshold: {
|
||||||
values: formattedThresholds,
|
values: this.getFormattedThresholds(),
|
||||||
label: {
|
label: {
|
||||||
show: showThresholdLabels,
|
show: showThresholdLabels,
|
||||||
margin: thresholdMarkersWidth + 1,
|
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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -16,7 +16,7 @@ import SelectOptionGroup from './SelectOptionGroup';
|
|||||||
import IndicatorsContainer from './IndicatorsContainer';
|
import IndicatorsContainer from './IndicatorsContainer';
|
||||||
import NoOptionsMessage from './NoOptionsMessage';
|
import NoOptionsMessage from './NoOptionsMessage';
|
||||||
import resetSelectStyles from './resetSelectStyles';
|
import resetSelectStyles from './resetSelectStyles';
|
||||||
import { CustomScrollbar } from '@grafana/ui';
|
import { CustomScrollbar } from '..';
|
||||||
|
|
||||||
export interface SelectOptionItem {
|
export interface SelectOptionItem {
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -61,7 +61,7 @@ interface AsyncProps {
|
|||||||
export const MenuList = (props: any) => {
|
export const MenuList = (props: any) => {
|
||||||
return (
|
return (
|
||||||
<components.MenuList {...props}>
|
<components.MenuList {...props}>
|
||||||
<CustomScrollbar autoHide={false} autoMaxHeight="inherit">{props.children}</CustomScrollbar>
|
<CustomScrollbar autoHide={false} autoHeightMax="inherit">{props.children}</CustomScrollbar>
|
||||||
</components.MenuList>
|
</components.MenuList>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -202,7 +202,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
|
|||||||
classNamePrefix="gf-form-select-box"
|
classNamePrefix="gf-form-select-box"
|
||||||
className={selectClassNames}
|
className={selectClassNames}
|
||||||
components={{
|
components={{
|
||||||
Option,
|
Option: SelectOption,
|
||||||
SingleValue,
|
SingleValue,
|
||||||
IndicatorsContainer,
|
IndicatorsContainer,
|
||||||
NoOptionsMessage,
|
NoOptionsMessage,
|
||||||
|
@ -19,9 +19,15 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
|||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
const thresholds: Threshold[] =
|
const addDefaultThreshold = this.props.thresholds.length === 0;
|
||||||
props.thresholds.length > 0 ? props.thresholds : [{ index: 0, value: -Infinity, color: colors[0] }];
|
const thresholds: Threshold[] = addDefaultThreshold
|
||||||
|
? [{ index: 0, value: -Infinity, color: colors[0] }]
|
||||||
|
: props.thresholds;
|
||||||
this.state = { thresholds };
|
this.state = { thresholds };
|
||||||
|
|
||||||
|
if (addDefaultThreshold) {
|
||||||
|
this.onChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddThreshold = (index: number) => {
|
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),
|
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 value = isNaN(parsedValue) ? null : parsedValue;
|
||||||
|
|
||||||
const newThresholds = thresholds.map(t => {
|
const newThresholds = thresholds.map(t => {
|
||||||
if (t === threshold) {
|
if (t === threshold && t.index !== 0) {
|
||||||
t = { ...t, value: value as number };
|
t = { ...t, value: value as number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,11 +130,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
|||||||
{
|
{
|
||||||
thresholds: newThresholds,
|
thresholds: newThresholds,
|
||||||
},
|
},
|
||||||
() => this.updateGauge()
|
() => this.onChange()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
|
|
||||||
onBlur = () => {
|
onBlur = () => {
|
||||||
this.setState(prevState => {
|
this.setState(prevState => {
|
||||||
const sortThresholds = this.sortThresholds([...prevState.thresholds]);
|
const sortThresholds = this.sortThresholds([...prevState.thresholds]);
|
||||||
@ -139,10 +144,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
|||||||
return { thresholds: sortThresholds };
|
return { thresholds: sortThresholds };
|
||||||
});
|
});
|
||||||
|
|
||||||
this.updateGauge();
|
this.onChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
updateGauge = () => {
|
onChange = () => {
|
||||||
this.props.onChange(this.state.thresholds);
|
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 { MappingType, ValueMapping } from '../../types';
|
||||||
import { Label } from '../Label/Label';
|
import { FormField, FormLabel, Select } from '..';
|
||||||
import { Select } from '../Select/Select';
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
valueMapping: ValueMapping;
|
valueMapping: ValueMapping;
|
||||||
@ -32,19 +31,19 @@ export default class MappingRow extends PureComponent<Props, State> {
|
|||||||
this.state = { ...props.valueMapping };
|
this.state = { ...props.valueMapping };
|
||||||
}
|
}
|
||||||
|
|
||||||
onMappingValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
onMappingValueChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
this.setState({ value: event.target.value });
|
this.setState({ value: event.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMappingFromChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
onMappingFromChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
this.setState({ from: event.target.value });
|
this.setState({ from: event.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMappingToChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
onMappingToChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
this.setState({ to: event.target.value });
|
this.setState({ to: event.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMappingTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
onMappingTextChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
this.setState({ text: event.target.value });
|
this.setState({ text: event.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,30 +61,28 @@ export default class MappingRow extends PureComponent<Props, State> {
|
|||||||
if (type === MappingType.RangeToText) {
|
if (type === MappingType.RangeToText) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="gf-form">
|
<FormField
|
||||||
<Label width={4}>From</Label>
|
label="From"
|
||||||
<input
|
labelWidth={4}
|
||||||
className="gf-form-input width-8"
|
inputWidth={8}
|
||||||
value={from}
|
|
||||||
onBlur={this.updateMapping}
|
onBlur={this.updateMapping}
|
||||||
onChange={this.onMappingFromChange}
|
onChange={this.onMappingFromChange}
|
||||||
|
value={from}
|
||||||
/>
|
/>
|
||||||
</div>
|
<FormField
|
||||||
<div className="gf-form">
|
label="To"
|
||||||
<Label width={4}>To</Label>
|
labelWidth={4}
|
||||||
<input
|
inputWidth={8}
|
||||||
className="gf-form-input width-8"
|
|
||||||
value={to}
|
|
||||||
onBlur={this.updateMapping}
|
onBlur={this.updateMapping}
|
||||||
onChange={this.onMappingToChange}
|
onChange={this.onMappingToChange}
|
||||||
|
value={to}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="gf-form gf-form--grow">
|
||||||
<div className="gf-form">
|
<FormLabel width={4}>Text</FormLabel>
|
||||||
<Label width={4}>Text</Label>
|
|
||||||
<input
|
<input
|
||||||
className="gf-form-input width-10"
|
className="gf-form-input"
|
||||||
value={text}
|
|
||||||
onBlur={this.updateMapping}
|
onBlur={this.updateMapping}
|
||||||
|
value={text}
|
||||||
onChange={this.onMappingTextChange}
|
onChange={this.onMappingTextChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -95,17 +92,16 @@ export default class MappingRow extends PureComponent<Props, State> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="gf-form">
|
<FormField
|
||||||
<Label width={4}>Value</Label>
|
label="Value"
|
||||||
<input
|
labelWidth={4}
|
||||||
className="gf-form-input width-8"
|
|
||||||
onBlur={this.updateMapping}
|
onBlur={this.updateMapping}
|
||||||
onChange={this.onMappingValueChange}
|
onChange={this.onMappingValueChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
inputWidth={8}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="gf-form gf-form--grow">
|
<div className="gf-form gf-form--grow">
|
||||||
<Label width={4}>Text</Label>
|
<FormLabel width={4}>Text</FormLabel>
|
||||||
<input
|
<input
|
||||||
className="gf-form-input"
|
className="gf-form-input"
|
||||||
onBlur={this.updateMapping}
|
onBlur={this.updateMapping}
|
||||||
@ -123,7 +119,7 @@ export default class MappingRow extends PureComponent<Props, State> {
|
|||||||
return (
|
return (
|
||||||
<div className="gf-form-inline">
|
<div className="gf-form-inline">
|
||||||
<div className="gf-form">
|
<div className="gf-form">
|
||||||
<Label width={5}>Type</Label>
|
<FormLabel width={5}>Type</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Choose type"
|
placeholder="Choose type"
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
|
@ -7,3 +7,4 @@
|
|||||||
@import 'PanelOptionsGrid/PanelOptionsGrid';
|
@import 'PanelOptionsGrid/PanelOptionsGrid';
|
||||||
@import 'ColorPicker/ColorPicker';
|
@import 'ColorPicker/ColorPicker';
|
||||||
@import 'ValueMappingsEditor/ValueMappingsEditor';
|
@import 'ValueMappingsEditor/ValueMappingsEditor';
|
||||||
|
@import "FormField/FormField";
|
||||||
|
@ -2,7 +2,6 @@ export { DeleteButton } from './DeleteButton/DeleteButton';
|
|||||||
export { Tooltip } from './Tooltip/Tooltip';
|
export { Tooltip } from './Tooltip/Tooltip';
|
||||||
export { Portal } from './Portal/Portal';
|
export { Portal } from './Portal/Portal';
|
||||||
export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
|
export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
|
||||||
export { Label } from './Label/Label';
|
|
||||||
|
|
||||||
// Select
|
// Select
|
||||||
export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
|
export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
|
||||||
@ -10,13 +9,17 @@ export { IndicatorsContainer } from './Select/IndicatorsContainer';
|
|||||||
export { NoOptionsMessage } from './Select/NoOptionsMessage';
|
export { NoOptionsMessage } from './Select/NoOptionsMessage';
|
||||||
export { default as resetSelectStyles } from './Select/resetSelectStyles';
|
export { default as resetSelectStyles } from './Select/resetSelectStyles';
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
export { FormLabel } from './FormLabel/FormLabel';
|
||||||
|
export { FormField } from './FormField/FormField';
|
||||||
|
|
||||||
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||||
export { ColorPicker } from './ColorPicker/ColorPicker';
|
export { ColorPicker } from './ColorPicker/ColorPicker';
|
||||||
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
|
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
|
||||||
export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
|
export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
|
||||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||||
export { GfFormLabel } from './GfFormLabel/GfFormLabel';
|
|
||||||
export { Graph } from './Graph/Graph';
|
export { Graph } from './Graph/Graph';
|
||||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||||
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
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 './series';
|
||||||
export * from './time';
|
export * from './time';
|
||||||
export * from './panel';
|
export * from './panel';
|
||||||
|
export * from './plugin';
|
||||||
|
export * from './datasource';
|
||||||
|
@ -66,3 +66,10 @@ export interface RangeMap extends BaseMap {
|
|||||||
from: string;
|
from: string;
|
||||||
to: 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;
|
color: string;
|
||||||
data: TimeSeriesValue[][];
|
data: TimeSeriesValue[][];
|
||||||
stats: TimeSeriesStats;
|
stats: TimeSeriesStats;
|
||||||
|
allIsNull: boolean;
|
||||||
|
allIsZero: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSeriesStats {
|
export interface TimeSeriesStats {
|
||||||
|
[key: string]: number | null;
|
||||||
total: number | null;
|
total: number | null;
|
||||||
max: number | null;
|
max: number | null;
|
||||||
min: number | null;
|
min: number | null;
|
||||||
@ -36,8 +39,6 @@ export interface TimeSeriesStats {
|
|||||||
range: number | null;
|
range: number | null;
|
||||||
timeStep: number;
|
timeStep: number;
|
||||||
count: number;
|
count: number;
|
||||||
allIsNull: boolean;
|
|
||||||
allIsZero: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NullValueMode {
|
export enum NullValueMode {
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
// Libraries
|
// Libraries
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { colors } from './colors';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
|
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
timeSeries: TimeSeries[];
|
timeSeries: TimeSeries[];
|
||||||
nullValueMode: NullValueMode;
|
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 vmSeries = timeSeries.map((item, index) => {
|
||||||
const colorIndex = index % colorPalette.length;
|
const colorIndex = index % colors.length;
|
||||||
const label = item.target;
|
const label = item.target;
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
@ -49,8 +50,8 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof currentValue !== 'number') {
|
if (currentValue !== null && typeof currentValue !== 'number') {
|
||||||
continue;
|
throw {message: 'Time series contains non number values'};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Due to missing values we could have different timeStep all along the series
|
// 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 {
|
return {
|
||||||
data: result,
|
data: result,
|
||||||
label: label,
|
label: label,
|
||||||
color: colorPalette[colorIndex],
|
color: colors[colorIndex],
|
||||||
|
allIsZero,
|
||||||
|
allIsNull,
|
||||||
stats: {
|
stats: {
|
||||||
total,
|
total,
|
||||||
min,
|
min,
|
||||||
@ -164,8 +167,6 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
|
|||||||
range,
|
range,
|
||||||
count,
|
count,
|
||||||
first,
|
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
|
// not logged in views
|
||||||
r.Get("/", reqSignedIn, hs.Index)
|
r.Get("/", reqSignedIn, hs.Index)
|
||||||
r.Get("/logout", Logout)
|
r.Get("/logout", hs.Logout)
|
||||||
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
|
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost))
|
||||||
r.Get("/login/:name", quota("session"), OAuthLogin)
|
r.Get("/login/:name", quota("session"), hs.OAuthLogin)
|
||||||
r.Get("/login", hs.LoginView)
|
r.Get("/login", hs.LoginView)
|
||||||
r.Get("/invite/:code", hs.Index)
|
r.Get("/invite/:code", hs.Index)
|
||||||
|
|
||||||
@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Get("/signup", hs.Index)
|
r.Get("/signup", hs.Index)
|
||||||
r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
|
r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
|
||||||
r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
|
r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
|
||||||
r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
|
r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2))
|
||||||
|
|
||||||
// invited
|
// invited
|
||||||
r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
|
r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
|
||||||
r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
|
r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite))
|
||||||
|
|
||||||
// reset password
|
// reset password
|
||||||
r.Get("/user/password/send-reset-email", hs.Index)
|
r.Get("/user/password/send-reset-email", hs.Index)
|
||||||
@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
|
r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
|
||||||
|
|
||||||
// api renew session based on remember cookie
|
// api renew session based on remember cookie
|
||||||
r.Get("/api/login/ping", quota("session"), LoginAPIPing)
|
r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
|
||||||
|
|
||||||
// authed api
|
// authed api
|
||||||
r.Group("/api", func(apiRoute routing.RouteRegister) {
|
r.Group("/api", func(apiRoute routing.RouteRegister) {
|
||||||
|
@ -5,7 +5,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/go-macaron/session"
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/middleware"
|
"github.com/grafana/grafana/pkg/middleware"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
@ -102,6 +101,7 @@ type scenarioContext struct {
|
|||||||
defaultHandler macaron.Handler
|
defaultHandler macaron.Handler
|
||||||
req *http.Request
|
req *http.Request
|
||||||
url string
|
url string
|
||||||
|
userAuthTokenService *fakeUserAuthTokenService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sc *scenarioContext) exec() {
|
func (sc *scenarioContext) exec() {
|
||||||
@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
|
|||||||
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
sc.m.Use(middleware.GetContextHandler())
|
sc.userAuthTokenService = newFakeUserAuthTokenService()
|
||||||
sc.m.Use(middleware.Sessioner(&session.Options{}, 0))
|
sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
|
||||||
|
|
||||||
return sc
|
return sc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeUserAuthTokenService struct {
|
||||||
|
initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
|
||||||
|
return &fakeUserAuthTokenService{
|
||||||
|
initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
|
||||||
|
return s.initContextWithTokenProvider(ctx, orgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
|
||||||
|
@ -165,6 +165,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
|
|||||||
"externalUserMngInfo": setting.ExternalUserMngInfo,
|
"externalUserMngInfo": setting.ExternalUserMngInfo,
|
||||||
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
|
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
|
||||||
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
|
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
|
||||||
|
"viewersCanEdit": setting.ViewersCanEdit,
|
||||||
|
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
|
||||||
"buildInfo": map[string]interface{}{
|
"buildInfo": map[string]interface{}{
|
||||||
"version": setting.BuildVersion,
|
"version": setting.BuildVersion,
|
||||||
"commit": setting.BuildCommit,
|
"commit": setting.BuildCommit,
|
||||||
|
@ -11,14 +11,8 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/routing"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
|
|
||||||
macaron "gopkg.in/macaron.v1"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/live"
|
"github.com/grafana/grafana/pkg/api/live"
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
httpstatic "github.com/grafana/grafana/pkg/api/static"
|
httpstatic "github.com/grafana/grafana/pkg/api/static"
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
@ -27,11 +21,16 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"github.com/grafana/grafana/pkg/registry"
|
||||||
|
"github.com/grafana/grafana/pkg/services/auth"
|
||||||
"github.com/grafana/grafana/pkg/services/cache"
|
"github.com/grafana/grafana/pkg/services/cache"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/hooks"
|
"github.com/grafana/grafana/pkg/services/hooks"
|
||||||
"github.com/grafana/grafana/pkg/services/rendering"
|
"github.com/grafana/grafana/pkg/services/rendering"
|
||||||
|
"github.com/grafana/grafana/pkg/services/session"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
macaron "gopkg.in/macaron.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -56,6 +55,7 @@ type HTTPServer struct {
|
|||||||
HooksService *hooks.HooksService `inject:""`
|
HooksService *hooks.HooksService `inject:""`
|
||||||
CacheService *cache.CacheService `inject:""`
|
CacheService *cache.CacheService `inject:""`
|
||||||
DatasourceCache datasources.CacheService `inject:""`
|
DatasourceCache datasources.CacheService `inject:""`
|
||||||
|
AuthTokenService auth.UserAuthTokenService `inject:""`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) Init() error {
|
func (hs *HTTPServer) Init() error {
|
||||||
@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
|
|||||||
hs.macaron = hs.newMacaron()
|
hs.macaron = hs.newMacaron()
|
||||||
hs.registerRoutes()
|
hs.registerRoutes()
|
||||||
|
|
||||||
|
session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
|
|||||||
|
|
||||||
m.Use(hs.healthHandler)
|
m.Use(hs.healthHandler)
|
||||||
m.Use(hs.metricsEndpoint)
|
m.Use(hs.metricsEndpoint)
|
||||||
m.Use(middleware.GetContextHandler())
|
m.Use(middleware.GetContextHandler(hs.AuthTokenService))
|
||||||
m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
|
|
||||||
m.Use(middleware.OrgRedirect())
|
m.Use(middleware.OrgRedirect())
|
||||||
|
|
||||||
// needs to be after context handler
|
// needs to be after context handler
|
||||||
|
@ -140,7 +140,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
|
|||||||
Children: dashboardChildNavs,
|
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{
|
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||||
Text: "Explore",
|
Text: "Explore",
|
||||||
Id: "explore",
|
Id: "explore",
|
||||||
|
124
pkg/api/login.go
124
pkg/api/login.go
@ -1,6 +1,8 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
@ -9,12 +11,13 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/login"
|
"github.com/grafana/grafana/pkg/login"
|
||||||
"github.com/grafana/grafana/pkg/metrics"
|
"github.com/grafana/grafana/pkg/metrics"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ViewIndex = "index"
|
ViewIndex = "index"
|
||||||
|
LoginErrorCookieName = "login_error"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (hs *HTTPServer) LoginView(c *m.ReqContext) {
|
func (hs *HTTPServer) LoginView(c *m.ReqContext) {
|
||||||
@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
|
|||||||
viewData.Settings["loginHint"] = setting.LoginHint
|
viewData.Settings["loginHint"] = setting.LoginHint
|
||||||
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
|
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
|
||||||
|
|
||||||
if loginError, ok := c.Session.Get("loginError").(string); ok {
|
if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
|
||||||
c.Session.Delete("loginError")
|
deleteCookie(c, LoginErrorCookieName)
|
||||||
viewData.Settings["loginError"] = loginError
|
viewData.Settings["loginError"] = loginError
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !tryLoginUsingRememberCookie(c) {
|
if !c.IsSignedIn {
|
||||||
c.HTML(200, ViewIndex, viewData)
|
c.HTML(200, ViewIndex, viewData)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryLoginUsingRememberCookie(c *m.ReqContext) bool {
|
func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
|
||||||
// Check auto-login.
|
if c.IsSignedIn || c.IsAnonymous {
|
||||||
uname := c.GetCookie(setting.CookieUserName)
|
return JSON(200, "Logged in")
|
||||||
if len(uname) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSucceed := false
|
return Error(401, "Unauthorized", nil)
|
||||||
defer func() {
|
|
||||||
if !isSucceed {
|
|
||||||
log.Trace("auto-login cookie cleared: %s", uname)
|
|
||||||
c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
|
|
||||||
c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname}
|
|
||||||
if err := bus.Dispatch(&userQuery); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user := userQuery.Result
|
func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
||||||
|
|
||||||
// validate remember me cookie
|
|
||||||
signingKey := user.Rands + user.Password
|
|
||||||
if len(signingKey) < 10 {
|
|
||||||
c.Logger.Error("Invalid user signingKey")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
isSucceed = true
|
|
||||||
loginUserWithUser(user, c)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoginAPIPing(c *m.ReqContext) {
|
|
||||||
if !tryLoginUsingRememberCookie(c) {
|
|
||||||
c.JsonApiErr(401, "Unauthorized", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JsonOK("Logged in")
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
|
||||||
if setting.DisableLoginForm {
|
if setting.DisableLoginForm {
|
||||||
return Error(401, "Login is disabled", nil)
|
return Error(401, "Login is disabled", nil)
|
||||||
}
|
}
|
||||||
@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
|||||||
|
|
||||||
user := authQuery.User
|
user := authQuery.User
|
||||||
|
|
||||||
loginUserWithUser(user, c)
|
hs.loginUserWithUser(user, c)
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"message": "Logged in",
|
"message": "Logged in",
|
||||||
@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
|
|||||||
return JSON(200, result)
|
return JSON(200, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginUserWithUser(user *m.User, c *m.ReqContext) {
|
func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
log.Error(3, "User login with nil user")
|
hs.log.Error("User login with nil user")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Resp.Header().Del("Set-Cookie")
|
err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
|
||||||
|
if err != nil {
|
||||||
days := 86400 * setting.LogInRememberDays
|
hs.log.Error("User auth hook failed", "error", err)
|
||||||
if days > 0 {
|
}
|
||||||
c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/")
|
|
||||||
c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Session.RegenerateId(c.Context)
|
func (hs *HTTPServer) Logout(c *m.ReqContext) {
|
||||||
c.Session.Set(session.SESS_KEY_USERID, user.Id)
|
hs.AuthTokenService.UserSignedOutHook(c)
|
||||||
}
|
|
||||||
|
|
||||||
func Logout(c *m.ReqContext) {
|
|
||||||
c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
|
|
||||||
c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
|
|
||||||
c.Session.Destory(c.Context)
|
|
||||||
if setting.SignoutRedirectUrl != "" {
|
if setting.SignoutRedirectUrl != "" {
|
||||||
c.Redirect(setting.SignoutRedirectUrl)
|
c.Redirect(setting.SignoutRedirectUrl)
|
||||||
} else {
|
} else {
|
||||||
c.Redirect(setting.AppSubUrl + "/login")
|
c.Redirect(setting.AppSubUrl + "/login")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) {
|
||||||
|
cookie := ctx.GetCookie(cookieName)
|
||||||
|
if cookie == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := hex.DecodeString(cookie)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey)
|
||||||
|
return string(decryptedError), err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCookie(ctx *m.ReqContext, cookieName string) {
|
||||||
|
ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error {
|
||||||
|
encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(ctx.Resp, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
MaxAge: 60,
|
||||||
|
Value: hex.EncodeToString(encryptedError),
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: setting.AppSubUrl + "/",
|
||||||
|
Secure: hs.Cfg.SecurityHTTPSCookies,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -3,9 +3,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -18,12 +20,14 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/login"
|
"github.com/grafana/grafana/pkg/login"
|
||||||
"github.com/grafana/grafana/pkg/metrics"
|
"github.com/grafana/grafana/pkg/metrics"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/social"
|
"github.com/grafana/grafana/pkg/social"
|
||||||
)
|
)
|
||||||
|
|
||||||
var oauthLogger = log.New("oauth")
|
var (
|
||||||
|
oauthLogger = log.New("oauth")
|
||||||
|
OauthStateCookieName = "oauth_state"
|
||||||
|
)
|
||||||
|
|
||||||
func GenStateString() string {
|
func GenStateString() string {
|
||||||
rnd := make([]byte, 32)
|
rnd := make([]byte, 32)
|
||||||
@ -31,7 +35,7 @@ func GenStateString() string {
|
|||||||
return base64.URLEncoding.EncodeToString(rnd)
|
return base64.URLEncoding.EncodeToString(rnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OAuthLogin(ctx *m.ReqContext) {
|
func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
|
||||||
if setting.OAuthService == nil {
|
if setting.OAuthService == nil {
|
||||||
ctx.Handle(404, "OAuth not enabled", nil)
|
ctx.Handle(404, "OAuth not enabled", nil)
|
||||||
return
|
return
|
||||||
@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) {
|
|||||||
if errorParam != "" {
|
if errorParam != "" {
|
||||||
errorDesc := ctx.Query("error_description")
|
errorDesc := ctx.Query("error_description")
|
||||||
oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
|
oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
|
||||||
redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
|
hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code := ctx.Query("code")
|
code := ctx.Query("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
state := GenStateString()
|
state := GenStateString()
|
||||||
ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state)
|
hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
|
||||||
|
hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60)
|
||||||
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
|
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
|
||||||
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
|
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
|
||||||
} else {
|
} else {
|
||||||
@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string)
|
cookieState := ctx.GetCookie(OauthStateCookieName)
|
||||||
if !ok {
|
|
||||||
|
// delete cookie
|
||||||
|
ctx.Resp.Header().Del("Set-Cookie")
|
||||||
|
hs.deleteCookie(ctx.Resp, OauthStateCookieName)
|
||||||
|
|
||||||
|
if cookieState == "" {
|
||||||
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
|
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queryState := ctx.Query("state")
|
queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret)
|
||||||
if savedState != queryState {
|
oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState)
|
||||||
|
if cookieState != queryState {
|
||||||
ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
|
ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) {
|
|||||||
userInfo, err := connect.UserInfo(client, token)
|
userInfo, err := connect.UserInfo(client, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if sErr, ok := err.(*social.Error); ok {
|
if sErr, ok := err.(*social.Error); ok {
|
||||||
redirectWithError(ctx, sErr)
|
hs.redirectWithError(ctx, sErr)
|
||||||
} else {
|
} else {
|
||||||
ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
|
ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
|
||||||
}
|
}
|
||||||
@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) {
|
|||||||
|
|
||||||
// validate that we got at least an email address
|
// validate that we got at least an email address
|
||||||
if userInfo.Email == "" {
|
if userInfo.Email == "" {
|
||||||
redirectWithError(ctx, login.ErrNoEmail)
|
hs.redirectWithError(ctx, login.ErrNoEmail)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate that the email is allowed to login to grafana
|
// validate that the email is allowed to login to grafana
|
||||||
if !connect.IsEmailAllowed(userInfo.Email) {
|
if !connect.IsEmailAllowed(userInfo.Email) {
|
||||||
redirectWithError(ctx, login.ErrEmailNotAllowed)
|
hs.redirectWithError(ctx, login.ErrEmailNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) {
|
|||||||
ExternalUser: extUser,
|
ExternalUser: extUser,
|
||||||
SignupAllowed: connect.IsSignupAllowed(),
|
SignupAllowed: connect.IsSignupAllowed(),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = bus.Dispatch(cmd)
|
err = bus.Dispatch(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
redirectWithError(ctx, err)
|
hs.redirectWithError(ctx, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// login
|
// login
|
||||||
loginUserWithUser(cmd.Result, ctx)
|
hs.loginUserWithUser(cmd.Result, ctx)
|
||||||
|
|
||||||
metrics.M_Api_Login_OAuth.Inc()
|
metrics.M_Api_Login_OAuth.Inc()
|
||||||
|
|
||||||
@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) {
|
|||||||
ctx.Redirect(setting.AppSubUrl + "/")
|
ctx.Redirect(setting.AppSubUrl + "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
|
func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) {
|
||||||
|
hs.writeCookie(w, name, "", -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Value: value,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: setting.AppSubUrl + "/",
|
||||||
|
Secure: hs.Cfg.SecurityHTTPSCookies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashStatecode(code, seed string) string {
|
||||||
|
hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed))
|
||||||
|
return hex.EncodeToString(hashBytes[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
|
||||||
ctx.Logger.Error(err.Error(), v...)
|
ctx.Logger.Error(err.Error(), v...)
|
||||||
ctx.Session.Set("loginError", err.Error())
|
hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60)
|
||||||
|
|
||||||
ctx.Redirect(setting.AppSubUrl + "/login")
|
ctx.Redirect(setting.AppSubUrl + "/login")
|
||||||
}
|
}
|
||||||
|
@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
|
func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
|
||||||
query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
|
query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
|
||||||
|
|
||||||
if err := bus.Dispatch(&query); err != nil {
|
if err := bus.Dispatch(&query); err != nil {
|
||||||
@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res
|
|||||||
return rsp
|
return rsp
|
||||||
}
|
}
|
||||||
|
|
||||||
loginUserWithUser(user, c)
|
hs.loginUserWithUser(user, c)
|
||||||
|
|
||||||
metrics.M_Api_User_SignUpCompleted.Inc()
|
metrics.M_Api_User_SignUpCompleted.Inc()
|
||||||
metrics.M_Api_User_SignUpInvite.Inc()
|
metrics.M_Api_User_SignUpInvite.Inc()
|
||||||
|
@ -54,7 +54,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx
|
|||||||
|
|
||||||
func newHTTPClient() httpClient {
|
func newHTTPClient() httpClient {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: time.Second * 30,
|
Timeout: time.Duration(setting.DataProxyTimeout) * time.Second,
|
||||||
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
|
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response {
|
|||||||
return JSON(200, util.DynMap{"status": "SignUpCreated"})
|
return JSON(200, util.DynMap{"status": "SignUpCreated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
|
func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
|
||||||
if !setting.AllowUserSignUp {
|
if !setting.AllowUserSignUp {
|
||||||
return Error(401, "User signup is disabled", nil)
|
return Error(401, "User signup is disabled", nil)
|
||||||
}
|
}
|
||||||
@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
|
|||||||
apiResponse["code"] = "redirect-to-select-org"
|
apiResponse["code"] = "redirect-to-select-org"
|
||||||
}
|
}
|
||||||
|
|
||||||
loginUserWithUser(user, c)
|
hs.loginUserWithUser(user, c)
|
||||||
metrics.M_Api_User_SignUpCompleted.Inc()
|
metrics.M_Api_User_SignUpCompleted.Inc()
|
||||||
|
|
||||||
return JSON(200, apiResponse)
|
return JSON(200, apiResponse)
|
||||||
|
@ -7,7 +7,6 @@ import (
|
|||||||
"gopkg.in/macaron.v1"
|
"gopkg.in/macaron.v1"
|
||||||
|
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
@ -17,16 +16,6 @@ type AuthOptions struct {
|
|||||||
ReqSignedIn bool
|
ReqSignedIn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRequestUserId(c *m.ReqContext) int64 {
|
|
||||||
userID := c.Session.Get(session.SESS_KEY_USERID)
|
|
||||||
|
|
||||||
if userID != nil {
|
|
||||||
return userID.(int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func getApiKey(c *m.ReqContext) string {
|
func getApiKey(c *m.ReqContext) string {
|
||||||
header := c.Req.Header.Get("Authorization")
|
header := c.Req.Header.Get("Authorization")
|
||||||
parts := strings.SplitN(header, " ", 2)
|
parts := strings.SplitN(header, " ", 2)
|
||||||
|
@ -16,7 +16,9 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
|
var (
|
||||||
|
AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
|
||||||
|
)
|
||||||
|
|
||||||
func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
|
func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
|
||||||
if !setting.AuthProxyEnabled {
|
if !setting.AuthProxyEnabled {
|
||||||
@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := ctx.Session.Release(); err != nil {
|
||||||
|
ctx.Logger.Error("failed to save session data", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
query := &m.GetSignedInUserQuery{OrgId: orgID}
|
query := &m.GetSignedInUserQuery{OrgId: orgID}
|
||||||
|
|
||||||
// if this session has already been authenticated by authProxy just load the user
|
// if this session has already been authenticated by authProxy just load the user
|
||||||
@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getRequestUserId(c *m.ReqContext) int64 {
|
||||||
|
userID := c.Session.Get(session.SESS_KEY_USERID)
|
||||||
|
|
||||||
|
if userID != nil {
|
||||||
|
return userID.(int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
|
func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
|
||||||
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
|
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
@ -3,15 +3,15 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"gopkg.in/macaron.v1"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/components/apikeygen"
|
"github.com/grafana/grafana/pkg/components/apikeygen"
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/auth"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
"github.com/grafana/grafana/pkg/services/session"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
|
macaron "gopkg.in/macaron.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -21,12 +21,12 @@ var (
|
|||||||
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
|
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetContextHandler() macaron.Handler {
|
func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
|
||||||
return func(c *macaron.Context) {
|
return func(c *macaron.Context) {
|
||||||
ctx := &m.ReqContext{
|
ctx := &m.ReqContext{
|
||||||
Context: c,
|
Context: c,
|
||||||
SignedInUser: &m.SignedInUser{},
|
SignedInUser: &m.SignedInUser{},
|
||||||
Session: session.GetSession(),
|
Session: session.GetSession(), // should only be used by auth_proxy
|
||||||
IsSignedIn: false,
|
IsSignedIn: false,
|
||||||
AllowAnonymous: false,
|
AllowAnonymous: false,
|
||||||
SkipCache: false,
|
SkipCache: false,
|
||||||
@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler {
|
|||||||
case initContextWithApiKey(ctx):
|
case initContextWithApiKey(ctx):
|
||||||
case initContextWithBasicAuth(ctx, orgId):
|
case initContextWithBasicAuth(ctx, orgId):
|
||||||
case initContextWithAuthProxy(ctx, orgId):
|
case initContextWithAuthProxy(ctx, orgId):
|
||||||
case initContextWithUserSessionCookie(ctx, orgId):
|
case ats.InitContextWithToken(ctx, orgId):
|
||||||
case initContextWithAnonymousUser(ctx):
|
case initContextWithAnonymousUser(ctx):
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool {
|
|
||||||
// initialize session
|
|
||||||
if err := ctx.Session.Start(ctx.Context); err != nil {
|
|
||||||
ctx.Logger.Error("Failed to start session", "error", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var userId int64
|
|
||||||
if userId = getRequestUserId(ctx); userId == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId}
|
|
||||||
if err := bus.Dispatch(&query); err != nil {
|
|
||||||
ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SignedInUser = query.Result
|
|
||||||
ctx.IsSignedIn = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func initContextWithApiKey(ctx *m.ReqContext) bool {
|
func initContextWithApiKey(ctx *m.ReqContext) bool {
|
||||||
var keyString string
|
var keyString string
|
||||||
if keyString = getApiKey(ctx); keyString == "" {
|
if keyString = getApiKey(ctx); keyString == "" {
|
||||||
|
@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
ms "github.com/go-macaron/session"
|
msession "github.com/go-macaron/session"
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
"github.com/grafana/grafana/pkg/services/session"
|
||||||
@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) {
|
|||||||
So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
|
So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
|
||||||
})
|
})
|
||||||
|
|
||||||
middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
|
|
||||||
sc.fakeReq("GET", "/").exec()
|
|
||||||
So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
|
|
||||||
})
|
|
||||||
|
|
||||||
middlewareScenario("Invalid api key", func(sc *scenarioContext) {
|
middlewareScenario("Invalid api key", func(sc *scenarioContext) {
|
||||||
sc.apiKey = "invalid_key_test"
|
sc.apiKey = "invalid_key_test"
|
||||||
sc.fakeReq("GET", "/").exec()
|
sc.fakeReq("GET", "/").exec()
|
||||||
@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
middlewareScenario("UserId in session", func(sc *scenarioContext) {
|
middlewareScenario("Auth token service", func(sc *scenarioContext) {
|
||||||
|
var wasCalled bool
|
||||||
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
|
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||||
c.Session.Set(session.SESS_KEY_USERID, int64(12))
|
wasCalled = true
|
||||||
}).exec()
|
return false
|
||||||
|
}
|
||||||
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
|
||||||
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
sc.fakeReq("GET", "/").exec()
|
sc.fakeReq("GET", "/").exec()
|
||||||
|
|
||||||
Convey("should init context with user info", func() {
|
Convey("should call middleware", func() {
|
||||||
So(sc.context.IsSignedIn, ShouldBeTrue)
|
So(wasCalled, ShouldBeTrue)
|
||||||
So(sc.context.UserId, ShouldEqual, 12)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setting.SessionOptions = msession.Options{}
|
||||||
sc.fakeReq("GET", "/")
|
sc.fakeReq("GET", "/")
|
||||||
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
|
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
|
||||||
sc.exec()
|
sc.exec()
|
||||||
@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
|
|||||||
defer bus.ClearBusHandlers()
|
defer bus.ClearBusHandlers()
|
||||||
|
|
||||||
sc := &scenarioContext{}
|
sc := &scenarioContext{}
|
||||||
|
|
||||||
viewsPath, _ := filepath.Abs("../../public/views")
|
viewsPath, _ := filepath.Abs("../../public/views")
|
||||||
|
|
||||||
sc.m = macaron.New()
|
sc.m = macaron.New()
|
||||||
@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) {
|
|||||||
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
sc.m.Use(GetContextHandler())
|
session.Init(&msession.Options{}, 0)
|
||||||
|
sc.userAuthTokenService = newFakeUserAuthTokenService()
|
||||||
|
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
|
||||||
// mock out gc goroutine
|
// mock out gc goroutine
|
||||||
session.StartSessionGC = func() {}
|
session.StartSessionGC = func() {}
|
||||||
sc.m.Use(Sessioner(&ms.Options{}, 0))
|
setting.SessionOptions = msession.Options{}
|
||||||
|
|
||||||
sc.m.Use(OrgRedirect())
|
sc.m.Use(OrgRedirect())
|
||||||
sc.m.Use(AddDefaultResponseHeaders())
|
sc.m.Use(AddDefaultResponseHeaders())
|
||||||
|
|
||||||
@ -517,6 +512,7 @@ type scenarioContext struct {
|
|||||||
handlerFunc handlerFunc
|
handlerFunc handlerFunc
|
||||||
defaultHandler macaron.Handler
|
defaultHandler macaron.Handler
|
||||||
url string
|
url string
|
||||||
|
userAuthTokenService *fakeUserAuthTokenService
|
||||||
|
|
||||||
req *http.Request
|
req *http.Request
|
||||||
}
|
}
|
||||||
@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() {
|
|||||||
|
|
||||||
type scenarioFunc func(c *scenarioContext)
|
type scenarioFunc func(c *scenarioContext)
|
||||||
type handlerFunc func(c *m.ReqContext)
|
type handlerFunc func(c *m.ReqContext)
|
||||||
|
|
||||||
|
type fakeUserAuthTokenService struct {
|
||||||
|
initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
|
||||||
|
return &fakeUserAuthTokenService{
|
||||||
|
initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
|
||||||
|
return s.initContextWithTokenProvider(ctx, orgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
|
||||||
|
@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
|
||||||
"gopkg.in/macaron.v1"
|
"gopkg.in/macaron.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
Convey("Can redirect to correct org", t, func() {
|
Convey("Can redirect to correct org", t, func() {
|
||||||
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
|
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
|
||||||
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
|
|
||||||
c.Session.Set(session.SESS_KEY_USERID, int64(12))
|
|
||||||
}).exec()
|
|
||||||
|
|
||||||
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||||
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||||
return nil
|
ctx.IsSignedIn = true
|
||||||
})
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
sc.m.Get("/", sc.defaultHandler)
|
sc.m.Get("/", sc.defaultHandler)
|
||||||
sc.fakeReq("GET", "/?orgId=3").exec()
|
sc.fakeReq("GET", "/?orgId=3").exec()
|
||||||
@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
|
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
|
||||||
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
|
|
||||||
c.Session.Set(session.SESS_KEY_USERID, int64(12))
|
|
||||||
}).exec()
|
|
||||||
|
|
||||||
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
||||||
return fmt.Errorf("")
|
return fmt.Errorf("")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||||
|
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||||
|
ctx.IsSignedIn = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
||||||
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||||
return nil
|
return nil
|
||||||
|
@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
middlewareScenario("with user logged in", func(sc *scenarioContext) {
|
middlewareScenario("with user logged in", func(sc *scenarioContext) {
|
||||||
// log us in, so we have a user_id and org_id in the context
|
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
||||||
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
|
ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
|
||||||
c.Session.Set(session.SESS_KEY_USERID, int64(12))
|
ctx.IsSignedIn = true
|
||||||
}).exec()
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
|
||||||
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
|
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
|
||||||
query.Result = &m.GlobalQuotaDTO{
|
query.Result = &m.GlobalQuotaDTO{
|
||||||
Target: query.Target,
|
Target: query.Target,
|
||||||
|
@ -4,13 +4,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
ms "github.com/go-macaron/session"
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
"github.com/grafana/grafana/pkg/services/session"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
"gopkg.in/macaron.v1"
|
macaron "gopkg.in/macaron.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecoveryMiddleware(t *testing.T) {
|
func TestRecoveryMiddleware(t *testing.T) {
|
||||||
@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
|
|||||||
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
sc.m.Use(GetContextHandler())
|
sc.userAuthTokenService = newFakeUserAuthTokenService()
|
||||||
|
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
|
||||||
// mock out gc goroutine
|
// mock out gc goroutine
|
||||||
session.StartSessionGC = func() {}
|
session.StartSessionGC = func() {}
|
||||||
sc.m.Use(Sessioner(&ms.Options{}, 0))
|
|
||||||
sc.m.Use(OrgRedirect())
|
sc.m.Use(OrgRedirect())
|
||||||
sc.m.Use(AddDefaultResponseHeaders())
|
sc.m.Use(AddDefaultResponseHeaders())
|
||||||
|
|
||||||
|
@ -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 (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"gopkg.in/macaron.v1"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
"github.com/grafana/grafana/pkg/services/session"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"gopkg.in/macaron.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReqContext struct {
|
type ReqContext struct {
|
||||||
*macaron.Context
|
*macaron.Context
|
||||||
*SignedInUser
|
*SignedInUser
|
||||||
|
|
||||||
|
// This should only be used by the auth_proxy
|
||||||
Session session.SessionStore
|
Session session.SessionStore
|
||||||
|
|
||||||
IsSignedIn bool
|
IsSignedIn bool
|
||||||
|
@ -106,6 +106,7 @@ var (
|
|||||||
unfinishedWorkTimeout = time.Second * 5
|
unfinishedWorkTimeout = time.Second * 5
|
||||||
// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
|
// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
|
||||||
alertTimeout = time.Second * 30
|
alertTimeout = time.Second * 30
|
||||||
|
resultHandleTimeout = time.Second * 30
|
||||||
alertMaxAttempts = 3
|
alertMaxAttempts = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -116,7 +117,7 @@ func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *J
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
cancelChan := make(chan context.CancelFunc, alertMaxAttempts)
|
cancelChan := make(chan context.CancelFunc, alertMaxAttempts*2)
|
||||||
attemptChan := make(chan int, 1)
|
attemptChan := make(chan int, 1)
|
||||||
|
|
||||||
// Initialize with first attemptID=1
|
// Initialize with first attemptID=1
|
||||||
@ -204,6 +205,15 @@ func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create new context with timeout for notifications
|
||||||
|
resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), resultHandleTimeout)
|
||||||
|
cancelChan <- resultHandleCancelFn
|
||||||
|
|
||||||
|
// override the context used for evaluation with a new context for notifications.
|
||||||
|
// This makes it possible for notifiers to execute when datasources
|
||||||
|
// dont respond within the timeout limit. We should rewrite this so notifications
|
||||||
|
// dont reuse the evalContext and get its own context.
|
||||||
|
evalContext.Ctx = resultHandleCtx
|
||||||
evalContext.Rule.State = evalContext.GetNewState()
|
evalContext.Rule.State = evalContext.GetNewState()
|
||||||
e.resultHandler.Handle(evalContext)
|
e.resultHandler.Handle(evalContext)
|
||||||
span.Finish()
|
span.Finish()
|
||||||
|
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,
|
User: dto.User,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bus.Dispatch(&alertCmd); err != nil {
|
return bus.Dispatch(&alertCmd)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
|
func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
|
||||||
|
@ -14,8 +14,6 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
SESS_KEY_USERID = "uid"
|
SESS_KEY_USERID = "uid"
|
||||||
SESS_KEY_OAUTH_STATE = "state"
|
|
||||||
SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys
|
|
||||||
SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
|
SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) {
|
|||||||
addLoginAttemptMigrations(mg)
|
addLoginAttemptMigrations(mg)
|
||||||
addUserAuthMigrations(mg)
|
addUserAuthMigrations(mg)
|
||||||
addServerlockMigrations(mg)
|
addServerlockMigrations(mg)
|
||||||
|
addUserAuthTokenMigrations(mg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addMigrationLogMigrations(mg *Migrator) {
|
func addMigrationLogMigrations(mg *Migrator) {
|
||||||
|
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]))
|
||||||
|
}
|
@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
|
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
"github.com/go-xorm/xorm"
|
"github.com/go-xorm/xorm"
|
||||||
@ -222,13 +223,9 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
|
|||||||
cnnstr += "&tls=custom"
|
cnnstr += "&tls=custom"
|
||||||
}
|
}
|
||||||
case migrator.POSTGRES:
|
case migrator.POSTGRES:
|
||||||
var host, port = "127.0.0.1", "5432"
|
host, port, err := util.SplitIpPort(ss.dbCfg.Host, "5432")
|
||||||
fields := strings.Split(ss.dbCfg.Host, ":")
|
if err != nil {
|
||||||
if len(fields) > 0 && len(strings.TrimSpace(fields[0])) > 0 {
|
return "", err
|
||||||
host = fields[0]
|
|
||||||
}
|
|
||||||
if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
|
|
||||||
port = fields[1]
|
|
||||||
}
|
}
|
||||||
if ss.dbCfg.Pwd == "" {
|
if ss.dbCfg.Pwd == "" {
|
||||||
ss.dbCfg.Pwd = "''"
|
ss.dbCfg.Pwd = "''"
|
||||||
|
101
pkg/services/sqlstore/sqlstore_test.go
Normal file
101
pkg/services/sqlstore/sqlstore_test.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package sqlstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqlStoreTest struct {
|
||||||
|
name string
|
||||||
|
dbType string
|
||||||
|
dbHost string
|
||||||
|
connStrValues []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqlStoreTestCases = []sqlStoreTest{
|
||||||
|
{
|
||||||
|
name: "MySQL IPv4",
|
||||||
|
dbType: "mysql",
|
||||||
|
dbHost: "1.2.3.4:5678",
|
||||||
|
connStrValues: []string{"tcp(1.2.3.4:5678)"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Postgres IPv4",
|
||||||
|
dbType: "postgres",
|
||||||
|
dbHost: "1.2.3.4:5678",
|
||||||
|
connStrValues: []string{"host=1.2.3.4", "port=5678"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Postgres IPv4 (Default Port)",
|
||||||
|
dbType: "postgres",
|
||||||
|
dbHost: "1.2.3.4",
|
||||||
|
connStrValues: []string{"host=1.2.3.4", "port=5432"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MySQL IPv4 (Default Port)",
|
||||||
|
dbType: "mysql",
|
||||||
|
dbHost: "1.2.3.4",
|
||||||
|
connStrValues: []string{"tcp(1.2.3.4)"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MySQL IPv6",
|
||||||
|
dbType: "mysql",
|
||||||
|
dbHost: "[fe80::24e8:31b2:91df:b177]:1234",
|
||||||
|
connStrValues: []string{"tcp([fe80::24e8:31b2:91df:b177]:1234)"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Postgres IPv6",
|
||||||
|
dbType: "postgres",
|
||||||
|
dbHost: "[fe80::24e8:31b2:91df:b177]:1234",
|
||||||
|
connStrValues: []string{"host=fe80::24e8:31b2:91df:b177", "port=1234"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MySQL IPv6 (Default Port)",
|
||||||
|
dbType: "mysql",
|
||||||
|
dbHost: "::1",
|
||||||
|
connStrValues: []string{"tcp(::1)"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Postgres IPv6 (Default Port)",
|
||||||
|
dbType: "postgres",
|
||||||
|
dbHost: "::1",
|
||||||
|
connStrValues: []string{"host=::1", "port=5432"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqlConnectionString(t *testing.T) {
|
||||||
|
Convey("Testing SQL Connection Strings", t, func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, testCase := range sqlStoreTestCases {
|
||||||
|
Convey(testCase.name, func() {
|
||||||
|
sqlstore := &SqlStore{}
|
||||||
|
sqlstore.Cfg = makeSqlStoreTestConfig(testCase.dbType, testCase.dbHost)
|
||||||
|
sqlstore.readConfig()
|
||||||
|
|
||||||
|
connStr, err := sqlstore.buildConnectionString()
|
||||||
|
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
for _, connSubStr := range testCase.connStrValues {
|
||||||
|
So(connStr, ShouldContainSubstring, connSubStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSqlStoreTestConfig(dbType string, host string) *setting.Cfg {
|
||||||
|
cfg := setting.NewCfg()
|
||||||
|
|
||||||
|
sec, _ := cfg.Raw.NewSection("database")
|
||||||
|
sec.NewKey("type", dbType)
|
||||||
|
sec.NewKey("host", host)
|
||||||
|
sec.NewKey("user", "user")
|
||||||
|
sec.NewKey("name", "test_db")
|
||||||
|
sec.NewKey("password", "pass")
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
@ -18,7 +18,7 @@ import (
|
|||||||
"github.com/go-macaron/session"
|
"github.com/go-macaron/session"
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
"gopkg.in/ini.v1"
|
ini "gopkg.in/ini.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Scheme string
|
type Scheme string
|
||||||
@ -77,15 +77,13 @@ var (
|
|||||||
SocketPath string
|
SocketPath string
|
||||||
RouterLogging bool
|
RouterLogging bool
|
||||||
DataProxyLogging bool
|
DataProxyLogging bool
|
||||||
|
DataProxyTimeout int
|
||||||
StaticRootPath string
|
StaticRootPath string
|
||||||
EnableGzip bool
|
EnableGzip bool
|
||||||
EnforceDomain bool
|
EnforceDomain bool
|
||||||
|
|
||||||
// Security settings.
|
// Security settings.
|
||||||
SecretKey string
|
SecretKey string
|
||||||
LogInRememberDays int
|
|
||||||
CookieUserName string
|
|
||||||
CookieRememberName string
|
|
||||||
DisableGravatar bool
|
DisableGravatar bool
|
||||||
EmailCodeValidMinutes int
|
EmailCodeValidMinutes int
|
||||||
DataProxyWhiteList map[string]bool
|
DataProxyWhiteList map[string]bool
|
||||||
@ -222,7 +220,15 @@ type Cfg struct {
|
|||||||
MetricsEndpointBasicAuthUsername string
|
MetricsEndpointBasicAuthUsername string
|
||||||
MetricsEndpointBasicAuthPassword string
|
MetricsEndpointBasicAuthPassword string
|
||||||
EnableAlphaPanels bool
|
EnableAlphaPanels bool
|
||||||
|
DisableSanitizeHtml bool
|
||||||
EnterpriseLicensePath string
|
EnterpriseLicensePath string
|
||||||
|
|
||||||
|
LoginCookieName string
|
||||||
|
LoginCookieMaxDays int
|
||||||
|
LoginCookieRotation int
|
||||||
|
LoginDeleteExpiredTokensAfterDays int
|
||||||
|
|
||||||
|
SecurityHTTPSCookies bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandLineArgs struct {
|
type CommandLineArgs struct {
|
||||||
@ -546,6 +552,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
|||||||
ApplicationName = APP_NAME_ENTERPRISE
|
ApplicationName = APP_NAME_ENTERPRISE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//login
|
||||||
|
login := iniFile.Section("login")
|
||||||
|
cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
|
||||||
|
cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
|
||||||
|
cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
|
||||||
|
cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
|
||||||
|
if cfg.LoginCookieRotation < 2 {
|
||||||
|
cfg.LoginCookieRotation = 2
|
||||||
|
}
|
||||||
|
|
||||||
Env = iniFile.Section("").Key("app_mode").MustString("development")
|
Env = iniFile.Section("").Key("app_mode").MustString("development")
|
||||||
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
|
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
|
||||||
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
|
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
|
||||||
@ -582,15 +598,14 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
|||||||
// read data proxy settings
|
// read data proxy settings
|
||||||
dataproxy := iniFile.Section("dataproxy")
|
dataproxy := iniFile.Section("dataproxy")
|
||||||
DataProxyLogging = dataproxy.Key("logging").MustBool(false)
|
DataProxyLogging = dataproxy.Key("logging").MustBool(false)
|
||||||
|
DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
|
||||||
|
|
||||||
// read security settings
|
// read security settings
|
||||||
security := iniFile.Section("security")
|
security := iniFile.Section("security")
|
||||||
SecretKey = security.Key("secret_key").String()
|
SecretKey = security.Key("secret_key").String()
|
||||||
LogInRememberDays = security.Key("login_remember_days").MustInt()
|
|
||||||
CookieUserName = security.Key("cookie_username").String()
|
|
||||||
CookieRememberName = security.Key("cookie_remember_name").String()
|
|
||||||
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
|
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
|
||||||
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
|
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
|
||||||
|
cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
|
||||||
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
|
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
|
||||||
|
|
||||||
// read snapshots settings
|
// read snapshots settings
|
||||||
@ -705,10 +720,11 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
|||||||
AlertingNoDataOrNullValues = alerting.Key("nodata_or_nullvalues").MustString("no_data")
|
AlertingNoDataOrNullValues = alerting.Key("nodata_or_nullvalues").MustString("no_data")
|
||||||
|
|
||||||
explore := iniFile.Section("explore")
|
explore := iniFile.Section("explore")
|
||||||
ExploreEnabled = explore.Key("enabled").MustBool(false)
|
ExploreEnabled = explore.Key("enabled").MustBool(true)
|
||||||
|
|
||||||
panels := iniFile.Section("panels")
|
panels := iniFile.Section("panels")
|
||||||
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
|
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
|
||||||
|
cfg.DisableSanitizeHtml = panels.Key("disable_sanitize_html").MustBool(false)
|
||||||
|
|
||||||
cfg.readSessionConfig()
|
cfg.readSessionConfig()
|
||||||
cfg.readSmtpSettings()
|
cfg.readSmtpSettings()
|
||||||
|
@ -4,13 +4,13 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
_ "github.com/denisenkom/go-mssqldb"
|
_ "github.com/denisenkom/go-mssqldb"
|
||||||
"github.com/go-xorm/core"
|
"github.com/go-xorm/core"
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -20,7 +20,10 @@ func init() {
|
|||||||
func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
|
func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
|
||||||
logger := log.New("tsdb.mssql")
|
logger := log.New("tsdb.mssql")
|
||||||
|
|
||||||
cnnstr := generateConnectionString(datasource)
|
cnnstr, err := generateConnectionString(datasource)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
logger.Debug("getEngine", "connection", cnnstr)
|
logger.Debug("getEngine", "connection", cnnstr)
|
||||||
|
|
||||||
config := tsdb.SqlQueryEndpointConfiguration{
|
config := tsdb.SqlQueryEndpointConfiguration{
|
||||||
@ -37,7 +40,7 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
|
|||||||
return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger)
|
return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateConnectionString(datasource *models.DataSource) string {
|
func generateConnectionString(datasource *models.DataSource) (string, error) {
|
||||||
password := ""
|
password := ""
|
||||||
for key, value := range datasource.SecureJsonData.Decrypt() {
|
for key, value := range datasource.SecureJsonData.Decrypt() {
|
||||||
if key == "password" {
|
if key == "password" {
|
||||||
@ -46,12 +49,11 @@ func generateConnectionString(datasource *models.DataSource) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostParts := strings.Split(datasource.Url, ":")
|
server, port, err := util.SplitIpPort(datasource.Url, "1433")
|
||||||
if len(hostParts) < 2 {
|
if err != nil {
|
||||||
hostParts = append(hostParts, "1433")
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
server, port := hostParts[0], hostParts[1]
|
|
||||||
encrypt := datasource.JsonData.Get("encrypt").MustString("false")
|
encrypt := datasource.JsonData.Get("encrypt").MustString("false")
|
||||||
connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
|
connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
|
||||||
server,
|
server,
|
||||||
@ -63,7 +65,7 @@ func generateConnectionString(datasource *models.DataSource) string {
|
|||||||
if encrypt != "false" {
|
if encrypt != "false" {
|
||||||
connStr += fmt.Sprintf("encrypt=%s;", encrypt)
|
connStr += fmt.Sprintf("encrypt=%s;", encrypt)
|
||||||
}
|
}
|
||||||
return connStr
|
return connStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mssqlRowTransformer struct {
|
type mssqlRowTransformer struct {
|
||||||
|
@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
|
|||||||
|
|
||||||
return userAndPass[0], userAndPass[1], nil
|
return userAndPass[0], userAndPass[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RandomHex(n int) (string, error) {
|
||||||
|
bytes := make([]byte, n)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
24
pkg/util/ip.go
Normal file
24
pkg/util/ip.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SplitIpPort(ipStr string, portDefault string) (ip string, port string, err error) {
|
||||||
|
ipAddr := net.ParseIP(ipStr)
|
||||||
|
|
||||||
|
if ipAddr == nil {
|
||||||
|
// Port was included
|
||||||
|
ip, port, err = net.SplitHostPort(ipStr)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No port was included
|
||||||
|
ip = ipAddr.String()
|
||||||
|
port = portDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip, port, 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")
|
||||||
|
})
|
||||||
|
}
|
43
pkg/util/ip_test.go
Normal file
43
pkg/util/ip_test.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitIpPort(t *testing.T) {
|
||||||
|
|
||||||
|
Convey("When parsing an IPv4 without explicit port", t, func() {
|
||||||
|
ip, port, err := SplitIpPort("1.2.3.4", "5678")
|
||||||
|
|
||||||
|
So(err, ShouldEqual, nil)
|
||||||
|
So(ip, ShouldEqual, "1.2.3.4")
|
||||||
|
So(port, ShouldEqual, "5678")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When parsing an IPv6 without explicit port", t, func() {
|
||||||
|
ip, port, err := SplitIpPort("::1", "5678")
|
||||||
|
|
||||||
|
So(err, ShouldEqual, nil)
|
||||||
|
So(ip, ShouldEqual, "::1")
|
||||||
|
So(port, ShouldEqual, "5678")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When parsing an IPv4 with explicit port", t, func() {
|
||||||
|
ip, port, err := SplitIpPort("1.2.3.4:56", "78")
|
||||||
|
|
||||||
|
So(err, ShouldEqual, nil)
|
||||||
|
So(ip, ShouldEqual, "1.2.3.4")
|
||||||
|
So(port, ShouldEqual, "56")
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When parsing an IPv6 with explicit port", t, func() {
|
||||||
|
ip, port, err := SplitIpPort("[::1]:56", "78")
|
||||||
|
|
||||||
|
So(err, ShouldEqual, nil)
|
||||||
|
So(ip, ShouldEqual, "::1")
|
||||||
|
So(port, ShouldEqual, "56")
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
@ -19,6 +19,7 @@ import angular from 'angular';
|
|||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import { addClassIfNoOverlayScrollbar } from 'app/core/utils/scrollbar';
|
||||||
|
|
||||||
// add move to lodash for backward compatabiltiy
|
// add move to lodash for backward compatabiltiy
|
||||||
_.move = (array, fromIndex, toIndex) => {
|
_.move = (array, fromIndex, toIndex) => {
|
||||||
@ -45,6 +46,7 @@ export class GrafanaApp {
|
|||||||
preBootModules: any[];
|
preBootModules: any[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
addClassIfNoOverlayScrollbar('no-overlay-scrollbar');
|
||||||
this.preBootModules = [];
|
this.preBootModules = [];
|
||||||
this.registerFunctions = {};
|
this.registerFunctions = {};
|
||||||
this.ngModuleDependencies = [];
|
this.ngModuleDependencies = [];
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
import { LocationUpdate } from 'app/types';
|
import { LocationUpdate } from 'app/types';
|
||||||
|
|
||||||
|
export enum CoreActionTypes {
|
||||||
|
UpdateLocation = 'UPDATE_LOCATION',
|
||||||
|
}
|
||||||
|
|
||||||
export type Action = UpdateLocationAction;
|
export type Action = UpdateLocationAction;
|
||||||
|
|
||||||
export interface UpdateLocationAction {
|
export interface UpdateLocationAction {
|
||||||
type: 'UPDATE_LOCATION';
|
type: CoreActionTypes.UpdateLocation;
|
||||||
payload: LocationUpdate;
|
payload: LocationUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
|
export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
|
||||||
type: 'UPDATE_LOCATION',
|
type: CoreActionTypes.UpdateLocation,
|
||||||
payload: location,
|
payload: location,
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { SFC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import Transition from 'react-transition-group/Transition';
|
import Transition from 'react-transition-group/Transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -8,7 +8,7 @@ interface Props {
|
|||||||
unmountOnExit?: boolean;
|
unmountOnExit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FadeIn: SFC<Props> = props => {
|
export const FadeIn: FC<Props> = props => {
|
||||||
const defaultStyle = {
|
const defaultStyle = {
|
||||||
transition: `opacity ${props.duration}ms linear`,
|
transition: `opacity ${props.duration}ms linear`,
|
||||||
opacity: 0,
|
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;
|
export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ interface Props {
|
|||||||
onLayoutModeChanged: (mode: LayoutMode) => {};
|
onLayoutModeChanged: (mode: LayoutMode) => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutSelector: SFC<Props> = props => {
|
const LayoutSelector: FC<Props> = props => {
|
||||||
const { mode, onLayoutModeChanged } = props;
|
const { mode, onLayoutModeChanged } = props;
|
||||||
return (
|
return (
|
||||||
<div className="layout-selector">
|
<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 { NavModel, NavModelItem } from 'app/types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
@ -12,8 +12,8 @@ const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string
|
|||||||
return navItem.active === true;
|
return navItem.active === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const gotoUrl = evt => {
|
const gotoUrl = (evt: FormEvent) => {
|
||||||
const element = evt.target;
|
const element = evt.target as HTMLSelectElement;
|
||||||
const url = element.options[element.selectedIndex].value;
|
const url = element.options[element.selectedIndex].value;
|
||||||
appEvents.emit('location-change', { href: url });
|
appEvents.emit('location-change', { href: url });
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React, { SFC } from 'react';
|
import React, { FC } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageName: string;
|
pageName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageLoader: SFC<Props> = ({ pageName }) => {
|
const PageLoader: FC<Props> = ({ pageName }) => {
|
||||||
const loadingText = `Loading ${pageName}...`;
|
const loadingText = `Loading ${pageName}...`;
|
||||||
return (
|
return (
|
||||||
<div className="page-loader-wrapper">
|
<div className="page-loader-wrapper">
|
||||||
|
@ -6,7 +6,7 @@ import _ from 'lodash';
|
|||||||
import { Select } from '@grafana/ui';
|
import { Select } from '@grafana/ui';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { DataSourceSelectItem } from 'app/types';
|
import { DataSourceSelectItem } from '@grafana/ui/src/types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange: (ds: DataSourceSelectItem) => void;
|
onChange: (ds: DataSourceSelectItem) => void;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { PureComponent } from 'react';
|
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 { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
|
||||||
import { DashboardSearchHit } from 'app/types';
|
import { DashboardSearchHit } from 'app/types';
|
||||||
@ -99,12 +99,12 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="gf-form">
|
<div className="gf-form">
|
||||||
<Label
|
<FormLabel
|
||||||
width={11}
|
width={11}
|
||||||
tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
|
tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
|
||||||
>
|
>
|
||||||
Home Dashboard
|
Home Dashboard
|
||||||
</Label>
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
|
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
|
||||||
getOptionValue={i => i.id}
|
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';
|
import { Tooltip } from '@grafana/ui';
|
||||||
|
|
||||||
interface ToggleButtonGroupProps {
|
interface ToggleButtonGroupProps {
|
||||||
@ -29,7 +29,7 @@ interface ToggleButtonProps {
|
|||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToggleButton: SFC<ToggleButtonProps> = ({
|
export const ToggleButton: FC<ToggleButtonProps> = ({
|
||||||
children,
|
children,
|
||||||
selected,
|
selected,
|
||||||
className = '',
|
className = '',
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user