diff --git a/.circleci/config.yml b/.circleci/config.yml
index ec1fcfb411f..7b5a9e7923b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -19,7 +19,7 @@ version: 2
jobs:
mysql-integration-test:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
- image: circleci/mysql:5.6-ram
environment:
MYSQL_ROOT_PASSWORD: rootpass
@@ -39,7 +39,7 @@ jobs:
postgres-integration-test:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
- image: circleci/postgres:9.3-ram
environment:
POSTGRES_USER: grafanatest
@@ -74,27 +74,16 @@ jobs:
gometalinter:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
environment:
# we need CGO because of go-sqlite3
CGO_ENABLED: 1
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
- - run: 'go get -u github.com/alecthomas/gometalinter'
- - run: 'go get -u github.com/tsenart/deadcode'
- - run: 'go get -u github.com/jgautheron/goconst/cmd/goconst'
- - run: 'go get -u github.com/gordonklaus/ineffassign'
- - run: 'go get -u honnef.co/go/tools/cmd/megacheck'
- - run: 'go get -u github.com/opennota/check/cmd/structcheck'
- - run: 'go get -u github.com/mdempsky/unconvert'
- - run: 'go get -u github.com/opennota/check/cmd/varcheck'
- run:
- name: run linters
- command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=goconst --enable=gofmt --enable=ineffassign --enable=megacheck --enable=structcheck --enable=unconvert --enable=varcheck ./...'
- - run:
- name: run go vet
- command: 'go vet ./pkg/...'
+ name: Gometalinter tests
+ command: './scripts/gometalinter.sh'
test-frontend:
docker:
@@ -117,7 +106,7 @@ jobs:
test-backend:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -127,7 +116,7 @@ jobs:
build-all:
docker:
- - image: grafana/build-container:1.2.2
+ - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -175,7 +164,7 @@ jobs:
build:
docker:
- - image: grafana/build-container:1.2.2
+ - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -244,7 +233,7 @@ jobs:
build-enterprise:
docker:
- - image: grafana/build-container:1.2.2
+ - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -276,7 +265,7 @@ jobs:
build-all-enterprise:
docker:
- - image: grafana/build-container:1.2.2
+ - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -323,7 +312,7 @@ jobs:
deploy-enterprise-master:
docker:
- - image: grafana/grafana-ci-deploy:1.1.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
- attach_workspace:
at: .
@@ -346,7 +335,7 @@ jobs:
deploy-enterprise-release:
docker:
- - image: grafana/grafana-ci-deploy:1.1.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
- attach_workspace:
at: .
@@ -370,15 +359,15 @@ jobs:
command: './scripts/build/load-signing-key.sh'
- run:
name: Update Debian repository
- command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+ command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
- run:
name: Update RPM repository
- command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+ command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
deploy-master:
docker:
- - image: grafana/grafana-ci-deploy:1.1.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
- attach_workspace:
at: .
@@ -408,7 +397,7 @@ jobs:
deploy-release:
docker:
- - image: grafana/grafana-ci-deploy:1.1.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
- checkout
- attach_workspace:
@@ -433,10 +422,10 @@ jobs:
command: './scripts/build/load-signing-key.sh'
- run:
name: Update Debian repository
- command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+ command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
- run:
name: Update RPM repository
- command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+ command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
workflows:
version: 2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd0339d1991..67acea4e149 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,28 +1,44 @@
-# 5.5.0 (unreleased)
+# 6.0.0-beta1 (unreleased)
### New Features
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
* **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
+* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl)
* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
### Minor
+* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
+* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
+* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
+* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
-* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
-* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK]req(https://github.com/IntegersOfK)
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
-* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
+* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
+* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
+* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
+* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
-* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
-* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
+* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
+* **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484)
+* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
+* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
### Bug fixes
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
+* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
+* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy)
+
+### Breaking changes
+* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
+* **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991)
# 5.4.3 (2019-01-14)
diff --git a/Dockerfile b/Dockerfile
index c3af89b6092..c3e59c8048e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Golang build container
-FROM golang:1.11.4
+FROM golang:1.11.5
WORKDIR $GOPATH/src/github.com/grafana/grafana
@@ -19,11 +19,13 @@ COPY package.json package.json
RUN go run build.go build
# Node build container
-FROM node:8
+FROM node:10.14.2
WORKDIR /usr/src/app/
COPY package.json yarn.lock ./
+COPY packages packages
+
RUN yarn install --pure-lockfile --no-progress
COPY Gruntfile.js tsconfig.json tslint.json ./
diff --git a/ROADMAP.md b/ROADMAP.md
index 891bc9f790b..b5e62578475 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan.
### Short term (1-2 months)
- PRs & Bugs
- - Multi-Stat panel
+ - React Panel Support
+ - React Query Editor Support
- Metrics & Log Explore UI
-
+ - Grafana UI library shared between grafana & plugins
+ - Seperate visualization from panels
+ - More reuse between Explore & dashboard
+ - Explore logging support for more data sources
+
### Mid term (2-4 months)
- - React Panels
- - Change visualization (panel type) on the fly.
- - Templating Query Editor UI Plugin hook
- - Backend plugins
+ - Drilldown links
+ - Dashboards as code workflows
+ - React migration
+ - New panels
### Long term (4 - 8 months)
- Alerting improvements (silence, per series tracking, etc)
- - Progress on React migration
### In a distant future far far away
- Meta queries
diff --git a/appveyor.yml b/appveyor.yml
index 5f97784dd38..ccf9b5a06e1 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment:
nodejs_version: "8"
GOPATH: C:\gopath
- GOVERSION: 1.11.4
+ GOVERSION: 1.11.5
install:
- rmdir c:\go /s /q
diff --git a/build.go b/build.go
index 4486cd3deb9..ebe240d97ef 100644
--- a/build.go
+++ b/build.go
@@ -46,6 +46,8 @@ var (
binaries []string = []string{"grafana-server", "grafana-cli"}
isDev bool = false
enterprise bool = false
+ skipRpmGen bool = false
+ skipDebGen bool = false
)
func main() {
@@ -67,6 +69,8 @@ func main() {
flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
+ flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)")
+ flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)")
flag.Parse()
buildId = shortenBuildId(buildIdRaw)
@@ -165,6 +169,7 @@ func makeLatestDistCopies() {
".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm",
".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
+ ".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz",
".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
}
@@ -239,6 +244,8 @@ func createDebPackages() {
previousPkgArch := pkgArch
if pkgArch == "armv7" {
pkgArch = "armhf"
+ } else if pkgArch == "armv6" {
+ pkgArch = "armel"
}
createPackage(linuxPackageOptions{
packageType: "deb",
@@ -289,8 +296,13 @@ func createRpmPackages() {
}
func createLinuxPackages() {
- createDebPackages()
- createRpmPackages()
+ if !skipDebGen {
+ createDebPackages()
+ }
+
+ if !skipRpmGen {
+ createRpmPackages()
+ }
}
func createPackage(options linuxPackageOptions) {
diff --git a/conf/defaults.ini b/conf/defaults.ini
index 7f61ac96870..6fc4cf2e4de 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -106,6 +106,22 @@ path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+login_remember_days = 7
+
+# How often should the login token be rotated. default to '10m'
+rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+delete_expired_token_after_days = 30
+
#################################### Session #############################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -175,11 +191,6 @@ admin_password = admin
# used for signing
secret_key = SW2YcwTIb9zpOOhoPsMm
-# Auto-login remember days
-login_remember_days = 7
-cookie_username = grafana_user
-cookie_remember_name = grafana_remember
-
# disable gravatar profile images
disable_gravatar = false
@@ -189,6 +200,9 @@ data_source_proxy_whitelist =
# disable protection against brute force login attempts
disable_brute_force_login_protection = false
+# set cookies as https only. default is false
+https_flag_cookies = false
+
#################################### Snapshots ###########################
[snapshots]
# snapshot sharing options
@@ -570,6 +584,7 @@ callback_url =
[panels]
enable_alpha = false
+disable_sanitize_html = false
[enterprise]
license_path =
diff --git a/conf/sample.ini b/conf/sample.ini
index 014016d45bc..0f1c02dc231 100644
--- a/conf/sample.ini
+++ b/conf/sample.ini
@@ -102,6 +102,22 @@ log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+;cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+;login_remember_days = 7
+
+# How often should the login token be rotated. default to '10'
+;rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+;delete_expired_token_after_days = 30
+
#################################### Session ####################################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -162,11 +178,6 @@ log_queries =
# used for signing
;secret_key = SW2YcwTIb9zpOOhoPsMm
-# Auto-login remember days
-;login_remember_days = 7
-;cookie_username = grafana_user
-;cookie_remember_name = grafana_remember
-
# disable gravatar profile images
;disable_gravatar = false
@@ -176,6 +187,9 @@ log_queries =
# disable protection against brute force login attempts
;disable_brute_force_login_protection = false
+# set cookies as https only. default is false
+;https_flag_cookies = false
+
#################################### Snapshots ###########################
[snapshots]
# snapshot sharing options
@@ -495,3 +509,8 @@ log_queries =
# Path to a valid Grafana Enterprise license.jwt file
;license_path =
+[panels]
+;enable_alpha = false
+# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
+;disable_sanitize_html = false
+
diff --git a/devenv/dev-dashboards/panel_tests_gauge.json b/devenv/dev-dashboards/panel_tests_gauge.json
new file mode 100644
index 00000000000..c6e81ececc8
--- /dev/null
+++ b/devenv/dev-dashboards/panel_tests_gauge.json
@@ -0,0 +1,1250 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "iteration": 1547810606599,
+ "links": [],
+ "panels": [
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 11,
+ "panels": [],
+ "title": "Value options tests",
+ "type": "row"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 5,
+ "x": 0,
+ "y": 1
+ },
+ "id": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Average, 2 decimals, ms unit",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 5,
+ "y": 1
+ },
+ "id": 5,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "max",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Max (90 ms), no decimals",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 5,
+ "x": 11,
+ "y": 1
+ },
+ "id": 6,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "p",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "s",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Current (10 ms), no unit, prefix (p), suffix (s)",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 3,
+ "x": 16,
+ "y": 1
+ },
+ "id": 16,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 5,
+ "x": 19,
+ "y": 1
+ },
+ "id": 18,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,91"
+ }
+ ],
+ "timeFrom": "1h",
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 3,
+ "x": 16,
+ "y": 5
+ },
+ "id": 17,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 5,
+ "x": 19,
+ "y": 5
+ },
+ "id": 19,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,81"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 9
+ },
+ "id": 15,
+ "panels": [],
+ "title": "Value Mappings",
+ "type": "row"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 4,
+ "x": 0,
+ "y": 10
+ },
+ "id": 12,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "",
+ "id": 1,
+ "operator": "",
+ "text": "TEN",
+ "to": "",
+ "type": 1,
+ "value": "10"
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping 10 -> TEN",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "description": "should read N/A",
+ "gridPos": {
+ "h": 8,
+ "w": 4,
+ "x": 4,
+ "y": 10
+ },
+ "id": 13,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "",
+ "id": 1,
+ "operator": "",
+ "text": "N/A",
+ "to": "",
+ "type": 1,
+ "value": "null"
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,null,null,null,null"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping null -> N/A",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "description": "should read N/A",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 8,
+ "y": 10
+ },
+ "id": 20,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "0",
+ "id": 1,
+ "operator": "",
+ "text": "OK",
+ "to": "10",
+ "type": 2,
+ "value": "null"
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,null,null,null,null,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping range, 0-10 -> OK, value 10",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "description": "should read N/A",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 14,
+ "y": 10
+ },
+ "id": 21,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "0",
+ "id": 1,
+ "operator": "",
+ "text": "OK",
+ "to": "90",
+ "type": 2,
+ "value": "null"
+ },
+ {
+ "from": "90",
+ "id": 2,
+ "operator": "",
+ "text": "BAD",
+ "to": "100",
+ "type": 2,
+ "value": ""
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,null,null,null,null,10,95"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping range, 90-100 -> BAD, value 90",
+ "type": "gauge"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 18
+ },
+ "id": 9,
+ "panels": [],
+ "title": "Templating & Repeat",
+ "type": "row"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 0,
+ "y": 19
+ },
+ "id": 7,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": "Servers",
+ "repeatDirection": "h",
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server1",
+ "value": "server1"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 6,
+ "y": 19
+ },
+ "id": 22,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": null,
+ "repeatDirection": "h",
+ "repeatIteration": 1547810606599,
+ "repeatPanelId": 7,
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server2",
+ "value": "server2"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 12,
+ "y": 19
+ },
+ "id": 23,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": null,
+ "repeatDirection": "h",
+ "repeatIteration": 1547810606599,
+ "repeatPanelId": 7,
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server3",
+ "value": "server3"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 18,
+ "y": 19
+ },
+ "id": 24,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": null,
+ "repeatDirection": "h",
+ "repeatIteration": 1547810606599,
+ "repeatPanelId": 7,
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server4",
+ "value": "server4"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ }
+ ],
+ "refresh": false,
+ "schemaVersion": 17,
+ "style": "dark",
+ "tags": [
+ "gdev",
+ "panel-tests"
+ ],
+ "templating": {
+ "list": [
+ {
+ "allValue": null,
+ "current": {
+ "selected": true,
+ "tags": [],
+ "text": "All",
+ "value": [
+ "$__all"
+ ]
+ },
+ "hide": 0,
+ "includeAll": true,
+ "label": null,
+ "multi": true,
+ "name": "Servers",
+ "options": [
+ {
+ "selected": true,
+ "text": "All",
+ "value": "$__all"
+ },
+ {
+ "selected": false,
+ "text": "server1",
+ "value": "server1"
+ },
+ {
+ "selected": false,
+ "text": "server2",
+ "value": "server2"
+ },
+ {
+ "selected": false,
+ "text": "server3",
+ "value": "server3"
+ },
+ {
+ "selected": false,
+ "text": "server4",
+ "value": "server4"
+ }
+ ],
+ "query": "server1,server2,server3,server4",
+ "skipUrlSync": false,
+ "type": "custom"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": [
+ "5s",
+ "10s",
+ "30s",
+ "1m",
+ "5m",
+ "15m",
+ "30m",
+ "1h",
+ "2h",
+ "1d"
+ ],
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "",
+ "title": "Panel Tests - Gauge",
+ "uid": "_5rDmaQiz",
+ "version": 5
+}
diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml
index 1195e2a977c..504ee86404d 100644
--- a/devenv/docker/ha_test/docker-compose.yaml
+++ b/devenv/docker/ha_test/docker-compose.yaml
@@ -54,7 +54,8 @@ services:
# - GF_DATABASE_SSL_MODE=disable
# - GF_SESSION_PROVIDER=postgres
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
- - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
+ - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
+ - GF_LOGIN_ROTATE_TOKEN_MINUTES=2
ports:
- 3000
depends_on:
diff --git a/devenv/docker/loadtest/README.md b/devenv/docker/loadtest/README.md
new file mode 100644
index 00000000000..8e724637acb
--- /dev/null
+++ b/devenv/docker/loadtest/README.md
@@ -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
+```
diff --git a/devenv/docker/loadtest/auth_token_test.js b/devenv/docker/loadtest/auth_token_test.js
new file mode 100644
index 00000000000..e1356fb6f9a
--- /dev/null
+++ b/devenv/docker/loadtest/auth_token_test.js
@@ -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) => {}
diff --git a/devenv/docker/loadtest/modules/client.js b/devenv/docker/loadtest/modules/client.js
new file mode 100644
index 00000000000..bda0da64564
--- /dev/null
+++ b/devenv/docker/loadtest/modules/client.js
@@ -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));
+}
diff --git a/devenv/docker/loadtest/modules/util.js b/devenv/docker/loadtest/modules/util.js
new file mode 100644
index 00000000000..af6d4cdac09
--- /dev/null
+++ b/devenv/docker/loadtest/modules/util.js
@@ -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;
+}
diff --git a/devenv/docker/loadtest/run.sh b/devenv/docker/loadtest/run.sh
new file mode 100755
index 00000000000..474d75383b6
--- /dev/null
+++ b/devenv/docker/loadtest/run.sh
@@ -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 "$@"
diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md
index 541aed3fd1f..b6028b0a2a7 100644
--- a/docs/sources/auth/gitlab.md
+++ b/docs/sources/auth/gitlab.md
@@ -47,7 +47,7 @@ authentication:
```bash
[auth.gitlab]
-enabled = false
+enabled = true
allow_sign_up = false
client_id = GITLAB_APPLICATION_ID
client_secret = GITLAB_SECRET
diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md
index e2bcb50bb1d..22f9f38c854 100644
--- a/docs/sources/features/datasources/cloudwatch.md
+++ b/docs/sources/features/datasources/cloudwatch.md
@@ -38,7 +38,7 @@ Name | Description
### IAM Roles
-Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If you grafana
+Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If your Grafana
server is running on AWS you can use IAM Roles and authentication will be handled automatically.
Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md
index 9aaf29ec5f4..364b55b0cfc 100644
--- a/docs/sources/http_api/data_source.md
+++ b/docs/sources/http_api/data_source.md
@@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"defaultRegion": "us-west-1"
},
"secureJsonData": {
- "accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded
- "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded
+ "accessKey": "Ol4pIDpeKSA6XikgOl4p",
+ "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs"
}
}
```
diff --git a/docs/sources/http_api/other.md b/docs/sources/http_api/other.md
index 5bf0cde05fe..ea905bf88f0 100644
--- a/docs/sources/http_api/other.md
+++ b/docs/sources/http_api/other.md
@@ -82,4 +82,29 @@ HTTP/1.1 200
Content-Type: application/json
{"message": "Logged in"}
-```
\ No newline at end of file
+```
+
+# 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"
+}
+```
diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md
index 0e5a55b3c0e..46bab83654e 100644
--- a/docs/sources/installation/configuration.md
+++ b/docs/sources/installation/configuration.md
@@ -391,6 +391,12 @@ value is `true`.
If you want to track Grafana usage via Google analytics specify *your* Universal
Analytics ID here. By default this feature is disabled.
+### check_for_updates
+
+Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used
+in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor
+send any sensitive information.
+
## [dashboards]
@@ -589,3 +595,14 @@ Default setting for how Grafana handles nodata or null values in alerting. (aler
Alert notifications can include images, but rendering many images at the same time can overload the server.
This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
value is `5`.
+
+## [panels]
+
+### enable_alpha
+Set to true if you want to test panels that are not yet ready for general usage.
+
+### disable_sanitize_html
+If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. Default
+is false. This settings was introduced in Grafana v6.0.
+
+
diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md
index 71ce6bdd2ae..3ef32b1b10f 100644
--- a/docs/sources/reference/templating.md
+++ b/docs/sources/reference/templating.md
@@ -52,6 +52,7 @@ Filter Option | Example | Raw | Interpolated | Description
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
`distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
`lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
+`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1).
diff --git a/package.json b/package.json
index 470101ff0c4..c794375793b 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
- "version": "5.5.0-pre1",
+ "version": "6.0.0-pre1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"
@@ -188,7 +188,8 @@
"slate-react": "^0.12.4",
"tether": "^1.4.0",
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
- "tinycolor2": "^1.4.1"
+ "tinycolor2": "^1.4.1",
+ "xss": "^1.0.3"
},
"resolutions": {
"caniuse-db": "1.0.30000772",
diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx
index 6974eed142e..a225db09046 100644
--- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import _ from 'lodash';
import $ from 'jquery';
-import 'vendor/spectrum';
+import '../../vendor/spectrum';
export interface Props {
color: string;
diff --git a/public/sass/components/_color_picker.scss b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
similarity index 100%
rename from public/sass/components/_color_picker.scss
rename to packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
index cf1657e1c83..12b5ff8062e 100644
--- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
+++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
@@ -1,4 +1,5 @@
import React, { PureComponent } from 'react';
+import _ from 'lodash';
import Scrollbars from 'react-custom-scrollbars';
interface Props {
@@ -6,7 +7,11 @@ interface Props {
autoHide?: boolean;
autoHideTimeout?: number;
autoHideDuration?: number;
+ autoHeightMax?: string;
hideTracksWhenNotNeeded?: boolean;
+ scrollTop?: number;
+ setScrollTop: (event: any) => void;
+ autoHeightMin?: number | string;
}
/**
@@ -15,29 +20,72 @@ interface Props {
export class CustomScrollbar extends PureComponent {
static defaultProps: Partial = {
customClassName: 'custom-scrollbars',
- autoHide: true,
+ autoHide: false,
autoHideTimeout: 200,
autoHideDuration: 200,
+ setScrollTop: () => {},
hideTracksWhenNotNeeded: false,
+ autoHeightMin: '0',
+ autoHeightMax: '100%',
};
+ private ref: React.RefObject;
+
+ constructor(props: Props) {
+ super(props);
+ this.ref = React.createRef();
+ }
+
+ updateScroll() {
+ const ref = this.ref.current;
+
+ if (ref && !_.isNil(this.props.scrollTop)) {
+ if (this.props.scrollTop > 10000) {
+ ref.scrollToBottom();
+ } else {
+ ref.scrollTop(this.props.scrollTop);
+ }
+ }
+ }
+
+ componentDidMount() {
+ this.updateScroll();
+ }
+
+ componentDidUpdate() {
+ this.updateScroll();
+ }
+
render() {
- const { customClassName, children, ...scrollProps } = this.props;
+ const {
+ customClassName,
+ children,
+ autoHeightMax,
+ autoHeightMin,
+ setScrollTop,
+ autoHide,
+ autoHideTimeout,
+ hideTracksWhenNotNeeded,
+ } = this.props;
return (
}
renderTrackVertical={props =>
}
renderThumbHorizontal={props =>
}
renderThumbVertical={props =>
}
renderView={props =>
}
- {...scrollProps}
>
{children}
diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
index 0a7de5fcffe..60b4a2e0aa5 100644
--- a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
+++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
@@ -42,9 +42,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
Object {
"display": "none",
"height": 6,
- "opacity": 0,
"position": "absolute",
- "transition": "opacity 200ms",
}
}
>
@@ -64,9 +62,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
style={
Object {
"display": "none",
- "opacity": 0,
"position": "absolute",
- "transition": "opacity 200ms",
"width": 6,
}
}
diff --git a/packages/grafana-ui/src/components/FormField/FormField.test.tsx b/packages/grafana-ui/src/components/FormField/FormField.test.tsx
new file mode 100644
index 00000000000..3c89a347e86
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/FormField.test.tsx
@@ -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( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/packages/grafana-ui/src/components/FormField/FormField.tsx b/packages/grafana-ui/src/components/FormField/FormField.tsx
new file mode 100644
index 00000000000..593678c7383
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/FormField.tsx
@@ -0,0 +1,25 @@
+import React, { InputHTMLAttributes, FunctionComponent } from 'react';
+import { FormLabel } from '..';
+
+export interface Props extends InputHTMLAttributes {
+ label: string;
+ labelWidth?: number;
+ inputWidth?: number;
+}
+
+const defaultProps = {
+ labelWidth: 6,
+ inputWidth: 12,
+};
+
+const FormField: FunctionComponent = ({ label, labelWidth, inputWidth, ...inputProps }) => {
+ return (
+
+ {label}
+
+
+ );
+};
+
+FormField.defaultProps = defaultProps;
+export { FormField };
diff --git a/packages/grafana-ui/src/components/FormField/_FormField.scss b/packages/grafana-ui/src/components/FormField/_FormField.scss
new file mode 100644
index 00000000000..36955e2fca6
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/_FormField.scss
@@ -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;
+ }
+}
diff --git a/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap
new file mode 100644
index 00000000000..99eb0803149
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+ Test
+
+
+
+`;
diff --git a/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
new file mode 100644
index 00000000000..2bd4fbc153b
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
@@ -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 = ({
+ 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 (
+
+ {children}
+ {tooltip && (
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
new file mode 100644
index 00000000000..396b7a03162
--- /dev/null
+++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
@@ -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( );
+ 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 ');
+ });
+});
diff --git a/public/app/viz/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
similarity index 54%
rename from public/app/viz/Gauge.tsx
rename to packages/grafana-ui/src/components/Gauge/Gauge.tsx
index 5112ff9aa1b..2dce20543fd 100644
--- a/public/app/viz/Gauge.tsx
+++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
@@ -1,15 +1,15 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
-import { BasicGaugeColor, Threshold, TimeSeriesVMs, RangeMap, ValueMap, MappingType } from '@grafana/ui';
-import config from '../core/config';
-import kbn from '../core/utils/kbn';
+import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
+import { TimeSeriesVMs } from '../../types/series';
+import { getValueFormat } from '../../utils/valueFormats/valueFormats';
+import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
export interface Props {
- baseColor: string;
decimals: number;
height: number;
- mappings: Array;
+ valueMappings: ValueMapping[];
maxValue: number;
minValue: number;
prefix: string;
@@ -21,15 +21,15 @@ export interface Props {
suffix: string;
unit: string;
width: number;
+ theme?: ThemeName;
}
export class Gauge extends PureComponent {
canvasElement: any;
static defaultProps = {
- baseColor: BasicGaugeColor.Green,
maxValue: 100,
- mappings: [],
+ valueMappings: [],
minValue: 0,
prefix: '',
showThresholdMarkers: true,
@@ -38,6 +38,7 @@ export class Gauge extends PureComponent {
thresholds: [],
unit: 'none',
stat: 'avg',
+ theme: ThemeNames.Dark,
};
componentDidMount() {
@@ -48,91 +49,93 @@ export class Gauge extends PureComponent {
this.draw();
}
- formatWithMappings(mappings, value) {
- const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
- const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
+ formatValue(value: TimeSeriesValue) {
+ const { decimals, valueMappings, prefix, suffix, unit } = this.props;
- const valueMap = valueMaps.map(mapping => {
- if (mapping.value && value === mapping.value) {
- return mapping.text;
+ if (isNaN(value as number)) {
+ return value;
+ }
+
+ if (valueMappings.length > 0) {
+ const valueMappedValue = getMappedValue(valueMappings, value);
+ if (valueMappedValue) {
+ return `${prefix} ${valueMappedValue.text} ${suffix}`;
}
- })[0];
+ }
- const rangeMap = rangeMaps.map(mapping => {
- if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
- return mapping.text;
- }
- })[0];
+ const formatFunc = getValueFormat(unit);
+ const formattedValue = formatFunc(value as number, decimals);
+ const handleNoValueValue = formattedValue || 'no value';
- return {
- rangeMap,
- valueMap,
- };
+ return `${prefix} ${handleNoValueValue} ${suffix}`;
}
- formatValue(value) {
- const { decimals, mappings, prefix, suffix, unit } = this.props;
+ getFontColor(value: TimeSeriesValue) {
+ const { thresholds } = this.props;
- const formatFunc = kbn.valueFormats[unit];
- const formattedValue = formatFunc(value, decimals);
-
- if (mappings.length > 0) {
- const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
-
- if (valueMap) {
- return valueMap;
- } else if (rangeMap) {
- return rangeMap;
- }
+ if (thresholds.length === 1) {
+ return thresholds[0].color;
}
- if (isNaN(value)) {
- return '-';
+ const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
+ if (atThreshold) {
+ return atThreshold.color;
}
- return `${prefix} ${formattedValue} ${suffix}`;
+ const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
+
+ if (belowThreshold.length > 0) {
+ const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
+ return nearestThreshold.color;
+ }
+
+ return BasicGaugeColor.Red;
}
- getFontColor(value) {
- const { baseColor, maxValue, thresholds } = this.props;
+ getFormattedThresholds() {
+ const { maxValue, minValue, thresholds } = this.props;
- if (thresholds.length > 0) {
- const atThreshold = thresholds.filter(threshold => value <= threshold.value);
+ const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
+ const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
- if (atThreshold.length > 0) {
- return atThreshold[0].color;
- } else if (value <= maxValue) {
- return BasicGaugeColor.Red;
- }
- }
+ 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() {
const {
- baseColor,
maxValue,
minValue,
timeSeries,
showThresholdLabels,
showThresholdMarkers,
- thresholds,
width,
height,
stat,
+ theme,
} = this.props;
- let value: string | number = '';
+ let value: TimeSeriesValue = '';
if (timeSeries[0]) {
value = timeSeries[0].stats[stat];
} else {
- value = 'N/A';
+ value = null;
}
const dimension = Math.min(width, height * 1.3);
- const backgroundColor = config.bootData.user.lightTheme ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
+ const backgroundColor = theme === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
const fontScale = parseInt('80', 10) / 100;
const fontSize = Math.min(dimension / 5, 100) * fontScale;
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
@@ -140,20 +143,6 @@ export class Gauge extends PureComponent {
const thresholdMarkersWidth = gaugeWidth / 5;
const thresholdLabelFontSize = fontSize / 2.5;
- const formattedThresholds = [
- { value: minValue, color: BasicGaugeColor.Green },
- ...thresholds.map((threshold, index) => {
- return {
- value: threshold.value,
- color: index === 0 ? threshold.color : thresholds[index].color,
- };
- }),
- {
- value: maxValue,
- color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor,
- },
- ];
-
const options = {
series: {
gauges: {
@@ -170,7 +159,7 @@ export class Gauge extends PureComponent {
layout: { margin: 0, thresholdWidth: 0 },
cell: { border: { width: 0 } },
threshold: {
- values: formattedThresholds,
+ values: this.getFormattedThresholds(),
label: {
show: showThresholdLabels,
margin: thresholdMarkersWidth + 1,
@@ -184,19 +173,14 @@ export class Gauge extends PureComponent {
formatter: () => {
return this.formatValue(value);
},
- font: {
- size: fontSize,
- family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
- },
+ font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
},
show: true,
},
},
};
- const plotSeries = {
- data: [[0, value]],
- };
+ const plotSeries = { data: [[0, value]] };
try {
$.plot(this.canvasElement, [plotSeries], options);
diff --git a/packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx b/packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx
deleted file mode 100644
index 8b80de64696..00000000000
--- a/packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx
+++ /dev/null
@@ -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 = ({ 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 (
-
- {children}
-
- );
-};
diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
index 9f5d4f02695..87d5b00f3b1 100644
--- a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
+++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
@@ -6,7 +6,7 @@
}
.panel-options-group__header {
- padding: 4px 20px;
+ padding: 4px 8px;
font-size: 1.1rem;
background: $panel-options-group-header-bg;
position: relative;
diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx
index b3b0c8efbbb..6d83968d546 100644
--- a/packages/grafana-ui/src/components/Select/Select.tsx
+++ b/packages/grafana-ui/src/components/Select/Select.tsx
@@ -16,7 +16,7 @@ import SelectOptionGroup from './SelectOptionGroup';
import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage';
import resetSelectStyles from './resetSelectStyles';
-import { CustomScrollbar } from '@grafana/ui';
+import { CustomScrollbar } from '..';
export interface SelectOptionItem {
label?: string;
@@ -61,7 +61,7 @@ interface AsyncProps {
export const MenuList = (props: any) => {
return (
- {props.children}
+ {props.children}
);
};
@@ -202,7 +202,7 @@ export class AsyncSelect extends PureComponent {
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
- Option,
+ Option: SelectOption,
SingleValue,
IndicatorsContainer,
NoOptionsMessage,
diff --git a/packages/grafana-ui/src/components/Select/_Select.scss b/packages/grafana-ui/src/components/Select/_Select.scss
index bf18125d7b8..bc18ed9d369 100644
--- a/packages/grafana-ui/src/components/Select/_Select.scss
+++ b/packages/grafana-ui/src/components/Select/_Select.scss
@@ -102,6 +102,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__value-container {
display: table-cell;
padding: 6px 10px;
+ vertical-align: middle;
> div {
display: inline-block;
}
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
index 14f84e00f80..845ff5f6bf4 100644
--- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
@@ -2,7 +2,6 @@ import React from 'react';
import { shallow } from 'enzyme';
import { ThresholdsEditor, Props } from './ThresholdsEditor';
-import { BasicGaugeColor } from '../../types';
const setup = (propOverrides?: object) => {
const props: Props = {
@@ -15,49 +14,160 @@ const setup = (propOverrides?: object) => {
return shallow( ).instance() as ThresholdsEditor;
};
+describe('Initialization', () => {
+ it('should add a base threshold if missing', () => {
+ const instance = setup();
+
+ expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+ });
+});
+
describe('Add threshold', () => {
- it('should add threshold', () => {
+ it('should not add threshold at index 0', () => {
const instance = setup();
instance.onAddThreshold(0);
- expect(instance.state.thresholds).toEqual([{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }]);
+ expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
});
- it('should add another threshold above a first', () => {
- const instance = setup({
- thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }],
- });
+ it('should add threshold', () => {
+ const instance = setup();
instance.onAddThreshold(1);
expect(instance.state.thresholds).toEqual([
- { index: 1, value: 75, color: 'rgb(170, 95, 61)' },
- { index: 0, value: 50, color: 'rgb(127, 115, 64)' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ]);
+ });
+
+ it('should add another threshold above a first', () => {
+ const instance = setup({
+ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
+ });
+
+ instance.onAddThreshold(2);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 2, value: 75, color: '#6ED0E0' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ]);
+ });
+
+ it('should add another threshold between first and second index', () => {
+ const instance = setup({
+ thresholds: [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ],
+ });
+
+ instance.onAddThreshold(2);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 3, value: 75, color: '#6ED0E0' },
+ { index: 2, value: 62.5, color: '#EF843C' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ]);
+ });
+});
+
+describe('Remove threshold', () => {
+ it('should not remove threshold at index 0', () => {
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+ const instance = setup({ thresholds });
+
+ instance.onRemoveThreshold(thresholds[0]);
+
+ expect(instance.state.thresholds).toEqual(thresholds);
+ });
+
+ it('should remove threshold', () => {
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+ const instance = setup({
+ thresholds,
+ });
+
+ instance.onRemoveThreshold(thresholds[1]);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 75, color: '#6ED0E0' },
]);
});
});
describe('change threshold value', () => {
- it('should update value and resort rows', () => {
+ it('should not change threshold at index 0', () => {
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+ const instance = setup({ thresholds });
+
+ const mockEvent = { target: { value: 12 } };
+
+ instance.onChangeThresholdValue(mockEvent, thresholds[0]);
+
+ expect(instance.state.thresholds).toEqual(thresholds);
+ });
+
+ it('should update value', () => {
const instance = setup();
- const mockThresholds = [
- { index: 0, value: 50, color: 'rgba(237, 129, 40, 0.89)' },
- { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
];
instance.state = {
- baseColor: BasicGaugeColor.Green,
- thresholds: mockThresholds,
+ thresholds,
};
const mockEvent = { target: { value: 78 } };
- instance.onChangeThresholdValue(mockEvent, mockThresholds[0]);
+ instance.onChangeThresholdValue(mockEvent, thresholds[1]);
expect(instance.state.thresholds).toEqual([
- { index: 0, value: 78, color: 'rgba(237, 129, 40, 0.89)' },
- { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 78, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ]);
+ });
+});
+
+describe('on blur threshold value', () => {
+ it('should resort rows and update indexes', () => {
+ const instance = setup();
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 78, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+
+ instance.state = {
+ thresholds,
+ };
+
+ instance.onBlur();
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 2, value: 78, color: '#EAB839' },
+ { index: 1, value: 75, color: '#6ED0E0' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
]);
});
});
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
index c635b9cb4f5..590aca5c7a1 100644
--- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
@@ -1,9 +1,10 @@
import React, { PureComponent } from 'react';
-import tinycolor, { ColorInput } from 'tinycolor2';
+// import tinycolor, { ColorInput } from 'tinycolor2';
-import { Threshold, BasicGaugeColor } from '../../types';
+import { Threshold } from '../../types';
import { ColorPicker } from '../ColorPicker/ColorPicker';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+import { colors } from '../../utils';
export interface Props {
thresholds: Threshold[];
@@ -12,50 +13,49 @@ export interface Props {
interface State {
thresholds: Threshold[];
- baseColor: string;
}
export class ThresholdsEditor extends PureComponent {
constructor(props: Props) {
super(props);
- this.state = { thresholds: props.thresholds, baseColor: BasicGaugeColor.Green };
+ const addDefaultThreshold = this.props.thresholds.length === 0;
+ const thresholds: Threshold[] = addDefaultThreshold
+ ? [{ index: 0, value: -Infinity, color: colors[0] }]
+ : props.thresholds;
+ this.state = { thresholds };
+
+ if (addDefaultThreshold) {
+ this.onChange();
+ }
}
onAddThreshold = (index: number) => {
- const maxValue = 100; // hardcoded for now before we add the base threshold
- const minValue = 0; // hardcoded for now before we add the base threshold
const { thresholds } = this.state;
+ const maxValue = 100;
+ const minValue = 0;
+
+ if (index === 0) {
+ return;
+ }
const newThresholds = thresholds.map(threshold => {
if (threshold.index >= index) {
- threshold = {
- ...threshold,
- index: threshold.index + 1,
- };
+ const index = threshold.index + 1;
+ threshold = { ...threshold, index };
}
-
return threshold;
});
// Setting value to a value between the previous thresholds
- let value;
+ const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0];
+ const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0];
+ const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue;
+ const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue;
+ const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2;
- if (index === 0 && thresholds.length === 0) {
- value = maxValue - (maxValue - minValue) / 2;
- } else if (index === 0 && thresholds.length > 0) {
- value = newThresholds[index + 1].value - (newThresholds[index + 1].value - minValue) / 2;
- } else if (index > newThresholds[newThresholds.length - 1].index) {
- value = maxValue - (maxValue - newThresholds[index - 1].value) / 2;
- }
-
- // Set a color that lies between the previous thresholds
- let color;
- if (index === 0 && thresholds.length === 0) {
- color = tinycolor.mix(BasicGaugeColor.Green, BasicGaugeColor.Red, 50).toRgbString();
- } else {
- color = tinycolor.mix(thresholds[index - 1].color as ColorInput, BasicGaugeColor.Red, 50).toRgbString();
- }
+ // Set a color
+ const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0];
this.setState(
{
@@ -68,23 +68,45 @@ export class ThresholdsEditor extends PureComponent {
},
]),
},
- () => this.updateGauge()
+ () => this.onChange()
);
};
onRemoveThreshold = (threshold: Threshold) => {
+ if (threshold.index === 0) {
+ return;
+ }
+
this.setState(
- prevState => ({ thresholds: prevState.thresholds.filter(t => t !== threshold) }),
- () => this.updateGauge()
+ prevState => {
+ const newThresholds = prevState.thresholds.map(t => {
+ if (t.index > threshold.index) {
+ const index = t.index - 1;
+ t = { ...t, index };
+ }
+ return t;
+ });
+
+ return {
+ thresholds: newThresholds.filter(t => t !== threshold),
+ };
+ },
+ () => this.onChange()
);
};
onChangeThresholdValue = (event: any, threshold: Threshold) => {
+ if (threshold.index === 0) {
+ return;
+ }
+
const { thresholds } = this.state;
+ const parsedValue = parseInt(event.target.value, 10);
+ const value = isNaN(parsedValue) ? null : parsedValue;
const newThresholds = thresholds.map(t => {
- if (t === threshold) {
- t = { ...t, value: event.target.value };
+ if (t === threshold && t.index !== 0) {
+ t = { ...t, value: value as number };
}
return t;
@@ -108,18 +130,24 @@ export class ThresholdsEditor extends PureComponent {
{
thresholds: newThresholds,
},
- () => this.updateGauge()
+ () => this.onChange()
);
};
- onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
onBlur = () => {
- this.setState(prevState => ({ thresholds: this.sortThresholds(prevState.thresholds) }));
+ this.setState(prevState => {
+ const sortThresholds = this.sortThresholds([...prevState.thresholds]);
+ let index = sortThresholds.length - 1;
+ sortThresholds.forEach(t => {
+ t.index = index--;
+ });
+ return { thresholds: sortThresholds };
+ });
- this.updateGauge();
+ this.onChange();
};
- updateGauge = () => {
+ onChange = () => {
this.props.onChange(this.state.thresholds);
};
@@ -129,92 +157,53 @@ export class ThresholdsEditor extends PureComponent {
});
};
- renderThresholds() {
- const { thresholds } = this.state;
-
- return thresholds.map((threshold, index) => {
- return (
-
-
-
- {threshold.color && (
-
- this.onChangeThresholdColor(threshold, color)}
- />
-
- )}
-
-
this.onChangeThresholdValue(event, threshold)}
- value={threshold.value}
- onBlur={this.onBlur}
- />
-
this.onRemoveThreshold(threshold)} className="threshold-row-remove">
-
-
-
-
- );
- });
- }
-
- renderIndicator() {
- const { thresholds } = this.state;
-
- return thresholds.map((t, i) => {
- return (
-
-
this.onAddThreshold(t.index + 1)} style={{ height: '50%', backgroundColor: t.color }} />
-
this.onAddThreshold(t.index)} style={{ height: '50%', backgroundColor: t.color }} />
-
- );
- });
- }
-
- renderBaseIndicator() {
+ renderInput = (threshold: Threshold) => {
+ const value = threshold.index === 0 ? 'Base' : threshold.value;
return (
-
-
this.onAddThreshold(0)}
- style={{ height: '100%', backgroundColor: BasicGaugeColor.Green }}
- />
+
+
+
+ {threshold.color && (
+
+ this.onChangeThresholdColor(threshold, color)} />
+
+ )}
+
+
+ this.onChangeThresholdValue(event, threshold)}
+ value={value}
+ onBlur={this.onBlur}
+ readOnly={threshold.index === 0}
+ />
+
+ {threshold.index > 0 && (
+
this.onRemoveThreshold(threshold)}>
+
+
+ )}
);
- }
-
- renderBase() {
- const baseColor = BasicGaugeColor.Green;
-
- return (
-
-
-
-
- this.onChangeBaseColor(color)} />
-
-
-
Base
-
-
- );
- }
+ };
render() {
+ const { thresholds } = this.state;
+
return (
-
- {this.renderIndicator()}
- {this.renderBaseIndicator()}
-
-
- {this.renderThresholds()}
- {this.renderBase()}
-
+ {thresholds.map((threshold, index) => {
+ return (
+
+
this.onAddThreshold(threshold.index + 1)}>
+
+
+
+
{this.renderInput(threshold)}
+
+ );
+ })}
);
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
index ff89a6b6ea6..61278321572 100644
--- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
@@ -1,46 +1,90 @@
.thresholds {
+ margin-bottom: 10px;
+}
+
+.thresholds-row {
display: flex;
+ flex-direction: row;
+ height: 70px;
}
-.threshold-rows {
- margin-left: 5px;
+.thresholds-row:first-child > .thresholds-row-color-indicator {
+ border-top-left-radius: $border-radius;
+ border-top-right-radius: $border-radius;
+ overflow: hidden;
}
-.threshold-row {
+.thresholds-row:last-child > .thresholds-row-color-indicator {
+ border-bottom-left-radius: $border-radius;
+ border-bottom-right-radius: $border-radius;
+ overflow: hidden;
+}
+
+.thresholds-row-add-button {
+ align-self: center;
+ margin-right: 5px;
+ color: $green;
+ height: 24px;
+ width: 24px;
+ background-color: $green;
+ border-radius: 50%;
display: flex;
align-items: center;
- margin-top: 3px;
- padding: 5px;
-
- &::before {
- font-family: 'FontAwesome';
- content: '\f0d9';
- color: $input-label-border-color;
- }
+ justify-content: center;
+ cursor: pointer;
}
-.threshold-row-inner {
- border: 1px solid $input-label-border-color;
- border-radius: $border-radius;
+.thresholds-row-add-button > i {
+ color: $white;
+}
+
+.thresholds-row-color-indicator {
+ width: 10px;
+}
+
+.thresholds-row-input {
+ margin-top: 49px;
+ margin-left: 2px;
+}
+
+.thresholds-row-input-inner {
display: flex;
- overflow: hidden;
- height: 37px;
-
- &--base {
- width: auto;
- }
+ justify-content: center;
+ flex-direction: row;
}
-.threshold-row-color {
- width: 36px;
- border-right: 1px solid $input-label-border-color;
+.thresholds-row-input-inner > *:last-child {
+ border-top-right-radius: $border-radius;
+ border-bottom-right-radius: $border-radius;
+}
+
+.thresholds-row-input-inner-arrow {
+ align-self: center;
+ width: 0;
+ height: 0;
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-right: 6px solid $input-label-border-color;
+}
+
+.thresholds-row-input-inner-value > input {
+ height: $gf-form-input-height;
+ padding: $input-padding-y $input-padding-x;
+ width: 150px;
+ border-top: 1px solid $input-label-border-color;
+ border-bottom: 1px solid $input-label-border-color;
+}
+
+.thresholds-row-input-inner-color {
+ width: 42px;
display: flex;
align-items: center;
justify-content: center;
background-color: $input-bg;
+ border: 1px solid $input-label-border-color;
}
-.threshold-row-color-inner {
+.thresholds-row-input-inner-color-colorpicker {
border-radius: 10px;
overflow: hidden;
display: flex;
@@ -48,56 +92,14 @@
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
-.threshold-row-input {
- padding: 8px 10px;
- width: 150px;
-}
-
-.threshold-row-label {
+.thresholds-row-input-inner-remove {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: $gf-form-input-height;
+ padding: $input-padding-y $input-padding-x;
+ width: 42px;
background-color: $input-label-bg;
- padding: 5px;
- display: flex;
- align-items: center;
-}
-
-.threshold-row-add-label {
- align-items: center;
- display: flex;
- padding: 5px 8px;
-}
-
-.threshold-row-remove {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 37px;
- width: 37px;
+ border: 1px solid $input-label-border-color;
cursor: pointer;
}
-
-.threshold-row-add {
- border-right: $border-width solid $input-label-border-color;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 36px;
- background-color: $green;
-}
-
-.threshold-row-label {
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
-}
-
-.indicator-section {
- width: 100%;
- height: 50px;
- cursor: pointer;
-}
-
-.color-indicators {
- width: 15px;
- border-bottom-left-radius: $border-radius;
- border-bottom-right-radius: $border-radius;
- overflow: hidden;
-}
diff --git a/public/app/plugins/panel/gauge/MappingRow.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
similarity index 51%
rename from public/app/plugins/panel/gauge/MappingRow.tsx
rename to packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
index b975821f27a..c5704e8bc88 100644
--- a/public/app/plugins/panel/gauge/MappingRow.tsx
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
@@ -1,22 +1,22 @@
-import React, { PureComponent } from 'react';
-import { MappingType, RangeMap, Select, ValueMap } from '@grafana/ui';
+import React, { ChangeEvent, PureComponent } from 'react';
-import { Label } from 'app/core/components/Label/Label';
+import { MappingType, ValueMapping } from '../../types';
+import { FormField, FormLabel, Select } from '..';
-interface Props {
- mapping: ValueMap | RangeMap;
- updateMapping: (mapping) => void;
- removeMapping: () => void;
+export interface Props {
+ valueMapping: ValueMapping;
+ updateValueMapping: (valueMapping: ValueMapping) => void;
+ removeValueMapping: () => void;
}
interface State {
- from: string;
+ from?: string;
id: number;
operator: string;
text: string;
- to: string;
+ to?: string;
type: MappingType;
- value: string;
+ value?: string;
}
const mappingOptions = [
@@ -25,36 +25,34 @@ const mappingOptions = [
];
export default class MappingRow extends PureComponent
{
- constructor(props) {
+ constructor(props: Props) {
super(props);
- this.state = {
- ...props.mapping,
- };
+ this.state = { ...props.valueMapping };
}
- onMappingValueChange = event => {
+ onMappingValueChange = (event: ChangeEvent) => {
this.setState({ value: event.target.value });
};
- onMappingFromChange = event => {
+ onMappingFromChange = (event: ChangeEvent) => {
this.setState({ from: event.target.value });
};
- onMappingToChange = event => {
+ onMappingToChange = (event: ChangeEvent) => {
this.setState({ to: event.target.value });
};
- onMappingTextChange = event => {
+ onMappingTextChange = (event: ChangeEvent) => {
this.setState({ text: event.target.value });
};
- onMappingTypeChange = mappingType => {
+ onMappingTypeChange = (mappingType: MappingType) => {
this.setState({ type: mappingType });
};
updateMapping = () => {
- this.props.updateMapping({ ...this.state });
+ this.props.updateValueMapping({ ...this.state } as ValueMapping);
};
renderRow() {
@@ -63,30 +61,28 @@ export default class MappingRow extends PureComponent {
if (type === MappingType.RangeToText) {
return (
<>
-
-
From
+
+
+
+ Text
-
-
- To
-
-
-
- Text
-
@@ -96,17 +92,16 @@ export default class MappingRow extends PureComponent
{
return (
<>
-
- Value
-
-
+
-
Text
+
Text
{
return (
- Type
+ Type
{
{this.renderRow()}
-
+
diff --git a/public/app/plugins/panel/gauge/ValueMappings.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
similarity index 59%
rename from public/app/plugins/panel/gauge/ValueMappings.test.tsx
rename to packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
index 07db4028c68..bbad3e5a7ca 100644
--- a/public/app/plugins/panel/gauge/ValueMappings.test.tsx
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
@@ -1,27 +1,23 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { GaugeOptions, MappingType, PanelOptionsProps } from '@grafana/ui';
-import { defaultProps } from 'app/plugins/panel/gauge/GaugePanelOptions';
-import ValueMappings from './ValueMappings';
+import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
+import { MappingType } from '../../types/panel';
const setup = (propOverrides?: object) => {
- const props: PanelOptionsProps
= {
+ const props: Props = {
onChange: jest.fn(),
- options: {
- ...defaultProps.options,
- mappings: [
- { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
- { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
- ],
- },
+ valueMappings: [
+ { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
+ { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
+ ],
};
Object.assign(props, propOverrides);
- const wrapper = shallow( );
+ const wrapper = shallow( );
- const instance = wrapper.instance() as ValueMappings;
+ const instance = wrapper.instance() as ValueMappingsEditor;
return {
instance,
@@ -40,18 +36,20 @@ describe('Render', () => {
describe('On remove mapping', () => {
it('Should remove mapping with id 0', () => {
const { instance } = setup();
+
instance.onRemoveMapping(1);
- expect(instance.state.mappings).toEqual([
+ expect(instance.state.valueMappings).toEqual([
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
]);
});
it('should remove mapping with id 1', () => {
const { instance } = setup();
+
instance.onRemoveMapping(2);
- expect(instance.state.mappings).toEqual([
+ expect(instance.state.valueMappings).toEqual([
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
]);
});
@@ -67,7 +65,7 @@ describe('Next id to add', () => {
});
it('should default to 1', () => {
- const { instance } = setup({ options: { ...defaultProps.options } });
+ const { instance } = setup({ valueMappings: [] });
expect(instance.state.nextIdToAdd).toEqual(1);
});
diff --git a/public/app/plugins/panel/gauge/ValueMappings.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
similarity index 50%
rename from public/app/plugins/panel/gauge/ValueMappings.tsx
rename to packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
index 9a3f87450f4..ca0a6e71f4a 100644
--- a/public/app/plugins/panel/gauge/ValueMappings.tsx
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
@@ -1,33 +1,39 @@
import React, { PureComponent } from 'react';
-import { GaugeOptions, PanelOptionsProps, MappingType, RangeMap, ValueMap, PanelOptionsGroup } from '@grafana/ui';
import MappingRow from './MappingRow';
+import { MappingType, ValueMapping } from '../../types/panel';
+import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+
+export interface Props {
+ valueMappings: ValueMapping[];
+ onChange: (valueMappings: ValueMapping[]) => void;
+}
interface State {
- mappings: Array;
+ valueMappings: ValueMapping[];
nextIdToAdd: number;
}
-export default class ValueMappings extends PureComponent, State> {
- constructor(props) {
+export class ValueMappingsEditor extends PureComponent {
+ constructor(props: Props) {
super(props);
- const mappings = props.options.mappings;
+ const mappings = props.valueMappings;
this.state = {
- mappings: mappings || [],
- nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1,
+ valueMappings: mappings,
+ nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromValueMappings(mappings) : 1,
};
}
- getMaxIdFromMappings(mappings) {
+ getMaxIdFromValueMappings(mappings: ValueMapping[]) {
return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
}
addMapping = () =>
this.setState(prevState => ({
- mappings: [
- ...prevState.mappings,
+ valueMappings: [
+ ...prevState.valueMappings,
{
id: prevState.nextIdToAdd,
operator: '',
@@ -41,23 +47,23 @@ export default class ValueMappings extends PureComponent {
+ onRemoveMapping = (id: number) => {
this.setState(
prevState => ({
- mappings: prevState.mappings.filter(m => {
+ valueMappings: prevState.valueMappings.filter(m => {
return m.id !== id;
}),
}),
() => {
- this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+ this.props.onChange(this.state.valueMappings);
}
);
};
- updateGauge = mapping => {
+ updateGauge = (mapping: ValueMapping) => {
this.setState(
prevState => ({
- mappings: prevState.mappings.map(m => {
+ valueMappings: prevState.valueMappings.map(m => {
if (m.id === mapping.id) {
return { ...mapping };
}
@@ -66,24 +72,24 @@ export default class ValueMappings extends PureComponent {
- this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+ this.props.onChange(this.state.valueMappings);
}
);
};
render() {
- const { mappings } = this.state;
+ const { valueMappings } = this.state;
return (
- {mappings.length > 0 &&
- mappings.map((mapping, index) => (
+ {valueMappings.length > 0 &&
+ valueMappings.map((valueMapping, index) => (
this.onRemoveMapping(mapping.id)}
+ key={`${valueMapping.text}-${index}`}
+ valueMapping={valueMapping}
+ updateValueMapping={this.updateGauge}
+ removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
/>
))}
diff --git a/public/sass/components/_value-mappings.scss b/packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss
similarity index 100%
rename from public/sass/components/_value-mappings.scss
rename to packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss
diff --git a/public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
similarity index 81%
rename from public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap
rename to packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
index 592b3326421..8a465ff88df 100644
--- a/public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
@@ -7,7 +7,9 @@ exports[`Render should render component 1`] = `
{
+ 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;
+}
diff --git a/packages/grafana-ui/src/types/gauge.ts b/packages/grafana-ui/src/types/gauge.ts
deleted file mode 100644
index e05849448f7..00000000000
--- a/packages/grafana-ui/src/types/gauge.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { RangeMap, Threshold, ValueMap } from './panel';
-
-export interface GaugeOptions {
- baseColor: string;
- decimals: number;
- mappings: Array
;
- maxValue: number;
- minValue: number;
- prefix: string;
- showThresholdLabels: boolean;
- showThresholdMarkers: boolean;
- stat: string;
- suffix: string;
- thresholds: Threshold[];
- unit: string;
-}
diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts
index 814ab0478db..eaa39fb080b 100644
--- a/packages/grafana-ui/src/types/index.ts
+++ b/packages/grafana-ui/src/types/index.ts
@@ -1,4 +1,5 @@
export * from './series';
export * from './time';
export * from './panel';
-export * from './gauge';
+export * from './plugin';
+export * from './datasource';
diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts
index 0b995f932f0..881bf920c27 100644
--- a/packages/grafana-ui/src/types/panel.ts
+++ b/packages/grafana-ui/src/types/panel.ts
@@ -1,6 +1,8 @@
import { TimeSeries, LoadingState } from './series';
import { TimeRange } from './time';
+export type InterpolateFunction = (value: string, format?: string | Function) => string;
+
export interface PanelProps {
timeSeries: TimeSeries[];
timeRange: TimeRange;
@@ -9,6 +11,7 @@ export interface PanelProps {
renderCounter: number;
width: number;
height: number;
+ onInterpolate: InterpolateFunction;
}
export interface PanelOptionsProps {
@@ -53,6 +56,8 @@ interface BaseMap {
type: MappingType;
}
+export type ValueMapping = ValueMap | RangeMap;
+
export interface ValueMap extends BaseMap {
value: string;
}
@@ -61,3 +66,10 @@ export interface RangeMap extends BaseMap {
from: string;
to: string;
}
+
+export type ThemeName = 'dark' | 'light';
+
+export enum ThemeNames {
+ Dark = 'dark',
+ Light = 'light',
+}
diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts
new file mode 100644
index 00000000000..420a54e5840
--- /dev/null
+++ b/packages/grafana-ui/src/types/plugin.ts
@@ -0,0 +1,118 @@
+import { ComponentClass } from 'react';
+import { PanelProps, PanelOptionsProps } from './panel';
+import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint } from './datasource';
+
+export interface DataSourceApi {
+ /**
+ * min interval range
+ */
+ interval?: string;
+
+ /**
+ * Imports queries from a different datasource
+ */
+ importQueries?(queries: TQuery[], originMeta: PluginMeta): Promise;
+
+ /**
+ * Initializes a datasource after instantiation
+ */
+ init?: () => void;
+
+ /**
+ * Main metrics / data query action
+ */
+ query(options: DataQueryOptions): Promise;
+
+ /**
+ * Test & verify datasource settings & connection details
+ */
+ testDatasource(): Promise;
+
+ /**
+ * 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 {
+ datasource: DSType;
+ query: TQuery;
+ onExecuteQuery?: () => void;
+ onQueryChange?: (value: TQuery) => void;
+}
+
+export interface PluginExports {
+ Datasource?: DataSourceApi;
+ QueryCtrl?: any;
+ QueryEditor?: ComponentClass>;
+ ConfigCtrl?: any;
+ AnnotationsQueryCtrl?: any;
+ VariableQueryEditor?: any;
+ ExploreQueryField?: any;
+ ExploreStartPage?: any;
+
+ // Panel plugin
+ PanelCtrl?: any;
+ Panel?: ComponentClass;
+ PanelOptions?: ComponentClass;
+ 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;
+}
+
+
diff --git a/packages/grafana-ui/src/types/series.ts b/packages/grafana-ui/src/types/series.ts
index 49662e9872d..5cad1e4a72a 100644
--- a/packages/grafana-ui/src/types/series.ts
+++ b/packages/grafana-ui/src/types/series.ts
@@ -21,9 +21,12 @@ export interface TimeSeriesVM {
color: string;
data: TimeSeriesValue[][];
stats: TimeSeriesStats;
+ allIsNull: boolean;
+ allIsZero: boolean;
}
export interface TimeSeriesStats {
+ [key: string]: number | null;
total: number | null;
max: number | null;
min: number | null;
@@ -36,8 +39,6 @@ export interface TimeSeriesStats {
range: number | null;
timeStep: number;
count: number;
- allIsNull: boolean;
- allIsZero: boolean;
}
export enum NullValueMode {
diff --git a/packages/grafana-ui/src/utils/processTimeSeries.ts b/packages/grafana-ui/src/utils/processTimeSeries.ts
index e92aaf0c1a6..f5389f1b2bd 100644
--- a/packages/grafana-ui/src/utils/processTimeSeries.ts
+++ b/packages/grafana-ui/src/utils/processTimeSeries.ts
@@ -1,18 +1,19 @@
// Libraries
import _ from 'lodash';
+import { colors } from './colors';
+
// Types
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
interface Options {
timeSeries: TimeSeries[];
nullValueMode: NullValueMode;
- colorPalette: string[];
}
-export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: Options): TimeSeriesVMs {
+export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
const vmSeries = timeSeries.map((item, index) => {
- const colorIndex = index % colorPalette.length;
+ const colorIndex = index % colors.length;
const label = item.target;
const result = [];
@@ -49,8 +50,8 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
continue;
}
- if (typeof currentValue !== 'number') {
- continue;
+ if (currentValue !== null && typeof currentValue !== 'number') {
+ throw {message: 'Time series contains non number values'};
}
// Due to missing values we could have different timeStep all along the series
@@ -150,7 +151,9 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
return {
data: result,
label: label,
- color: colorPalette[colorIndex],
+ color: colors[colorIndex],
+ allIsZero,
+ allIsNull,
stats: {
total,
min,
@@ -164,8 +167,6 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
range,
count,
first,
- allIsZero,
- allIsNull,
},
};
});
diff --git a/packages/grafana-ui/src/utils/valueMappings.test.ts b/packages/grafana-ui/src/utils/valueMappings.test.ts
new file mode 100644
index 00000000000..d37e0beedab
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueMappings.test.ts
@@ -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: '', type: MappingType.ValueToText, value: 'null' },
+ ];
+ const value = null;
+
+ expect(getMappedValue(valueMappings, value).text).toEqual('');
+ });
+
+ it('should return if value is null and range to text mapping from and to is null', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '', 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('');
+ });
+
+ 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');
+ });
+});
diff --git a/packages/grafana-ui/src/utils/valueMappings.ts b/packages/grafana-ui/src/utils/valueMappings.ts
new file mode 100644
index 00000000000..c9b926ea0a4
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueMappings.ts
@@ -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];
+};
diff --git a/public/vendor/css/spectrum.css b/packages/grafana-ui/src/vendor/spectrum.css
similarity index 100%
rename from public/vendor/css/spectrum.css
rename to packages/grafana-ui/src/vendor/spectrum.css
diff --git a/public/vendor/spectrum.js b/packages/grafana-ui/src/vendor/spectrum.js
similarity index 100%
rename from public/vendor/spectrum.js
rename to packages/grafana-ui/src/vendor/spectrum.js
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 0526ee80afe..07cb712f794 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() {
// not logged in views
r.Get("/", reqSignedIn, hs.Index)
- r.Get("/logout", Logout)
- r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
- r.Get("/login/:name", quota("session"), OAuthLogin)
+ r.Get("/logout", hs.Logout)
+ r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost))
+ r.Get("/login/:name", quota("session"), hs.OAuthLogin)
r.Get("/login", hs.LoginView)
r.Get("/invite/:code", hs.Index)
@@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/signup", hs.Index)
r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
- r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
+ r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2))
// invited
r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
- r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
+ r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite))
// reset password
r.Get("/user/password/send-reset-email", hs.Index)
@@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
// api renew session based on remember cookie
- r.Get("/api/login/ping", quota("session"), LoginAPIPing)
+ r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
// authed api
r.Group("/api", func(apiRoute routing.RouteRegister) {
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index 8b66a7a468b..eb1f89e3f22 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -5,7 +5,6 @@ import (
"net/http/httptest"
"path/filepath"
- "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
@@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
}
type scenarioContext struct {
- m *macaron.Macaron
- context *m.ReqContext
- resp *httptest.ResponseRecorder
- handlerFunc handlerFunc
- defaultHandler macaron.Handler
- req *http.Request
- url string
+ m *macaron.Macaron
+ context *m.ReqContext
+ resp *httptest.ResponseRecorder
+ handlerFunc handlerFunc
+ defaultHandler macaron.Handler
+ req *http.Request
+ url string
+ userAuthTokenService *fakeUserAuthTokenService
}
func (sc *scenarioContext) exec() {
@@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
- sc.m.Use(middleware.GetContextHandler())
- sc.m.Use(middleware.Sessioner(&session.Options{}, 0))
+ sc.userAuthTokenService = newFakeUserAuthTokenService()
+ sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
return sc
}
+
+type fakeUserAuthTokenService struct {
+ initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+}
+
+func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
+ return &fakeUserAuthTokenService{
+ initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
+ return false
+ },
+ }
+}
+
+func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
+ return s.initContextWithTokenProvider(ctx, orgID)
+}
+
+func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
+ return nil
+}
+
+func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go
index 2789b0bf51e..5959c230fb9 100644
--- a/pkg/api/dashboard.go
+++ b/pkg/api/dashboard.go
@@ -336,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
"id": 123123,
"gridPos": map[string]interface{}{
"x": 0,
- "y": 3,
+ "y": 0,
"w": 24,
"h": 4,
},
diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go
index 8f5457cf271..ed7054050e4 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -165,6 +165,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
"externalUserMngInfo": setting.ExternalUserMngInfo,
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
+ "viewersCanEdit": setting.ViewersCanEdit,
+ "disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
"buildInfo": map[string]interface{}{
"version": setting.BuildVersion,
"commit": setting.BuildCommit,
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index d4d7b41bec5..7b7c1478a4c 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -11,14 +11,8 @@ import (
"path"
"time"
- "github.com/grafana/grafana/pkg/api/routing"
- "github.com/prometheus/client_golang/prometheus"
-
- "github.com/prometheus/client_golang/prometheus/promhttp"
-
- macaron "gopkg.in/macaron.v1"
-
"github.com/grafana/grafana/pkg/api/live"
+ "github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -27,11 +21,16 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
+ "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cache"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/rendering"
+ "github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ macaron "gopkg.in/macaron.v1"
)
func init() {
@@ -49,13 +48,14 @@ type HTTPServer struct {
streamManager *live.StreamManager
httpSrv *http.Server
- RouteRegister routing.RouteRegister `inject:""`
- Bus bus.Bus `inject:""`
- RenderService rendering.Service `inject:""`
- Cfg *setting.Cfg `inject:""`
- HooksService *hooks.HooksService `inject:""`
- CacheService *cache.CacheService `inject:""`
- DatasourceCache datasources.CacheService `inject:""`
+ RouteRegister routing.RouteRegister `inject:""`
+ Bus bus.Bus `inject:""`
+ RenderService rendering.Service `inject:""`
+ Cfg *setting.Cfg `inject:""`
+ HooksService *hooks.HooksService `inject:""`
+ CacheService *cache.CacheService `inject:""`
+ DatasourceCache datasources.CacheService `inject:""`
+ AuthTokenService auth.UserAuthTokenService `inject:""`
}
func (hs *HTTPServer) Init() error {
@@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
hs.macaron = hs.newMacaron()
hs.registerRoutes()
+ session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
+
return nil
}
@@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
m.Use(hs.healthHandler)
m.Use(hs.metricsEndpoint)
- m.Use(middleware.GetContextHandler())
- m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
+ m.Use(middleware.GetContextHandler(hs.AuthTokenService))
m.Use(middleware.OrgRedirect())
// needs to be after context handler
diff --git a/pkg/api/index.go b/pkg/api/index.go
index 2980d8a5c6b..e90db84016d 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -140,7 +140,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
Children: dashboardChildNavs,
})
- if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) {
+ if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || setting.ViewersCanEdit) {
data.NavTree = append(data.NavTree, &dtos.NavLink{
Text: "Explore",
Id: "explore",
diff --git a/pkg/api/login.go b/pkg/api/login.go
index 05afc40e59a..50c62e0835a 100644
--- a/pkg/api/login.go
+++ b/pkg/api/login.go
@@ -1,6 +1,8 @@
package api
import (
+ "encoding/hex"
+ "net/http"
"net/url"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -9,12 +11,13 @@ import (
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
)
const (
- ViewIndex = "index"
+ ViewIndex = "index"
+ LoginErrorCookieName = "login_error"
)
func (hs *HTTPServer) LoginView(c *m.ReqContext) {
@@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
viewData.Settings["loginHint"] = setting.LoginHint
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
- if loginError, ok := c.Session.Get("loginError").(string); ok {
- c.Session.Delete("loginError")
+ if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
+ deleteCookie(c, LoginErrorCookieName)
viewData.Settings["loginError"] = loginError
}
@@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
return
}
- if !tryLoginUsingRememberCookie(c) {
+ if !c.IsSignedIn {
c.HTML(200, ViewIndex, viewData)
return
}
@@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool {
return false
}
-func tryLoginUsingRememberCookie(c *m.ReqContext) bool {
- // Check auto-login.
- uname := c.GetCookie(setting.CookieUserName)
- if len(uname) == 0 {
- return false
+func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
+ if c.IsSignedIn || c.IsAnonymous {
+ return JSON(200, "Logged in")
}
- isSucceed := false
- defer func() {
- if !isSucceed {
- log.Trace("auto-login cookie cleared: %s", uname)
- c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
- c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
- return
- }
- }()
-
- userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname}
- if err := bus.Dispatch(&userQuery); err != nil {
- return false
- }
-
- user := userQuery.Result
-
- // validate remember me cookie
- signingKey := user.Rands + user.Password
- if len(signingKey) < 10 {
- c.Logger.Error("Invalid user signingKey")
- return false
- }
-
- if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login {
- return false
- }
-
- isSucceed = true
- loginUserWithUser(user, c)
- return true
+ return Error(401, "Unauthorized", nil)
}
-func LoginAPIPing(c *m.ReqContext) {
- if !tryLoginUsingRememberCookie(c) {
- c.JsonApiErr(401, "Unauthorized", nil)
- return
- }
-
- c.JsonOK("Logged in")
-}
-
-func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
+func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
if setting.DisableLoginForm {
return Error(401, "Login is disabled", nil)
}
@@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
user := authQuery.User
- loginUserWithUser(user, c)
+ hs.loginUserWithUser(user, c)
result := map[string]interface{}{
"message": "Logged in",
@@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
return JSON(200, result)
}
-func loginUserWithUser(user *m.User, c *m.ReqContext) {
+func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
if user == nil {
- log.Error(3, "User login with nil user")
+ hs.log.Error("User login with nil user")
}
- c.Resp.Header().Del("Set-Cookie")
-
- days := 86400 * setting.LogInRememberDays
- if days > 0 {
- c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/")
- c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/")
+ err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
+ if err != nil {
+ hs.log.Error("User auth hook failed", "error", err)
}
-
- c.Session.RegenerateId(c.Context)
- c.Session.Set(session.SESS_KEY_USERID, user.Id)
}
-func Logout(c *m.ReqContext) {
- c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
- c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
- c.Session.Destory(c.Context)
+func (hs *HTTPServer) Logout(c *m.ReqContext) {
+ hs.AuthTokenService.UserSignedOutHook(c)
+
if setting.SignoutRedirectUrl != "" {
c.Redirect(setting.SignoutRedirectUrl)
} else {
c.Redirect(setting.AppSubUrl + "/login")
}
}
+
+func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) {
+ cookie := ctx.GetCookie(cookieName)
+ if cookie == "" {
+ return "", false
+ }
+
+ decoded, err := hex.DecodeString(cookie)
+ if err != nil {
+ return "", false
+ }
+
+ decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey)
+ return string(decryptedError), err == nil
+}
+
+func deleteCookie(ctx *m.ReqContext, cookieName string) {
+ ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/")
+}
+
+func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error {
+ encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey)
+ if err != nil {
+ return err
+ }
+
+ http.SetCookie(ctx.Resp, &http.Cookie{
+ Name: cookieName,
+ MaxAge: 60,
+ Value: hex.EncodeToString(encryptedError),
+ HttpOnly: true,
+ Path: setting.AppSubUrl + "/",
+ Secure: hs.Cfg.SecurityHTTPSCookies,
+ })
+
+ return nil
+}
diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go
index fe4fa93b621..4160d48733e 100644
--- a/pkg/api/login_oauth.go
+++ b/pkg/api/login_oauth.go
@@ -3,9 +3,11 @@ package api
import (
"context"
"crypto/rand"
+ "crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
+ "encoding/hex"
"fmt"
"io/ioutil"
"net/http"
@@ -18,12 +20,14 @@ import (
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/social"
)
-var oauthLogger = log.New("oauth")
+var (
+ oauthLogger = log.New("oauth")
+ OauthStateCookieName = "oauth_state"
+)
func GenStateString() string {
rnd := make([]byte, 32)
@@ -31,7 +35,7 @@ func GenStateString() string {
return base64.URLEncoding.EncodeToString(rnd)
}
-func OAuthLogin(ctx *m.ReqContext) {
+func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
if setting.OAuthService == nil {
ctx.Handle(404, "OAuth not enabled", nil)
return
@@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) {
if errorParam != "" {
errorDesc := ctx.Query("error_description")
oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
- redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
+ hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
return
}
code := ctx.Query("code")
if code == "" {
state := GenStateString()
- ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state)
+ hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
+ hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60)
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
} else {
@@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) {
return
}
- savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string)
- if !ok {
+ cookieState := ctx.GetCookie(OauthStateCookieName)
+
+ // delete cookie
+ ctx.Resp.Header().Del("Set-Cookie")
+ hs.deleteCookie(ctx.Resp, OauthStateCookieName)
+
+ if cookieState == "" {
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
return
}
- queryState := ctx.Query("state")
- if savedState != queryState {
+ queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret)
+ oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState)
+ if cookieState != queryState {
ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
return
}
@@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) {
userInfo, err := connect.UserInfo(client, token)
if err != nil {
if sErr, ok := err.(*social.Error); ok {
- redirectWithError(ctx, sErr)
+ hs.redirectWithError(ctx, sErr)
} else {
ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
}
@@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) {
// validate that we got at least an email address
if userInfo.Email == "" {
- redirectWithError(ctx, login.ErrNoEmail)
+ hs.redirectWithError(ctx, login.ErrNoEmail)
return
}
// validate that the email is allowed to login to grafana
if !connect.IsEmailAllowed(userInfo.Email) {
- redirectWithError(ctx, login.ErrEmailNotAllowed)
+ hs.redirectWithError(ctx, login.ErrEmailNotAllowed)
return
}
@@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) {
ExternalUser: extUser,
SignupAllowed: connect.IsSignupAllowed(),
}
+
err = bus.Dispatch(cmd)
if err != nil {
- redirectWithError(ctx, err)
+ hs.redirectWithError(ctx, err)
return
}
// login
- loginUserWithUser(cmd.Result, ctx)
+ hs.loginUserWithUser(cmd.Result, ctx)
metrics.M_Api_Login_OAuth.Inc()
@@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) {
ctx.Redirect(setting.AppSubUrl + "/")
}
-func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
+func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) {
+ hs.writeCookie(w, name, "", -1)
+}
+
+func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) {
+ http.SetCookie(w, &http.Cookie{
+ Name: name,
+ MaxAge: maxAge,
+ Value: value,
+ HttpOnly: true,
+ Path: setting.AppSubUrl + "/",
+ Secure: hs.Cfg.SecurityHTTPSCookies,
+ })
+}
+
+func hashStatecode(code, seed string) string {
+ hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed))
+ return hex.EncodeToString(hashBytes[:])
+}
+
+func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
ctx.Logger.Error(err.Error(), v...)
- ctx.Session.Set("loginError", err.Error())
+ hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60)
+
ctx.Redirect(setting.AppSubUrl + "/login")
}
diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go
index dfb2cf045ed..835b03a2cc9 100644
--- a/pkg/api/org_invite.go
+++ b/pkg/api/org_invite.go
@@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response {
})
}
-func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
+func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
if err := bus.Dispatch(&query); err != nil {
@@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res
return rsp
}
- loginUserWithUser(user, c)
+ hs.loginUserWithUser(user, c)
metrics.M_Api_User_SignUpCompleted.Inc()
metrics.M_Api_User_SignUpInvite.Inc()
diff --git a/pkg/api/signup.go b/pkg/api/signup.go
index 200a3ebc9d1..fe577dd9ef9 100644
--- a/pkg/api/signup.go
+++ b/pkg/api/signup.go
@@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response {
return JSON(200, util.DynMap{"status": "SignUpCreated"})
}
-func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
+func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
if !setting.AllowUserSignUp {
return Error(401, "User signup is disabled", nil)
}
@@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
apiResponse["code"] = "redirect-to-select-org"
}
- loginUserWithUser(user, c)
+ hs.loginUserWithUser(user, c)
metrics.M_Api_User_SignUpCompleted.Inc()
return JSON(200, apiResponse)
diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go
index 93f69cadd46..422a03d3501 100644
--- a/pkg/components/imguploader/imguploader.go
+++ b/pkg/components/imguploader/imguploader.go
@@ -6,7 +6,6 @@ import (
"regexp"
"github.com/grafana/grafana/pkg/log"
-
"github.com/grafana/grafana/pkg/setting"
)
@@ -21,6 +20,10 @@ func (NopImageUploader) Upload(ctx context.Context, path string) (string, error)
return "", nil
}
+var (
+ logger = log.New("imguploader")
+)
+
func NewImageUploader() (ImageUploader, error) {
switch setting.ImageUploadProvider {
@@ -94,7 +97,7 @@ func NewImageUploader() (ImageUploader, error) {
}
if setting.ImageUploadProvider != "" {
- log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
+ logger.Error("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
}
return NopImageUploader{}, nil
diff --git a/pkg/log/log.go b/pkg/log/log.go
index 8f0522748ef..2e3b6303a6e 100644
--- a/pkg/log/log.go
+++ b/pkg/log/log.go
@@ -10,13 +10,11 @@ import (
"path/filepath"
"strings"
- "gopkg.in/ini.v1"
-
"github.com/go-stack/stack"
+ "github.com/grafana/grafana/pkg/util"
"github.com/inconshreveable/log15"
isatty "github.com/mattn/go-isatty"
-
- "github.com/grafana/grafana/pkg/util"
+ "gopkg.in/ini.v1"
)
var Root log15.Logger
@@ -58,10 +56,6 @@ func Debug(format string, v ...interface{}) {
Root.Debug(message)
}
-func Debug2(message string, v ...interface{}) {
- Root.Debug(message, v...)
-}
-
func Info(format string, v ...interface{}) {
var message string
if len(v) > 0 {
@@ -73,10 +67,6 @@ func Info(format string, v ...interface{}) {
Root.Info(message)
}
-func Info2(message string, v ...interface{}) {
- Root.Info(message, v...)
-}
-
func Warn(format string, v ...interface{}) {
var message string
if len(v) > 0 {
@@ -88,18 +78,10 @@ func Warn(format string, v ...interface{}) {
Root.Warn(message)
}
-func Warn2(message string, v ...interface{}) {
- Root.Warn(message, v...)
-}
-
func Error(skip int, format string, v ...interface{}) {
Root.Error(fmt.Sprintf(format, v...))
}
-func Error2(message string, v ...interface{}) {
- Root.Error(message, v...)
-}
-
func Critical(skip int, format string, v ...interface{}) {
Root.Crit(fmt.Sprintf(format, v...))
}
diff --git a/pkg/login/ext_user.go b/pkg/login/ext_user.go
index 1262c1cc44f..42fb37ff9d0 100644
--- a/pkg/login/ext_user.go
+++ b/pkg/login/ext_user.go
@@ -11,6 +11,10 @@ func init() {
bus.AddHandler("auth", UpsertUser)
}
+var (
+ logger = log.New("login.ext_user")
+)
+
func UpsertUser(cmd *m.UpsertUserCommand) error {
extUser := cmd.ExternalUser
@@ -135,7 +139,7 @@ func updateUser(user *m.User, extUser *m.ExternalUserInfo) error {
return nil
}
- log.Debug2("Syncing user info", "id", user.Id, "update", updateCmd)
+ logger.Debug("Syncing user info", "id", user.Id, "update", updateCmd)
return bus.Dispatch(updateCmd)
}
diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go
index 5faee1e3fa7..27248342c8d 100644
--- a/pkg/middleware/auth.go
+++ b/pkg/middleware/auth.go
@@ -7,7 +7,6 @@ import (
"gopkg.in/macaron.v1"
m "github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -17,16 +16,6 @@ type AuthOptions struct {
ReqSignedIn bool
}
-func getRequestUserId(c *m.ReqContext) int64 {
- userID := c.Session.Get(session.SESS_KEY_USERID)
-
- if userID != nil {
- return userID.(int64)
- }
-
- return 0
-}
-
func getApiKey(c *m.ReqContext) string {
header := c.Req.Header.Get("Authorization")
parts := strings.SplitN(header, " ", 2)
diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go
index fc109ac707f..93ee577e3c6 100644
--- a/pkg/middleware/auth_proxy.go
+++ b/pkg/middleware/auth_proxy.go
@@ -16,7 +16,9 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
-var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
+var (
+ AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
+)
func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
if !setting.AuthProxyEnabled {
@@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
return false
}
+ defer func() {
+ if err := ctx.Session.Release(); err != nil {
+ ctx.Logger.Error("failed to save session data", "error", err)
+ }
+ }()
+
query := &m.GetSignedInUserQuery{OrgId: orgID}
// if this session has already been authenticated by authProxy just load the user
@@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error {
return nil
}
+func getRequestUserId(c *m.ReqContext) int64 {
+ userID := c.Session.Get(session.SESS_KEY_USERID)
+
+ if userID != nil {
+ return userID.(int64)
+ }
+
+ return 0
+}
+
func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
return nil
diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go
index ace72d998eb..3722ac3058f 100644
--- a/pkg/middleware/middleware.go
+++ b/pkg/middleware/middleware.go
@@ -3,15 +3,15 @@ package middleware
import (
"strconv"
- "gopkg.in/macaron.v1"
-
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
+ macaron "gopkg.in/macaron.v1"
)
var (
@@ -21,12 +21,12 @@ var (
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
)
-func GetContextHandler() macaron.Handler {
+func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
return func(c *macaron.Context) {
ctx := &m.ReqContext{
Context: c,
SignedInUser: &m.SignedInUser{},
- Session: session.GetSession(),
+ Session: session.GetSession(), // should only be used by auth_proxy
IsSignedIn: false,
AllowAnonymous: false,
SkipCache: false,
@@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler {
case initContextWithApiKey(ctx):
case initContextWithBasicAuth(ctx, orgId):
case initContextWithAuthProxy(ctx, orgId):
- case initContextWithUserSessionCookie(ctx, orgId):
+ case ats.InitContextWithToken(ctx, orgId):
case initContextWithAnonymousUser(ctx):
}
@@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
return true
}
-func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool {
- // initialize session
- if err := ctx.Session.Start(ctx.Context); err != nil {
- ctx.Logger.Error("Failed to start session", "error", err)
- return false
- }
-
- var userId int64
- if userId = getRequestUserId(ctx); userId == 0 {
- return false
- }
-
- query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId}
- if err := bus.Dispatch(&query); err != nil {
- ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err)
- return false
- }
-
- ctx.SignedInUser = query.Result
- ctx.IsSignedIn = true
- return true
-}
-
func initContextWithApiKey(ctx *m.ReqContext) bool {
var keyString string
if keyString = getApiKey(ctx); keyString == "" {
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index b9a8afce6c6..11740574d0b 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"
- ms "github.com/go-macaron/session"
+ msession "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
@@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) {
So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
})
- middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
- sc.fakeReq("GET", "/").exec()
- So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
- })
-
middlewareScenario("Invalid api key", func(sc *scenarioContext) {
sc.apiKey = "invalid_key_test"
sc.fakeReq("GET", "/").exec()
@@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) {
})
})
- middlewareScenario("UserId in session", func(sc *scenarioContext) {
-
- sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
- c.Session.Set(session.SESS_KEY_USERID, int64(12))
- }).exec()
-
- bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
- query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
- return nil
- })
+ middlewareScenario("Auth token service", func(sc *scenarioContext) {
+ var wasCalled bool
+ sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+ wasCalled = true
+ return false
+ }
sc.fakeReq("GET", "/").exec()
- Convey("should init context with user info", func() {
- So(sc.context.IsSignedIn, ShouldBeTrue)
- So(sc.context.UserId, ShouldEqual, 12)
+ Convey("should call middleware", func() {
+ So(wasCalled, ShouldBeTrue)
})
})
@@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) {
return nil
})
+ setting.SessionOptions = msession.Options{}
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.exec()
@@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
defer bus.ClearBusHandlers()
sc := &scenarioContext{}
+
viewsPath, _ := filepath.Abs("../../public/views")
sc.m = macaron.New()
@@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
- sc.m.Use(GetContextHandler())
+ session.Init(&msession.Options{}, 0)
+ sc.userAuthTokenService = newFakeUserAuthTokenService()
+ sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine
session.StartSessionGC = func() {}
- sc.m.Use(Sessioner(&ms.Options{}, 0))
+ setting.SessionOptions = msession.Options{}
+
sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders())
@@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) {
}
type scenarioContext struct {
- m *macaron.Macaron
- context *m.ReqContext
- resp *httptest.ResponseRecorder
- apiKey string
- authHeader string
- respJson map[string]interface{}
- handlerFunc handlerFunc
- defaultHandler macaron.Handler
- url string
+ m *macaron.Macaron
+ context *m.ReqContext
+ resp *httptest.ResponseRecorder
+ apiKey string
+ authHeader string
+ respJson map[string]interface{}
+ handlerFunc handlerFunc
+ defaultHandler macaron.Handler
+ url string
+ userAuthTokenService *fakeUserAuthTokenService
req *http.Request
}
@@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() {
type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext)
+
+type fakeUserAuthTokenService struct {
+ initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+}
+
+func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
+ return &fakeUserAuthTokenService{
+ initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
+ return false
+ },
+ }
+}
+
+func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
+ return s.initContextWithTokenProvider(ctx, orgID)
+}
+
+func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
+ return nil
+}
+
+func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go
index db263c2a17a..ca63733946c 100644
--- a/pkg/middleware/org_redirect.go
+++ b/pkg/middleware/org_redirect.go
@@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
-
"gopkg.in/macaron.v1"
)
diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go
index fa08154b250..46b8776fdcc 100644
--- a/pkg/middleware/org_redirect_test.go
+++ b/pkg/middleware/org_redirect_test.go
@@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/session"
. "github.com/smartystreets/goconvey/convey"
)
@@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) {
Convey("Can redirect to correct org", t, func() {
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
- sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
- c.Session.Set(session.SESS_KEY_USERID, int64(12))
- }).exec()
-
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return nil
})
- bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
- query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
- return nil
- })
+ sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+ ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
+ ctx.IsSignedIn = true
+ return true
+ }
sc.m.Get("/", sc.defaultHandler)
sc.fakeReq("GET", "/?orgId=3").exec()
@@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) {
})
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
- sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
- c.Session.Set(session.SESS_KEY_USERID, int64(12))
- }).exec()
-
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return fmt.Errorf("")
})
+ sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+ ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
+ ctx.IsSignedIn = true
+ return true
+ }
+
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil
diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go
index 92c3d62674d..4f2203a5d3d 100644
--- a/pkg/middleware/quota_test.go
+++ b/pkg/middleware/quota_test.go
@@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) {
})
middlewareScenario("with user logged in", func(sc *scenarioContext) {
- // log us in, so we have a user_id and org_id in the context
- sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
- c.Session.Set(session.SESS_KEY_USERID, int64(12))
- }).exec()
+ sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+ ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
+ ctx.IsSignedIn = true
+ return true
+ }
- bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
- query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
- return nil
- })
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
query.Result = &m.GlobalQuotaDTO{
Target: query.Target,
diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go
index c92150f3b7d..e041d42e56b 100644
--- a/pkg/middleware/recovery_test.go
+++ b/pkg/middleware/recovery_test.go
@@ -4,13 +4,12 @@ import (
"path/filepath"
"testing"
- ms "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
- "gopkg.in/macaron.v1"
+ macaron "gopkg.in/macaron.v1"
)
func TestRecoveryMiddleware(t *testing.T) {
@@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
- sc.m.Use(GetContextHandler())
+ sc.userAuthTokenService = newFakeUserAuthTokenService()
+ sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine
session.StartSessionGC = func() {}
- sc.m.Use(Sessioner(&ms.Options{}, 0))
sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders())
diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go
deleted file mode 100644
index 19cfa368b49..00000000000
--- a/pkg/middleware/session.go
+++ /dev/null
@@ -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())
- }
- }
-}
diff --git a/pkg/models/context.go b/pkg/models/context.go
index 7cb80a957c3..df970451304 100644
--- a/pkg/models/context.go
+++ b/pkg/models/context.go
@@ -3,18 +3,18 @@ package models
import (
"strings"
- "github.com/prometheus/client_golang/prometheus"
- "gopkg.in/macaron.v1"
-
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/prometheus/client_golang/prometheus"
+ "gopkg.in/macaron.v1"
)
type ReqContext struct {
*macaron.Context
*SignedInUser
+ // This should only be used by the auth_proxy
Session session.SessionStore
IsSignedIn bool
diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go
index 0f8e24bcef5..22cbe2456b7 100644
--- a/pkg/services/alerting/engine.go
+++ b/pkg/services/alerting/engine.go
@@ -105,8 +105,9 @@ func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
var (
unfinishedWorkTimeout = time.Second * 5
// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
- alertTimeout = time.Second * 30
- alertMaxAttempts = 3
+ alertTimeout = time.Second * 30
+ resultHandleTimeout = time.Second * 30
+ alertMaxAttempts = 3
)
func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
@@ -116,7 +117,7 @@ func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *J
}
}()
- cancelChan := make(chan context.CancelFunc, alertMaxAttempts)
+ cancelChan := make(chan context.CancelFunc, alertMaxAttempts*2)
attemptChan := make(chan int, 1)
// Initialize with first attemptID=1
@@ -204,6 +205,15 @@ func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancel
}
}
+ // create new context with timeout for notifications
+ resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), resultHandleTimeout)
+ cancelChan <- resultHandleCancelFn
+
+ // override the context used for evaluation with a new context for notifications.
+ // This makes it possible for notifiers to execute when datasources
+ // dont respond within the timeout limit. We should rewrite this so notifications
+ // dont reuse the evalContext and get its own context.
+ evalContext.Ctx = resultHandleCtx
evalContext.Rule.State = evalContext.GetNewState()
e.resultHandler.Handle(evalContext)
span.Finish()
diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go
new file mode 100644
index 00000000000..aa518baae24
--- /dev/null
+++ b/pkg/services/alerting/engine_integration_test.go
@@ -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)
+}
diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go
index 4a4a989d873..ab43f3bce35 100644
--- a/pkg/services/alerting/notifiers/telegram.go
+++ b/pkg/services/alerting/notifiers/telegram.go
@@ -130,7 +130,7 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
defer func() {
err := imageFile.Close()
if err != nil {
- log.Error2("Could not close Telegram inline image.", "err", err)
+ this.log.Error("Could not close Telegram inline image.", "err", err)
}
}()
diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go
index b6e59f694c8..5ffc8dc58fc 100644
--- a/pkg/services/alerting/test_notification.go
+++ b/pkg/services/alerting/test_notification.go
@@ -18,9 +18,12 @@ type NotificationTestCommand struct {
Settings *simplejson.Json
}
+var (
+ logger = log.New("alerting.testnotification")
+)
+
func init() {
bus.AddHandler("alerting", handleNotificationTestCommand)
-
}
func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
@@ -35,7 +38,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
notifiers, err := InitNotifier(model)
if err != nil {
- log.Error2("Failed to create notifier", "error", err.Error())
+ logger.Error("Failed to create notifier", "error", err.Error())
return err
}
diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go
new file mode 100644
index 00000000000..7e9433c2d70
--- /dev/null
+++ b/pkg/services/auth/auth_token.go
@@ -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[:])
+}
diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go
new file mode 100644
index 00000000000..2f75c660d9d
--- /dev/null
+++ b/pkg/services/auth/auth_token_test.go
@@ -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
+}
diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go
new file mode 100644
index 00000000000..7a0f49539f2
--- /dev/null
+++ b/pkg/services/auth/model.go
@@ -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:"-"`
+}
diff --git a/pkg/services/auth/session_cleanup.go b/pkg/services/auth/session_cleanup.go
new file mode 100644
index 00000000000..7e523181a7b
--- /dev/null
+++ b/pkg/services/auth/session_cleanup.go
@@ -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
+}
diff --git a/pkg/services/auth/session_cleanup_test.go b/pkg/services/auth/session_cleanup_test.go
new file mode 100644
index 00000000000..eef2cd74d04
--- /dev/null
+++ b/pkg/services/auth/session_cleanup_test.go
@@ -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)
+ })
+}
diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go
index 59ceefa0be5..33f418cbee3 100644
--- a/pkg/services/dashboards/dashboard_service.go
+++ b/pkg/services/dashboards/dashboard_service.go
@@ -164,11 +164,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand,
User: dto.User,
}
- if err := bus.Dispatch(&alertCmd); err != nil {
- return err
- }
-
- return nil
+ return bus.Dispatch(&alertCmd)
}
func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
diff --git a/pkg/services/session/session.go b/pkg/services/session/session.go
index 5873a6a5b72..2e60b8a25d7 100644
--- a/pkg/services/session/session.go
+++ b/pkg/services/session/session.go
@@ -14,8 +14,6 @@ import (
const (
SESS_KEY_USERID = "uid"
- SESS_KEY_OAUTH_STATE = "state"
- SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys
SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
)
diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go
index 36cd8e5ed62..931259ec3ed 100644
--- a/pkg/services/sqlstore/migrations/migrations.go
+++ b/pkg/services/sqlstore/migrations/migrations.go
@@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) {
addLoginAttemptMigrations(mg)
addUserAuthMigrations(mg)
addServerlockMigrations(mg)
+ addUserAuthTokenMigrations(mg)
}
func addMigrationLogMigrations(mg *Migrator) {
diff --git a/pkg/services/sqlstore/migrations/user_auth_token_mig.go b/pkg/services/sqlstore/migrations/user_auth_token_mig.go
new file mode 100644
index 00000000000..9794b7a78c7
--- /dev/null
+++ b/pkg/services/sqlstore/migrations/user_auth_token_mig.go
@@ -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]))
+}
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 1417392fdf8..660a00ba41d 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -18,7 +18,7 @@ import (
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util"
- "gopkg.in/ini.v1"
+ ini "gopkg.in/ini.v1"
)
type Scheme string
@@ -83,9 +83,6 @@ var (
// Security settings.
SecretKey string
- LogInRememberDays int
- CookieUserName string
- CookieRememberName string
DisableGravatar bool
EmailCodeValidMinutes int
DataProxyWhiteList map[string]bool
@@ -222,7 +219,15 @@ type Cfg struct {
MetricsEndpointBasicAuthUsername string
MetricsEndpointBasicAuthPassword string
EnableAlphaPanels bool
+ DisableSanitizeHtml bool
EnterpriseLicensePath string
+
+ LoginCookieName string
+ LoginCookieMaxDays int
+ LoginCookieRotation int
+ LoginDeleteExpiredTokensAfterDays int
+
+ SecurityHTTPSCookies bool
}
type CommandLineArgs struct {
@@ -546,6 +551,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
ApplicationName = APP_NAME_ENTERPRISE
}
+ //login
+ login := iniFile.Section("login")
+ cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
+ cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
+ cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
+ cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
+ if cfg.LoginCookieRotation < 2 {
+ cfg.LoginCookieRotation = 2
+ }
+
Env = iniFile.Section("").Key("app_mode").MustString("development")
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
@@ -586,11 +601,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// read security settings
security := iniFile.Section("security")
SecretKey = security.Key("secret_key").String()
- LogInRememberDays = security.Key("login_remember_days").MustInt()
- CookieUserName = security.Key("cookie_username").String()
- CookieRememberName = security.Key("cookie_remember_name").String()
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
+ cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
// read snapshots settings
@@ -709,6 +722,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
panels := iniFile.Section("panels")
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
+ cfg.DisableSanitizeHtml = panels.Key("disable_sanitize_html").MustBool(false)
cfg.readSessionConfig()
cfg.readSmtpSettings()
diff --git a/pkg/util/encoding.go b/pkg/util/encoding.go
index 0edb721e422..e82344d73f9 100644
--- a/pkg/util/encoding.go
+++ b/pkg/util/encoding.go
@@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
return userAndPass[0], userAndPass[1], nil
}
+
+func RandomHex(n int) (string, error) {
+ bytes := make([]byte, n)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/pkg/util/ip_address.go b/pkg/util/ip_address.go
new file mode 100644
index 00000000000..d8d95ef3acd
--- /dev/null
+++ b/pkg/util/ip_address.go
@@ -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()
+}
diff --git a/pkg/util/ip_address_test.go b/pkg/util/ip_address_test.go
new file mode 100644
index 00000000000..fd3e3ea8587
--- /dev/null
+++ b/pkg/util/ip_address_test.go
@@ -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")
+ })
+}
diff --git a/public/app/core/components/Animations/FadeIn.tsx b/public/app/core/components/Animations/FadeIn.tsx
index e12f22486f1..ea9a92d5f0f 100644
--- a/public/app/core/components/Animations/FadeIn.tsx
+++ b/public/app/core/components/Animations/FadeIn.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import Transition from 'react-transition-group/Transition';
interface Props {
@@ -8,7 +8,7 @@ interface Props {
unmountOnExit?: boolean;
}
-export const FadeIn: SFC = props => {
+export const FadeIn: FC = props => {
const defaultStyle = {
transition: `opacity ${props.duration}ms linear`,
opacity: 0,
diff --git a/public/app/core/components/Footer/Footer.tsx b/public/app/core/components/Footer/Footer.tsx
new file mode 100644
index 00000000000..101168beb66
--- /dev/null
+++ b/public/app/core/components/Footer/Footer.tsx
@@ -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 = React.memo(({appName, buildVersion, buildCommit, newGrafanaVersionExists, newGrafanaVersion}) => {
+ return (
+
+ );
+});
+
+export default Footer;
diff --git a/public/app/core/components/Label/Label.tsx b/public/app/core/components/Label/Label.tsx
deleted file mode 100644
index 5d60efa056a..00000000000
--- a/public/app/core/components/Label/Label.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React, { SFC, ReactNode } from 'react';
-import { Tooltip } from '@grafana/ui';
-
-interface Props {
- tooltip?: string;
- for?: string;
- children: ReactNode;
- width?: number;
- className?: string;
-}
-
-export const Label: SFC = props => {
- return (
-
- {props.children}
- {props.tooltip && (
-
-
-
-
-
- )}
-
- );
-};
diff --git a/public/app/core/components/LayoutSelector/LayoutSelector.tsx b/public/app/core/components/LayoutSelector/LayoutSelector.tsx
index d9e00102438..3afa1f931f2 100644
--- a/public/app/core/components/LayoutSelector/LayoutSelector.tsx
+++ b/public/app/core/components/LayoutSelector/LayoutSelector.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
@@ -12,7 +12,7 @@ interface Props {
onLayoutModeChanged: (mode: LayoutMode) => {};
}
-const LayoutSelector: SFC = props => {
+const LayoutSelector: FC = props => {
const { mode, onLayoutModeChanged } = props;
return (
diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx
new file mode 100644
index 00000000000..8c9a5595cb7
--- /dev/null
+++ b/public/app/core/components/Page/Page.tsx
@@ -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
{
+ 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 (
+
+
+
+
+ {this.props.children}
+
+
+
+
+ );
+ }
+}
+
+export default Page;
diff --git a/public/app/core/components/Page/PageContents.tsx b/public/app/core/components/Page/PageContents.tsx
new file mode 100644
index 00000000000..6970857d383
--- /dev/null
+++ b/public/app/core/components/Page/PageContents.tsx
@@ -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 {
+
+ render() {
+ const { isLoading } = this.props;
+
+ return (
+
+ {isLoading &&
}
+ {this.props.children}
+
+ );
+ }
+}
+
+export default PageContents;
diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx
index c176095afa4..83066054f88 100644
--- a/public/app/core/components/PageHeader/PageHeader.tsx
+++ b/public/app/core/components/PageHeader/PageHeader.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { FormEvent } from 'react';
import { NavModel, NavModelItem } from 'app/types';
import classNames from 'classnames';
import appEvents from 'app/core/app_events';
@@ -12,8 +12,8 @@ const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string
return navItem.active === true;
});
- const gotoUrl = evt => {
- const element = evt.target;
+ const gotoUrl = (evt: FormEvent) => {
+ const element = evt.target as HTMLSelectElement;
const url = element.options[element.selectedIndex].value;
appEvents.emit('location-change', { href: url });
};
diff --git a/public/app/core/components/PageLoader/PageLoader.tsx b/public/app/core/components/PageLoader/PageLoader.tsx
index dcb67dde220..3182695e5e5 100644
--- a/public/app/core/components/PageLoader/PageLoader.tsx
+++ b/public/app/core/components/PageLoader/PageLoader.tsx
@@ -1,10 +1,10 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
interface Props {
- pageName: string;
+ pageName?: string;
}
-const PageLoader: SFC = ({ pageName }) => {
+const PageLoader: FC = ({ pageName }) => {
const loadingText = `Loading ${pageName}...`;
return (
diff --git a/public/app/core/components/Select/DataSourcePicker.tsx b/public/app/core/components/Select/DataSourcePicker.tsx
index 372c4cd4013..d028682b215 100644
--- a/public/app/core/components/Select/DataSourcePicker.tsx
+++ b/public/app/core/components/Select/DataSourcePicker.tsx
@@ -6,7 +6,7 @@ import _ from 'lodash';
import { Select } from '@grafana/ui';
// Types
-import { DataSourceSelectItem } from 'app/types';
+import { DataSourceSelectItem } from '@grafana/ui/src/types';
export interface Props {
onChange: (ds: DataSourceSelectItem) => void;
diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
index b13393ab2e1..33aca1de2aa 100644
--- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx
+++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
@@ -1,7 +1,6 @@
import React, { PureComponent } from 'react';
-import { Label } from 'app/core/components/Label/Label';
-import { Select } from '@grafana/ui';
+import { FormLabel, Select } from '@grafana/ui';
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchHit } from 'app/types';
@@ -100,12 +99,12 @@ export class SharedPreferences extends PureComponent
{
/>
-
Home Dashboard
-
+
dashboard.id === homeDashboardId)}
getOptionValue={i => i.id}
diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
index 86e15923bda..a2c06eef9f5 100644
--- a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
+++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
@@ -1,4 +1,4 @@
-import React, { SFC, ReactNode, PureComponent } from 'react';
+import React, { FC, ReactNode, PureComponent } from 'react';
import { Tooltip } from '@grafana/ui';
interface ToggleButtonGroupProps {
@@ -29,7 +29,7 @@ interface ToggleButtonProps {
tooltip?: string;
}
-export const ToggleButton: SFC = ({
+export const ToggleButton: FC = ({
children,
selected,
className = '',
diff --git a/public/app/core/components/sidemenu/DropDownChild.tsx b/public/app/core/components/sidemenu/DropDownChild.tsx
index 1a577d185e5..41aa794999e 100644
--- a/public/app/core/components/sidemenu/DropDownChild.tsx
+++ b/public/app/core/components/sidemenu/DropDownChild.tsx
@@ -1,10 +1,10 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
export interface Props {
child: any;
}
-const DropDownChild: SFC = props => {
+const DropDownChild: FC = props => {
const { child } = props;
const listItemClassName = child.divider ? 'divider' : '';
diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.tsx
index 7cd7554f82c..db2172039c6 100644
--- a/public/app/core/components/sidemenu/SideMenuDropDown.tsx
+++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx
@@ -1,16 +1,18 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import DropDownChild from './DropDownChild';
interface Props {
link: any;
}
-const SideMenuDropDown: SFC = props => {
+const SideMenuDropDown: FC = props => {
const { link } = props;
return (
- {link.text}
+
+ {link.text}
+
{link.children &&
link.children.map((child, index) => {
diff --git a/public/app/core/components/sidemenu/SignIn.tsx b/public/app/core/components/sidemenu/SignIn.tsx
index 17dd913823a..50b3aef2d9b 100644
--- a/public/app/core/components/sidemenu/SignIn.tsx
+++ b/public/app/core/components/sidemenu/SignIn.tsx
@@ -1,6 +1,6 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
-const SignIn: SFC = () => {
+const SignIn: FC = () => {
const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`;
return (
diff --git a/public/app/core/components/sidemenu/TopSection.tsx b/public/app/core/components/sidemenu/TopSection.tsx
index c6bf5df8242..827b868ea67 100644
--- a/public/app/core/components/sidemenu/TopSection.tsx
+++ b/public/app/core/components/sidemenu/TopSection.tsx
@@ -1,9 +1,9 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import _ from 'lodash';
import TopSectionItem from './TopSectionItem';
import config from '../../config';
-const TopSection: SFC
= () => {
+const TopSection: FC = () => {
const navTree = _.cloneDeep(config.bootData.navTree);
const mainLinks = _.filter(navTree, item => !item.hideFromMenu);
diff --git a/public/app/core/components/sidemenu/TopSectionItem.tsx b/public/app/core/components/sidemenu/TopSectionItem.tsx
index 7b3bf96dce8..0aca32c3ba3 100644
--- a/public/app/core/components/sidemenu/TopSectionItem.tsx
+++ b/public/app/core/components/sidemenu/TopSectionItem.tsx
@@ -1,11 +1,11 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import SideMenuDropDown from './SideMenuDropDown';
export interface Props {
link: any;
}
-const TopSectionItem: SFC = props => {
+const TopSectionItem: FC = props => {
const { link } = props;
return (
diff --git a/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
index 861168c1cc3..20d0a3ef3a4 100644
--- a/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
+++ b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
@@ -8,11 +8,15 @@ exports[`Render should render children 1`] = `
-
- link
-
+
+ link
+
+
-
- link
-
+
+ link
+
+
`;
diff --git a/public/app/core/config.ts b/public/app/core/config.ts
index 13d84772ecf..395e40e914b 100644
--- a/public/app/core/config.ts
+++ b/public/app/core/config.ts
@@ -6,6 +6,8 @@ export interface BuildInfo {
commit: string;
isEnterprise: boolean;
env: string;
+ latestVersion: string;
+ hasUpdate: boolean;
}
export class Settings {
@@ -32,8 +34,10 @@ export class Settings {
disableUserSignUp: boolean;
loginHint: any;
loginError: any;
+ viewersCanEdit: boolean;
+ disableSanitizeHtml: boolean;
- constructor(options) {
+ constructor(options: Settings) {
const defaults = {
datasources: {},
windowTitlePrefix: 'Grafana - ',
@@ -48,6 +52,8 @@ export class Settings {
env: 'production',
isEnterprise: false,
},
+ viewersCanEdit: false,
+ disableSanitizeHtml: false
};
_.extend(this, defaults, options);
diff --git a/public/app/core/controllers/all.ts b/public/app/core/controllers/all.ts
index 0dbcdf4cb28..f6a4e51bad4 100644
--- a/public/app/core/controllers/all.ts
+++ b/public/app/core/controllers/all.ts
@@ -1,4 +1,3 @@
-import './inspect_ctrl';
import './json_editor_ctrl';
import './login_ctrl';
import './invited_ctrl';
diff --git a/public/app/core/controllers/inspect_ctrl.ts b/public/app/core/controllers/inspect_ctrl.ts
deleted file mode 100644
index d106b42da16..00000000000
--- a/public/app/core/controllers/inspect_ctrl.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-import $ from 'jquery';
-import coreModule from '../core_module';
-
-export class InspectCtrl {
- /** @ngInject */
- constructor($scope, $sanitize) {
- const model = $scope.inspector;
-
- $scope.init = function() {
- $scope.editor = { index: 0 };
-
- if (!model.error) {
- return;
- }
-
- if (_.isString(model.error.data)) {
- $scope.response = $('' + model.error.data + '
').text();
- } else if (model.error.data) {
- if (model.error.data.response) {
- $scope.response = $sanitize(model.error.data.response);
- } else {
- $scope.response = angular.toJson(model.error.data, true);
- }
- } else if (model.error.message) {
- $scope.message = model.error.message;
- }
-
- if (model.error.config && model.error.config.params) {
- $scope.request_parameters = _.map(model.error.config.params, (value, key) => {
- return { key: key, value: value };
- });
- }
-
- if (model.error.stack) {
- $scope.editor.index = 3;
- $scope.stack_trace = model.error.stack;
- $scope.message = model.error.message;
- }
-
- if (model.error.config && model.error.config.data) {
- $scope.editor.index = 2;
-
- if (_.isString(model.error.config.data)) {
- $scope.request_parameters = this.getParametersFromQueryString(model.error.config.data);
- } else {
- $scope.request_parameters = _.map(model.error.config.data, (value, key) => {
- return { key: key, value: angular.toJson(value, true) };
- });
- }
- }
- };
- }
- getParametersFromQueryString(queryString) {
- const result = [];
- const parameters = queryString.split('&');
- for (let i = 0; i < parameters.length; i++) {
- const keyValue = parameters[i].split('=');
- if (keyValue[1].length > 0) {
- result.push({
- key: keyValue[0],
- value: (window as any).unescape(keyValue[1]),
- });
- }
- }
- return result;
- }
-}
-
-coreModule.controller('InspectCtrl', InspectCtrl);
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index 6713d8bcd14..fb38cefd435 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -1,5 +1,6 @@
import './directives/dash_class';
import './directives/dropdown_typeahead';
+import './directives/autofill_event_fix';
import './directives/metric_segment';
import './directives/misc';
import './directives/ng_model_on_blur';
diff --git a/public/app/core/directives/autofill_event_fix.ts b/public/app/core/directives/autofill_event_fix.ts
new file mode 100644
index 00000000000..51d278fe7c9
--- /dev/null
+++ b/public/app/core/directives/autofill_event_fix.ts
@@ -0,0 +1,35 @@
+import coreModule from '../core_module';
+
+/** @ngInject */
+export function autofillEventFix($compile) {
+ return {
+ link: ($scope: any, elem: any) => {
+ const input = elem[0];
+ const dispatchChangeEvent = () => {
+ const event = new Event('change');
+ return input.dispatchEvent(event);
+ };
+ const onAnimationStart = ({ animationName }: AnimationEvent) => {
+ switch (animationName) {
+ case 'onAutoFillStart':
+ return dispatchChangeEvent();
+ case 'onAutoFillCancel':
+ return dispatchChangeEvent();
+ }
+ return null;
+ };
+
+ // const onChange = (evt: Event) => console.log(evt);
+
+ input.addEventListener('animationstart', onAnimationStart);
+ // input.addEventListener('change', onChange);
+
+ $scope.$on('$destroy', () => {
+ input.removeEventListener('animationstart', onAnimationStart);
+ // input.removeEventListener('change', onChange);
+ });
+ }
+ };
+}
+
+coreModule.directive('autofillEventFix', autofillEventFix);
diff --git a/public/app/core/directives/dropdown_typeahead.ts b/public/app/core/directives/dropdown_typeahead.ts
index a4bed4fe2b7..dfc3eddbcbb 100644
--- a/public/app/core/directives/dropdown_typeahead.ts
+++ b/public/app/core/directives/dropdown_typeahead.ts
@@ -141,6 +141,9 @@ export function dropdownTypeahead2($compile) {
link: ($scope, elem, attrs) => {
const $input = $(inputTemplate);
const $button = $(buttonTemplate);
+ const timeoutId = {
+ blur: null
+ };
$input.appendTo(elem);
$button.appendTo(elem);
@@ -177,6 +180,14 @@ export function dropdownTypeahead2($compile) {
[]
);
+ const closeDropdownMenu = () => {
+ $input.hide();
+ $input.val('');
+ $button.show();
+ $button.focus();
+ elem.removeClass('open');
+ };
+
$scope.menuItemSelected = (index, subIndex) => {
const menuItem = $scope.menuItems[index];
const payload: any = { $item: menuItem };
@@ -184,6 +195,7 @@ export function dropdownTypeahead2($compile) {
payload.$subItem = menuItem.submenu[subIndex];
}
$scope.dropdownTypeaheadOnSelect(payload);
+ closeDropdownMenu();
};
$input.attr('data-provide', 'typeahead');
@@ -223,16 +235,15 @@ export function dropdownTypeahead2($compile) {
elem.toggleClass('open', $input.val() === '');
});
+ elem.mousedown((evt: Event) => {
+ evt.preventDefault();
+ timeoutId.blur = null;
+ });
+
$input.blur(() => {
- $input.hide();
- $input.val('');
- $button.show();
- $button.focus();
- // clicking the function dropdown menu won't
- // work if you remove class at once
- setTimeout(() => {
- elem.removeClass('open');
- }, 200);
+ timeoutId.blur = setTimeout(() => {
+ closeDropdownMenu();
+ }, 1);
});
$compile(elem.contents())($scope);
diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts
index 4cf9a029a2a..a3f78e7152a 100644
--- a/public/app/core/logs_model.ts
+++ b/public/app/core/logs_model.ts
@@ -42,7 +42,7 @@ export interface LogSearchMatch {
text: string;
}
-export interface LogRow {
+export interface LogRowModel {
duplicates?: number;
entry: string;
key: string; // timestamp + labels
@@ -56,7 +56,7 @@ export interface LogRow {
uniqueLabels?: LogsStreamLabels;
}
-export interface LogsLabelStat {
+export interface LogLabelStatsModel {
active?: boolean;
count: number;
proportion: number;
@@ -78,7 +78,7 @@ export interface LogsMetaItem {
export interface LogsModel {
id: string; // Identify one logs result from another
meta?: LogsMetaItem[];
- rows: LogRow[];
+ rows: LogRowModel[];
series?: TimeSeries[];
}
@@ -188,13 +188,13 @@ export const LogsParsers: { [name: string]: LogsParser } = {
},
};
-export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] {
+export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] {
// Consider only rows that satisfy the matcher
const rowsWithField = rows.filter(row => extractor.test(row.entry));
const rowCount = rowsWithField.length;
// Get field value counts for eligible rows
- const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]);
+ const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]);
const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count')
@@ -204,13 +204,13 @@ export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabe
return sortedCounts;
}
-export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] {
+export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
// Consider only rows that have the given label
const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
const rowCount = rowsWithLabel.length;
// Get label value counts for eligible rows
- const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]);
+ const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]);
const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count')
@@ -221,7 +221,7 @@ export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabe
}
const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
-function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean {
+function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean {
switch (strategy) {
case LogsDedupStrategy.exact:
// Exact still strips dates
@@ -243,7 +243,7 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
return logs;
}
- const dedupedRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => {
+ const dedupedRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
const previous = result[result.length - 1];
if (index > 0 && isDuplicateRow(row, previous, strategy)) {
previous.duplicates++;
@@ -278,7 +278,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set)
return logs;
}
- const filteredRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => {
+ const filteredRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
if (!hiddenLogLevels.has(row.logLevel)) {
result.push(row);
}
@@ -291,7 +291,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set)
};
}
-export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] {
+export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): TimeSeries[] {
// currently interval is rangeMs / resolution, which is too low for showing series as bars.
// need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain
// when executing queries & interval calculated and not here but this is a temporary fix.
diff --git a/public/app/core/selectors/navModel.ts b/public/app/core/selectors/navModel.ts
index aa508616962..7d745b58002 100644
--- a/public/app/core/selectors/navModel.ts
+++ b/public/app/core/selectors/navModel.ts
@@ -41,3 +41,7 @@ export function getNavModel(navIndex: NavIndex, id: string, fallback?: NavModel)
return getNotFoundModel();
}
+
+export const getTitleFromNavModel = (navModel: NavModel) => {
+ return `${navModel.main.text}${navModel.node.text ? ': ' + navModel.node.text : '' }`;
+};
diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts
index c4134598175..05985aae999 100644
--- a/public/app/core/services/context_srv.ts
+++ b/public/app/core/services/context_srv.ts
@@ -2,6 +2,7 @@ import config from 'app/core/config';
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import store from 'app/core/store';
+import { ThemeNames, ThemeName } from '@grafana/ui';
export class User {
isGrafanaAdmin: any;
@@ -59,6 +60,14 @@ export class ContextSrv {
this.sidemenu = !this.sidemenu;
store.set('grafana.sidemenu', this.sidemenu);
}
+
+ hasAccessToExplore() {
+ return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
+ }
+
+ getTheme(): ThemeName {
+ return this.user.lightTheme ? ThemeNames.Light : ThemeNames.Dark;
+ }
}
const contextSrv = new ContextSrv();
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index c02f6850e8b..989746fd067 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/public/app/core/services/keybindingSrv.ts
@@ -1,13 +1,13 @@
import $ from 'jquery';
import _ from 'lodash';
-import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
+import { ContextSrv } from './context_srv';
export class KeybindingSrv {
helpModal: boolean;
@@ -21,7 +21,7 @@ export class KeybindingSrv {
private $timeout,
private datasourceSrv,
private timeSrv,
- private contextSrv
+ private contextSrv: ContextSrv
) {
// clear out all shortcuts on route change
$rootScope.$on('$routeChangeSuccess', () => {
@@ -196,7 +196,7 @@ export class KeybindingSrv {
});
// jump to explore if permissions allow
- if (this.contextSrv.isEditor && config.exploreEnabled) {
+ if (this.contextSrv.hasAccessToExplore()) {
this.bind('x', async () => {
if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
@@ -236,7 +236,7 @@ export class KeybindingSrv {
shareScope.dashboard = dashboard;
appEvents.emit('show-modal', {
- src: 'public/app/features/dashboard/partials/shareModal.html',
+ src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: shareScope,
});
}
diff --git a/public/app/core/specs/url.test.ts b/public/app/core/specs/url.test.ts
index b5994488128..3b7f81494f9 100644
--- a/public/app/core/specs/url.test.ts
+++ b/public/app/core/specs/url.test.ts
@@ -14,3 +14,12 @@ describe('toUrlParams', () => {
expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined=');
});
});
+
+describe('toUrlParams', () => {
+ it('should encode the same way as angularjs', () => {
+ const url = toUrlParams({
+ server: ':@',
+ });
+ expect(url).toBe('server=:@');
+ });
+});
diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts
index a3b08516d16..32135eab90a 100644
--- a/public/app/core/utils/explore.test.ts
+++ b/public/app/core/utils/explore.test.ts
@@ -6,26 +6,13 @@ import {
clearHistory,
hasNonEmptyQuery,
} from './explore';
-import { ExploreState } from 'app/types/explore';
+import { ExploreUrlState } from 'app/types/explore';
import store from 'app/core/store';
-const DEFAULT_EXPLORE_STATE: ExploreState = {
+const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
datasource: null,
- datasourceError: null,
- datasourceLoading: null,
- datasourceMissing: false,
- exploreDatasources: [],
- graphInterval: 1000,
- history: [],
- initialQueries: [],
- queryTransactions: [],
+ queries: [],
range: DEFAULT_RANGE,
- showingGraph: true,
- showingLogs: true,
- showingTable: true,
- supportsGraph: null,
- supportsLogs: null,
- supportsTable: null,
};
describe('state functions', () => {
@@ -68,21 +55,19 @@ describe('state functions', () => {
it('returns url parameter value for a state object', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
- initialDatasource: 'foo',
+ datasource: 'foo',
+ queries: [
+ {
+ expr: 'metric{test="a/b"}',
+ },
+ {
+ expr: 'super{foo="x/z"}',
+ },
+ ],
range: {
from: 'now-5h',
to: 'now',
},
- initialQueries: [
- {
- refId: '1',
- expr: 'metric{test="a/b"}',
- },
- {
- refId: '2',
- expr: 'super{foo="x/z"}',
- },
- ],
};
expect(serializeStateToUrlParam(state)).toBe(
'{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
@@ -93,21 +78,19 @@ describe('state functions', () => {
it('returns url parameter value for a state object', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
- initialDatasource: 'foo',
+ datasource: 'foo',
+ queries: [
+ {
+ expr: 'metric{test="a/b"}',
+ },
+ {
+ expr: 'super{foo="x/z"}',
+ },
+ ],
range: {
from: 'now-5h',
to: 'now',
},
- initialQueries: [
- {
- refId: '1',
- expr: 'metric{test="a/b"}',
- },
- {
- refId: '2',
- expr: 'super{foo="x/z"}',
- },
- ],
};
expect(serializeStateToUrlParam(state, true)).toBe(
'["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
@@ -119,35 +102,24 @@ describe('state functions', () => {
it('can parse the serialized state into the original state', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
- initialDatasource: 'foo',
+ datasource: 'foo',
+ queries: [
+ {
+ expr: 'metric{test="a/b"}',
+ },
+ {
+ expr: 'super{foo="x/z"}',
+ },
+ ],
range: {
from: 'now - 5h',
to: 'now',
},
- initialQueries: [
- {
- refId: '1',
- expr: 'metric{test="a/b"}',
- },
- {
- refId: '2',
- expr: 'super{foo="x/z"}',
- },
- ],
};
const serialized = serializeStateToUrlParam(state);
const parsed = parseUrlState(serialized);
- // Account for datasource vs datasourceName
- const { datasource, queries, ...rest } = parsed;
- const resultState = {
- ...rest,
- datasource: DEFAULT_EXPLORE_STATE.datasource,
- initialDatasource: datasource,
- initialQueries: queries,
- };
-
- expect(state).toMatchObject(resultState);
+ expect(state).toMatchObject(parsed);
});
});
});
diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts
index f3273ffa16d..7a9f54a0cae 100644
--- a/public/app/core/utils/explore.ts
+++ b/public/app/core/utils/explore.ts
@@ -1,16 +1,26 @@
+// Libraries
import _ from 'lodash';
-import { colors } from '@grafana/ui';
+// Services & Utils
+import * as dateMath from 'app/core/utils/datemath';
import { renderUrl } from 'app/core/utils/url';
import kbn from 'app/core/utils/kbn';
import store from 'app/core/store';
import { parse as parseDate } from 'app/core/utils/datemath';
-
-import TimeSeries from 'app/core/time_series2';
+import { colors } from '@grafana/ui';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
-import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
-import { DataQuery, DataSourceApi } from 'app/types/series';
-import { RawTimeRange, IntervalValues } from '@grafana/ui';
+
+// Types
+import { RawTimeRange, IntervalValues, DataQuery } from '@grafana/ui/src/types';
+import TimeSeries from 'app/core/time_series2';
+import {
+ ExploreUrlState,
+ HistoryItem,
+ QueryTransaction,
+ ResultType,
+ QueryIntervals,
+ QueryOptions,
+} from 'app/types/explore';
export const DEFAULT_RANGE = {
from: 'now-6h',
@@ -19,6 +29,8 @@ export const DEFAULT_RANGE = {
const MAX_HISTORY_ITEMS = 100;
+export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
+
/**
* Returns an Explore-URL that contains a panel's queries and the dashboard time range.
*
@@ -72,12 +84,68 @@ export async function getExploreUrl(
}
const exploreState = JSON.stringify(state);
- url = renderUrl('/explore', { state: exploreState });
+ url = renderUrl('/explore', { left: exploreState });
}
return url;
}
-const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
+export function buildQueryTransaction(
+ query: DataQuery,
+ rowIndex: number,
+ resultType: ResultType,
+ queryOptions: QueryOptions,
+ range: RawTimeRange,
+ queryIntervals: QueryIntervals,
+ scanning: boolean
+): QueryTransaction {
+ const { interval, intervalMs } = queryIntervals;
+
+ const configuredQueries = [
+ {
+ ...query,
+ ...queryOptions,
+ },
+ ];
+
+ // Clone range for query request
+ // const queryRange: RawTimeRange = { ...range };
+ // const { from, to, raw } = this.timeSrv.timeRange();
+ // Most datasource is using `panelId + query.refId` for cancellation logic.
+ // Using `format` here because it relates to the view panel that the request is for.
+ // However, some datasources don't use `panelId + query.refId`, but only `panelId`.
+ // Therefore panel id has to be unique.
+ const panelId = `${queryOptions.format}-${query.key}`;
+
+ const options = {
+ interval,
+ intervalMs,
+ panelId,
+ targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
+ range: {
+ from: dateMath.parse(range.from, false),
+ to: dateMath.parse(range.to, true),
+ raw: range,
+ },
+ rangeRaw: range,
+ scopedVars: {
+ __interval: { text: interval, value: interval },
+ __interval_ms: { text: intervalMs, value: intervalMs },
+ },
+ };
+
+ return {
+ options,
+ query,
+ resultType,
+ rowIndex,
+ scanning,
+ id: generateKey(), // reusing for unique ID
+ done: false,
+ latency: 0,
+ };
+}
+
+export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
export function parseUrlState(initial: string | undefined): ExploreUrlState {
if (initial) {
@@ -103,12 +171,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
return { datasource: null, queries: [], range: DEFAULT_RANGE };
}
-export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
- const urlState: ExploreUrlState = {
- datasource: state.initialDatasource,
- queries: state.initialQueries.map(clearQueryKeys),
- range: state.range,
- };
+export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
if (compact) {
return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
}
@@ -123,7 +186,7 @@ export function generateRefId(index = 0): string {
return `${index + 1}`;
}
-export function generateQueryKeys(index = 0): { refId: string; key: string } {
+export function generateEmptyQuery(index = 0): { refId: string; key: string } {
return { refId: generateRefId(index), key: generateKey(index) };
}
@@ -132,20 +195,23 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } {
*/
export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
if (queries && typeof queries === 'object' && queries.length > 0) {
- return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) }));
+ return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
}
- return [{ ...generateQueryKeys() }];
+ return [{ ...generateEmptyQuery() }];
}
/**
* A target is non-empty when it has keys (with non-empty values) other than refId and key.
*/
-export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
- return queries.some(
- query =>
- Object.keys(query)
- .map(k => query[k])
- .filter(v => v).length > 2
+export function hasNonEmptyQuery(queries: TQuery[]): boolean {
+ return (
+ queries &&
+ queries.some(
+ query =>
+ Object.keys(query)
+ .map(k => query[k])
+ .filter(v => v).length > 2
+ )
);
}
@@ -180,8 +246,8 @@ export function calculateResultsFromQueryTransactions(
};
}
-export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues {
- if (!datasource || !resolution) {
+export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues {
+ if (!resolution) {
return { interval: '1s', intervalMs: 1000 };
}
@@ -190,7 +256,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res
to: parseDate(range.to, true),
};
- return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
+ return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
}
export function makeTimeSeriesList(dataList) {
@@ -214,7 +280,11 @@ export function makeTimeSeriesList(dataList) {
/**
* Update the query history. Side-effect: store history in local storage
*/
-export function updateHistory(history: HistoryItem[], datasourceId: string, queries: DataQuery[]): HistoryItem[] {
+export function updateHistory(
+ history: Array>,
+ datasourceId: string,
+ queries: T[]
+): Array> {
const ts = Date.now();
queries.forEach(query => {
history = [{ query, ts }, ...history];
diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts
index 4e948116dba..427b0102c95 100644
--- a/public/app/core/utils/text.ts
+++ b/public/app/core/utils/text.ts
@@ -1,4 +1,5 @@
import { TextMatch } from 'app/types/explore';
+import xss from 'xss';
/**
* Adapt findMatchesInText for react-highlight-words findChunks handler.
@@ -22,7 +23,7 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
}
const matches = [];
const cleaned = cleanNeedle(needle);
- let regexp;
+ let regexp: RegExp;
try {
regexp = new RegExp(`(?:${cleaned})`, 'g');
} catch (error) {
@@ -42,3 +43,28 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
});
return matches;
}
+
+const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => {
+ acc[element] = xss.whiteList[element].concat(['class', 'style']);
+ return acc;
+}, {});
+
+const sanitizeXSS = new xss.FilterXSS({
+ whiteList: XSSWL
+});
+
+/**
+ * Returns string safe from XSS attacks.
+ *
+ * Even though we allow the style-attribute, there's still default filtering applied to it
+ * Info: https://github.com/leizongmin/js-xss#customize-css-filter
+ * Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js
+ */
+export function sanitize (unsanitizedString: string): string {
+ try {
+ return sanitizeXSS.process(unsanitizedString);
+ } catch (error) {
+ console.log('String could not be sanitized', unsanitizedString);
+ return unsanitizedString;
+ }
+}
diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts
index ab8be8ad222..824e0e4e9c9 100644
--- a/public/app/core/utils/url.ts
+++ b/public/app/core/utils/url.ts
@@ -11,6 +11,16 @@ export function renderUrl(path: string, query: UrlQueryMap | undefined): string
return path;
}
+export function encodeURIComponentAsAngularJS(val, pctEncodeSpaces) {
+ return encodeURIComponent(val).
+ replace(/%40/gi, '@').
+ replace(/%3A/gi, ':').
+ replace(/%24/g, '$').
+ replace(/%2C/gi, ',').
+ replace(/%3B/gi, ';').
+ replace(/%20/g, (pctEncodeSpaces ? '%20' : '+'));
+}
+
export function toUrlParams(a) {
const s = [];
const rbracket = /\[\]$/;
@@ -22,9 +32,9 @@ export function toUrlParams(a) {
const add = (k, v) => {
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
if (typeof v !== 'boolean') {
- s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
+ s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true);
} else {
- s[s.length] = encodeURIComponent(k);
+ s[s.length] = encodeURIComponentAsAngularJS(k, true);
}
};
diff --git a/public/app/features/all.ts b/public/app/features/all.ts
index 1ba6a85899c..83146596ea0 100644
--- a/public/app/features/all.ts
+++ b/public/app/features/all.ts
@@ -1,7 +1,7 @@
import './annotations/all';
import './templating/all';
import './plugins/all';
-import './dashboard/all';
+import './dashboard';
import './playlist/all';
import './panel/all';
import './org/all';
diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx
index 54200234ddc..cd640b5a357 100644
--- a/public/app/features/api-keys/ApiKeysPage.test.tsx
+++ b/public/app/features/api-keys/ApiKeysPage.test.tsx
@@ -6,7 +6,14 @@ import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock';
const setup = (propOverrides?: object) => {
const props: Props = {
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Api Keys'
+ }
+ } as NavModel,
apiKeys: [] as ApiKey[],
searchQuery: '',
hasFetched: false,
diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx
index e14873fa9f6..41b9b0c8a55 100644
--- a/public/app/features/api-keys/ApiKeysPage.tsx
+++ b/public/app/features/api-keys/ApiKeysPage.tsx
@@ -6,8 +6,7 @@ import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { getApiKeys, getApiKeysCount } from './state/selectors';
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
+import Page from 'app/core/components/Page/Page';
import SlideDown from 'app/core/components/Animations/SlideDown';
import ApiKeysAddedModal from './ApiKeysAddedModal';
import config from 'app/core/config';
@@ -240,18 +239,17 @@ export class ApiKeysPage extends PureComponent {
const { hasFetched, navModel, apiKeysCount } = this.props;
return (
-
-
- {hasFetched ? (
- apiKeysCount > 0 ? (
- this.renderApiKeyList()
- ) : (
- this.renderEmptyList()
- )
- ) : (
-
- )}
-
+
+
+ {hasFetched && (
+ apiKeysCount > 0 ? (
+ this.renderApiKeyList()
+ ) : (
+ this.renderEmptyList()
+ )
+ )}
+
+
);
}
}
diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
index 7ede9618250..f40894426ae 100644
--- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
+++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
@@ -1,132 +1,152 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render API keys table if there are any keys 1`] = `
-
+
`;
exports[`Render should render CTA if there are no API keys 1`] = `
-
-
-
+
-
-
-
+
-
-
-
-
- Add API Key
-
-
-
-
-
-
-
+
+
+
+
+
+
`;
diff --git a/public/app/features/dashboard/alerting_srv.ts b/public/app/features/dashboard/alerting_srv.ts
deleted file mode 100644
index 446c3218f79..00000000000
--- a/public/app/features/dashboard/alerting_srv.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import coreModule from 'app/core/core_module';
-
-export class AlertingSrv {
- dashboard: any;
- alerts: any[];
-
- init(dashboard, alerts) {
- this.dashboard = dashboard;
- this.alerts = alerts || [];
- }
-}
-
-coreModule.service('alertingSrv', AlertingSrv);
diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts
deleted file mode 100644
index 5ec4e5e3929..00000000000
--- a/public/app/features/dashboard/all.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import './dashboard_ctrl';
-import './alerting_srv';
-import './history/history';
-import './dashboard_loader_srv';
-import './dashnav/dashnav';
-import './submenu/submenu';
-import './save_as_modal';
-import './save_modal';
-import './save_provisioned_modal';
-import './shareModalCtrl';
-import './share_snapshot_ctrl';
-import './dashboard_srv';
-import './view_state_srv';
-import './validation_srv';
-import './time_srv';
-import './unsaved_changes_srv';
-import './unsaved_changes_modal';
-import './timepicker/timepicker';
-import './upload';
-import './export/export_modal';
-import './export_data/export_data_modal';
-import './ad_hoc_filters';
-import './repeat_option/repeat_option';
-import './dashgrid/DashboardGridDirective';
-import './dashgrid/RowOptions';
-import './folder_picker/folder_picker';
-import './move_to_folder_modal/move_to_folder';
-import './settings/settings';
-import './panellinks/module';
-import './dashlinks/module';
-
-// angular wrappers
-import { react2AngularDirective } from 'app/core/utils/react2angular';
-import DashboardPermissions from './permissions/DashboardPermissions';
-
-react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);
-
-import coreModule from 'app/core/core_module';
-import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
-import { DashboardImportCtrl } from './dashboard_import_ctrl';
-import { CreateFolderCtrl } from './create_folder_ctrl';
-
-coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
-coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
-coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);
diff --git a/public/app/features/dashboard/ad_hoc_filters.ts b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/ad_hoc_filters.ts
rename to public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
diff --git a/public/app/features/dashboard/components/AdHocFilters/index.ts b/public/app/features/dashboard/components/AdHocFilters/index.ts
new file mode 100644
index 00000000000..522b564d004
--- /dev/null
+++ b/public/app/features/dashboard/components/AdHocFilters/index.ts
@@ -0,0 +1 @@
+export { AdHocFiltersCtrl } from './AdHocFiltersCtrl';
diff --git a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
similarity index 86%
rename from public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
rename to public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
index 95d03152b14..4d46d88a1d2 100644
--- a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
+++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
@@ -1,23 +1,23 @@
import React from 'react';
import _ from 'lodash';
import config from 'app/core/config';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import { PanelModel } from '../../panel_model';
+import { DashboardModel } from '../../dashboard_model';
import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { updateLocation } from 'app/core/actions';
import { store as reduxStore } from 'app/store/store';
-export interface AddPanelPanelProps {
+export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
}
-export interface AddPanelPanelState {
+export interface State {
copiedPanelPlugins: any[];
}
-export class AddPanelPanel extends React.Component {
+export class AddPanelWidget extends React.Component {
constructor(props) {
super(props);
this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
@@ -133,15 +133,15 @@ export class AddPanelPanel extends React.Component
-
-
+
+
+
-
+
-
+
Edit Panel
diff --git a/public/sass/components/_panel_add_panel.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
similarity index 81%
rename from public/sass/components/_panel_add_panel.scss
rename to public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
index 86921fb43f3..5a1cbee4b44 100644
--- a/public/sass/components/_panel_add_panel.scss
+++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
@@ -1,12 +1,12 @@
-.add-panel-container {
+.add-panel-widget-container {
height: 100%;
}
-.add-panel {
+.add-panel-widget {
height: 100%;
}
-.add-panel__header {
+.add-panel-widget__header {
top: 0;
position: absolute;
padding: 0 15px;
@@ -26,7 +26,7 @@
}
}
-.add-panel__close {
+.add-panel-widget__close {
margin-left: auto;
background-color: transparent;
border: 0;
@@ -34,7 +34,7 @@
margin-right: -10px;
}
-.add-panel-btn-container {
+.add-panel-widget__btn-container {
display: flex;
justify-content: center;
align-items: center;
diff --git a/public/app/features/dashboard/components/AddPanelWidget/index.ts b/public/app/features/dashboard/components/AddPanelWidget/index.ts
new file mode 100644
index 00000000000..b96948ab1c0
--- /dev/null
+++ b/public/app/features/dashboard/components/AddPanelWidget/index.ts
@@ -0,0 +1 @@
+export { AddPanelWidget } from './AddPanelWidget';
diff --git a/public/app/features/dashboard/export/export_modal.ts b/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts
similarity index 92%
rename from public/app/features/dashboard/export/export_modal.ts
rename to public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts
index 8136c77cd8f..7769bdf114a 100644
--- a/public/app/features/dashboard/export/export_modal.ts
+++ b/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts
@@ -2,7 +2,7 @@ import angular from 'angular';
import { saveAs } from 'file-saver';
import coreModule from 'app/core/core_module';
-import { DashboardExporter } from './exporter';
+import { DashboardExporter } from './DashboardExporter';
export class DashExportCtrl {
dash: any;
@@ -66,7 +66,7 @@ export class DashExportCtrl {
export function dashExportDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/export/export_modal.html',
+ templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html',
controller: DashExportCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/specs/exporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
similarity index 98%
rename from public/app/features/dashboard/specs/exporter.test.ts
rename to public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
index eac6b0b272a..20ab21541a5 100644
--- a/public/app/features/dashboard/specs/exporter.test.ts
+++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
@@ -6,8 +6,8 @@ jest.mock('app/core/store', () => {
import _ from 'lodash';
import config from 'app/core/config';
-import { DashboardExporter } from '../export/exporter';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardExporter } from './DashboardExporter';
+import { DashboardModel } from '../../dashboard_model';
describe('given dashboard with repeated panels', () => {
let dash, exported;
diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
similarity index 98%
rename from public/app/features/dashboard/export/exporter.ts
rename to public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
index 7aecb5c384f..22b93b767d6 100644
--- a/public/app/features/dashboard/export/exporter.ts
+++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
@@ -1,6 +1,6 @@
import config from 'app/core/config';
import _ from 'lodash';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../dashboard_model';
export class DashboardExporter {
constructor(private datasourceSrv) {}
diff --git a/public/app/features/dashboard/components/DashExportModal/index.ts b/public/app/features/dashboard/components/DashExportModal/index.ts
new file mode 100644
index 00000000000..6529cf07ad9
--- /dev/null
+++ b/public/app/features/dashboard/components/DashExportModal/index.ts
@@ -0,0 +1,2 @@
+export { DashboardExporter } from './DashboardExporter';
+export { DashExportCtrl } from './DashExportCtrl';
diff --git a/public/app/features/dashboard/export/export_modal.html b/public/app/features/dashboard/components/DashExportModal/template.html
similarity index 100%
rename from public/app/features/dashboard/export/export_modal.html
rename to public/app/features/dashboard/components/DashExportModal/template.html
diff --git a/public/app/features/dashboard/dashlinks/module.ts b/public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts
similarity index 99%
rename from public/app/features/dashboard/dashlinks/module.ts
rename to public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts
index c951538d45d..a08e438a46c 100644
--- a/public/app/features/dashboard/dashlinks/module.ts
+++ b/public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts
@@ -1,6 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
-import { iconMap } from './editor';
+import { iconMap } from './DashLinksEditorCtrl';
function dashLinksContainer() {
return {
diff --git a/public/app/features/dashboard/dashlinks/editor.ts b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
similarity index 90%
rename from public/app/features/dashboard/dashlinks/editor.ts
rename to public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
index 482052469db..398ad757bf3 100644
--- a/public/app/features/dashboard/dashlinks/editor.ts
+++ b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
@@ -11,7 +11,7 @@ export let iconMap = {
cloud: 'fa-cloud',
};
-export class DashLinkEditorCtrl {
+export class DashLinksEditorCtrl {
dashboard: any;
iconMap: any;
mode: any;
@@ -65,8 +65,8 @@ export class DashLinkEditorCtrl {
function dashLinksEditor() {
return {
restrict: 'E',
- controller: DashLinkEditorCtrl,
- templateUrl: 'public/app/features/dashboard/dashlinks/editor.html',
+ controller: DashLinksEditorCtrl,
+ templateUrl: 'public/app/features/dashboard/components/DashLinks/editor.html',
bindToController: true,
controllerAs: 'ctrl',
scope: {
diff --git a/public/app/features/dashboard/dashlinks/editor.html b/public/app/features/dashboard/components/DashLinks/editor.html
similarity index 100%
rename from public/app/features/dashboard/dashlinks/editor.html
rename to public/app/features/dashboard/components/DashLinks/editor.html
diff --git a/public/app/features/dashboard/components/DashLinks/index.ts b/public/app/features/dashboard/components/DashLinks/index.ts
new file mode 100644
index 00000000000..ef118d4a84c
--- /dev/null
+++ b/public/app/features/dashboard/components/DashLinks/index.ts
@@ -0,0 +1,2 @@
+export { DashLinksContainerCtrl } from './DashLinksContainerCtrl';
+export { DashLinksEditorCtrl } from './DashLinksEditorCtrl';
diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
similarity index 92%
rename from public/app/features/dashboard/dashnav/dashnav.ts
rename to public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
index 1c83b2d0bdb..d7305b948dc 100644
--- a/public/app/features/dashboard/dashnav/dashnav.ts
+++ b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
@@ -1,7 +1,7 @@
import moment from 'moment';
import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../dashboard_model';
export class DashNavCtrl {
dashboard: DashboardModel;
@@ -60,7 +60,7 @@ export class DashNavCtrl {
modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', {
- src: 'public/app/features/dashboard/partials/shareModal.html',
+ src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
}
@@ -107,7 +107,7 @@ export class DashNavCtrl {
export function dashNavDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/dashnav/dashnav.html',
+ templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts
new file mode 100644
index 00000000000..854e32b24d2
--- /dev/null
+++ b/public/app/features/dashboard/components/DashNav/index.ts
@@ -0,0 +1 @@
+export { DashNavCtrl } from './DashNavCtrl';
diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/components/DashNav/template.html
similarity index 100%
rename from public/app/features/dashboard/dashnav/dashnav.html
rename to public/app/features/dashboard/components/DashNav/template.html
diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx
similarity index 97%
rename from public/app/features/dashboard/permissions/DashboardPermissions.tsx
rename to public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx
index 96d0e23adcd..506709fad75 100644
--- a/public/app/features/dashboard/permissions/DashboardPermissions.tsx
+++ b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx
@@ -8,11 +8,11 @@ import {
addDashboardPermission,
removeDashboardPermission,
updateDashboardPermission,
-} from '../state/actions';
+} from '../../state/actions';
import PermissionList from 'app/core/components/PermissionList/PermissionList';
import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
-import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
+import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
export interface Props {
dashboardId: number;
diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
similarity index 97%
rename from public/app/features/dashboard/settings/settings.ts
rename to public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
index 1e8d96a54cb..a0eb5c8c6b3 100755
--- a/public/app/features/dashboard/settings/settings.ts
+++ b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
@@ -1,5 +1,5 @@
import { coreModule, appEvents, contextSrv } from 'app/core/core';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../dashboard_model';
import $ from 'jquery';
import _ from 'lodash';
import angular from 'angular';
@@ -230,7 +230,7 @@ export class SettingsCtrl {
export function dashboardSettings() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/settings/settings.html',
+ templateUrl: 'public/app/features/dashboard/components/DashboardSettings/template.html',
controller: SettingsCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts
new file mode 100644
index 00000000000..f81b8cdbc67
--- /dev/null
+++ b/public/app/features/dashboard/components/DashboardSettings/index.ts
@@ -0,0 +1 @@
+export { SettingsCtrl } from './SettingsCtrl';
diff --git a/public/app/features/dashboard/settings/settings.html b/public/app/features/dashboard/components/DashboardSettings/template.html
similarity index 98%
rename from public/app/features/dashboard/settings/settings.html
rename to public/app/features/dashboard/components/DashboardSettings/template.html
index 46d84a7a2fd..97002f7bf92 100644
--- a/public/app/features/dashboard/settings/settings.html
+++ b/public/app/features/dashboard/components/DashboardSettings/template.html
@@ -51,7 +51,8 @@
on-change="ctrl.onFolderChange($folder)"
enable-create-new="true"
is-valid-selection="true"
- label-class="width-7">
+ label-class="width-7"
+ dashboard-id="ctrl.dashboard.id">
diff --git a/public/app/features/dashboard/export_data/export_data_modal.ts b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts
similarity index 92%
rename from public/app/features/dashboard/export_data/export_data_modal.ts
rename to public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts
index 460f80079d9..f87daa94ee7 100644
--- a/public/app/features/dashboard/export_data/export_data_modal.ts
+++ b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts
@@ -31,7 +31,7 @@ export class ExportDataModalCtrl {
export function exportDataModal() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/export_data/export_data_modal.html',
+ templateUrl: 'public/app/features/dashboard/components/ExportDataModal/template.html',
controller: ExportDataModalCtrl,
controllerAs: 'ctrl',
scope: {
diff --git a/public/app/features/dashboard/components/ExportDataModal/index.ts b/public/app/features/dashboard/components/ExportDataModal/index.ts
new file mode 100644
index 00000000000..6df4fd00434
--- /dev/null
+++ b/public/app/features/dashboard/components/ExportDataModal/index.ts
@@ -0,0 +1 @@
+export { ExportDataModalCtrl } from './ExportDataModalCtrl';
diff --git a/public/app/features/dashboard/export_data/export_data_modal.html b/public/app/features/dashboard/components/ExportDataModal/template.html
similarity index 100%
rename from public/app/features/dashboard/export_data/export_data_modal.html
rename to public/app/features/dashboard/components/ExportDataModal/template.html
diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts
similarity index 90%
rename from public/app/features/dashboard/folder_picker/folder_picker.ts
rename to public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts
index 80651fecb7e..93d43d36038 100644
--- a/public/app/features/dashboard/folder_picker/folder_picker.ts
+++ b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts
@@ -21,6 +21,7 @@ export class FolderPickerCtrl {
hasValidationError: boolean;
validationError: any;
isEditor: boolean;
+ dashboardId?: number;
/** @ngInject */
constructor(private backendSrv, private validationSrv, private contextSrv) {
@@ -144,7 +145,13 @@ export class FolderPickerCtrl {
if (this.isEditor) {
folder = rootFolder;
} else {
- folder = result.length > 0 ? result[0] : resetFolder;
+ // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard
+ const isPersistedDashBoard = this.dashboardId ? true : false;
+ if (isPersistedDashBoard) {
+ folder = resetFolder;
+ } else {
+ folder = result.length > 0 ? result[0] : resetFolder;
+ }
}
}
@@ -161,7 +168,7 @@ export class FolderPickerCtrl {
export function folderPicker() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/folder_picker/folder_picker.html',
+ templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html',
controller: FolderPickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@@ -176,6 +183,7 @@ export function folderPicker() {
exitFolderCreation: '&',
enableCreateNew: '@',
enableReset: '@',
+ dashboardId: '',
},
};
}
diff --git a/public/app/features/dashboard/components/FolderPicker/index.ts b/public/app/features/dashboard/components/FolderPicker/index.ts
new file mode 100644
index 00000000000..7550f7fd573
--- /dev/null
+++ b/public/app/features/dashboard/components/FolderPicker/index.ts
@@ -0,0 +1 @@
+export { FolderPickerCtrl } from './FolderPickerCtrl';
diff --git a/public/app/features/dashboard/folder_picker/folder_picker.html b/public/app/features/dashboard/components/FolderPicker/template.html
similarity index 100%
rename from public/app/features/dashboard/folder_picker/folder_picker.html
rename to public/app/features/dashboard/components/FolderPicker/template.html
diff --git a/public/app/features/dashboard/dashgrid/RowOptions.ts b/public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/dashgrid/RowOptions.ts
rename to public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts
diff --git a/public/app/features/dashboard/components/RowOptions/index.ts b/public/app/features/dashboard/components/RowOptions/index.ts
new file mode 100644
index 00000000000..626e4cd65b3
--- /dev/null
+++ b/public/app/features/dashboard/components/RowOptions/index.ts
@@ -0,0 +1 @@
+export { RowOptionsCtrl } from './RowOptionsCtrl';
diff --git a/public/app/features/dashboard/partials/row_options.html b/public/app/features/dashboard/components/RowOptions/template.html
similarity index 100%
rename from public/app/features/dashboard/partials/row_options.html
rename to public/app/features/dashboard/components/RowOptions/template.html
diff --git a/public/app/features/dashboard/specs/save_as_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.test.ts
similarity index 95%
rename from public/app/features/dashboard/specs/save_as_modal.test.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.test.ts
index ceb7e49c550..91b9097b626 100644
--- a/public/app/features/dashboard/specs/save_as_modal.test.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.test.ts
@@ -1,4 +1,4 @@
-import { SaveDashboardAsModalCtrl } from '../save_as_modal';
+import { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
import { describe, it, expect } from 'test/lib/common';
describe('saving dashboard as', () => {
diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
similarity index 96%
rename from public/app/features/dashboard/save_as_modal.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
index 4649bc18f9f..6a470785fdb 100644
--- a/public/app/features/dashboard/save_as_modal.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
@@ -25,7 +25,8 @@ const template = `
enter-folder-creation="ctrl.onEnterFolderCreation()"
exit-folder-creation="ctrl.onExitFolderCreation()"
enable-create-new="true"
- label-class="width-7">
+ label-class="width-7"
+ dashboard-id="ctrl.clone.id">
diff --git a/public/app/features/dashboard/specs/save_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts
similarity index 97%
rename from public/app/features/dashboard/specs/save_modal.test.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts
index 669ae43a0ff..f973c1b8e63 100644
--- a/public/app/features/dashboard/specs/save_modal.test.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts
@@ -1,4 +1,4 @@
-import { SaveDashboardModalCtrl } from '../save_modal';
+import { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
const setup = (timeChanged, variableValuesChanged, cb) => {
const dash = {
diff --git a/public/app/features/dashboard/save_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/save_modal.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts
diff --git a/public/app/features/dashboard/specs/save_provisioned_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts
similarity index 87%
rename from public/app/features/dashboard/specs/save_provisioned_modal.test.ts
rename to public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts
index a3ab27a984f..86048e861bd 100644
--- a/public/app/features/dashboard/specs/save_provisioned_modal.test.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts
@@ -1,4 +1,4 @@
-import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal';
+import { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';
describe('SaveProvisionedDashboardModalCtrl', () => {
const json = {
diff --git a/public/app/features/dashboard/save_provisioned_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/save_provisioned_modal.ts
rename to public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.ts
diff --git a/public/app/features/dashboard/components/SaveModals/index.ts b/public/app/features/dashboard/components/SaveModals/index.ts
new file mode 100644
index 00000000000..afab0796d28
--- /dev/null
+++ b/public/app/features/dashboard/components/SaveModals/index.ts
@@ -0,0 +1,2 @@
+export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
+export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
diff --git a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
similarity index 96%
rename from public/app/features/dashboard/specs/share_modal_ctrl.test.ts
rename to public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
index 70d301ed5ff..3181231cb53 100644
--- a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts
+++ b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
@@ -1,7 +1,6 @@
-import '../shareModalCtrl';
-import { ShareModalCtrl } from '../shareModalCtrl';
import config from 'app/core/config';
-import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
+import { ShareModalCtrl } from './ShareModalCtrl';
describe('ShareModalCtrl', () => {
const ctx = {
diff --git a/public/app/features/dashboard/shareModalCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/shareModalCtrl.ts
rename to public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts
diff --git a/public/app/features/dashboard/share_snapshot_ctrl.ts b/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/share_snapshot_ctrl.ts
rename to public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts
diff --git a/public/app/features/dashboard/components/ShareModal/index.ts b/public/app/features/dashboard/components/ShareModal/index.ts
new file mode 100644
index 00000000000..3f27d5a1ba3
--- /dev/null
+++ b/public/app/features/dashboard/components/ShareModal/index.ts
@@ -0,0 +1,2 @@
+export { ShareModalCtrl } from './ShareModalCtrl';
+export { ShareSnapshotCtrl } from './ShareSnapshotCtrl';
diff --git a/public/app/features/dashboard/partials/shareModal.html b/public/app/features/dashboard/components/ShareModal/template.html
similarity index 100%
rename from public/app/features/dashboard/partials/shareModal.html
rename to public/app/features/dashboard/components/ShareModal/template.html
diff --git a/public/app/features/dashboard/submenu/submenu.ts b/public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts
similarity index 86%
rename from public/app/features/dashboard/submenu/submenu.ts
rename to public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts
index 184d29facee..502e467ad2b 100644
--- a/public/app/features/dashboard/submenu/submenu.ts
+++ b/public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts
@@ -1,7 +1,7 @@
import angular from 'angular';
import _ from 'lodash';
-export class SubmenuCtrl {
+export class SubMenuCtrl {
annotations: any;
variables: any;
dashboard: any;
@@ -29,8 +29,8 @@ export class SubmenuCtrl {
export function submenuDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/submenu/submenu.html',
- controller: SubmenuCtrl,
+ templateUrl: 'public/app/features/dashboard/components/SubMenu/template.html',
+ controller: SubMenuCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
diff --git a/public/app/features/dashboard/components/SubMenu/index.ts b/public/app/features/dashboard/components/SubMenu/index.ts
new file mode 100644
index 00000000000..1790aa66782
--- /dev/null
+++ b/public/app/features/dashboard/components/SubMenu/index.ts
@@ -0,0 +1 @@
+export { SubMenuCtrl } from './SubMenuCtrl';
diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/components/SubMenu/template.html
similarity index 100%
rename from public/app/features/dashboard/submenu/submenu.html
rename to public/app/features/dashboard/components/SubMenu/template.html
diff --git a/public/app/features/dashboard/timepicker/timepicker.ts b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts
similarity index 95%
rename from public/app/features/dashboard/timepicker/timepicker.ts
rename to public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts
index c89e49b54b3..0c388c27f8d 100644
--- a/public/app/features/dashboard/timepicker/timepicker.ts
+++ b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts
@@ -159,7 +159,7 @@ export class TimePickerCtrl {
export function settingsDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/timepicker/settings.html',
+ templateUrl: 'public/app/features/dashboard/components/TimePicker/settings.html',
controller: TimePickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@@ -172,7 +172,7 @@ export function settingsDirective() {
export function timePickerDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/timepicker/timepicker.html',
+ templateUrl: 'public/app/features/dashboard/components/TimePicker/template.html',
controller: TimePickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@@ -185,5 +185,5 @@ export function timePickerDirective() {
angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective);
angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
-import { inputDateDirective } from './input_date';
+import { inputDateDirective } from './validation';
angular.module('grafana.directives').directive('inputDatetime', inputDateDirective);
diff --git a/public/app/features/dashboard/components/TimePicker/index.ts b/public/app/features/dashboard/components/TimePicker/index.ts
new file mode 100644
index 00000000000..ca6e2792c43
--- /dev/null
+++ b/public/app/features/dashboard/components/TimePicker/index.ts
@@ -0,0 +1 @@
+export { TimePickerCtrl } from './TimePickerCtrl';
diff --git a/public/app/features/dashboard/timepicker/settings.html b/public/app/features/dashboard/components/TimePicker/settings.html
similarity index 100%
rename from public/app/features/dashboard/timepicker/settings.html
rename to public/app/features/dashboard/components/TimePicker/settings.html
diff --git a/public/app/features/dashboard/timepicker/timepicker.html b/public/app/features/dashboard/components/TimePicker/template.html
similarity index 100%
rename from public/app/features/dashboard/timepicker/timepicker.html
rename to public/app/features/dashboard/components/TimePicker/template.html
diff --git a/public/app/features/dashboard/timepicker/input_date.ts b/public/app/features/dashboard/components/TimePicker/validation.ts
similarity index 100%
rename from public/app/features/dashboard/timepicker/input_date.ts
rename to public/app/features/dashboard/components/TimePicker/validation.ts
diff --git a/public/app/features/dashboard/unsaved_changes_modal.ts b/public/app/features/dashboard/components/UnsavedChangesModal/UnsavedChangesModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/unsaved_changes_modal.ts
rename to public/app/features/dashboard/components/UnsavedChangesModal/UnsavedChangesModalCtrl.ts
diff --git a/public/app/features/dashboard/components/UnsavedChangesModal/index.ts b/public/app/features/dashboard/components/UnsavedChangesModal/index.ts
new file mode 100644
index 00000000000..43943f06694
--- /dev/null
+++ b/public/app/features/dashboard/components/UnsavedChangesModal/index.ts
@@ -0,0 +1 @@
+export { UnsavedChangesModalCtrl } from './UnsavedChangesModalCtrl';
diff --git a/public/app/features/dashboard/specs/history_ctrl.test.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
similarity index 98%
rename from public/app/features/dashboard/specs/history_ctrl.test.ts
rename to public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
index 632f3489dae..2b257e148f5 100644
--- a/public/app/features/dashboard/specs/history_ctrl.test.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
@@ -1,6 +1,6 @@
import _ from 'lodash';
-import { HistoryListCtrl } from 'app/features/dashboard/history/history';
-import { versions, compare, restore } from './history_mocks';
+import { HistoryListCtrl } from './HistoryListCtrl';
+import { versions, compare, restore } from './__mocks__/history';
import $q from 'q';
describe('HistoryListCtrl', () => {
diff --git a/public/app/features/dashboard/history/history.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
similarity index 96%
rename from public/app/features/dashboard/history/history.ts
rename to public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
index 3563ccc7766..b8632e2eeae 100644
--- a/public/app/features/dashboard/history/history.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
@@ -1,12 +1,10 @@
-import './history_srv';
-
import _ from 'lodash';
import angular from 'angular';
import moment from 'moment';
import locationUtil from 'app/core/utils/location_util';
-import { DashboardModel } from '../dashboard_model';
-import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
+import { DashboardModel } from '../../dashboard_model';
+import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './HistorySrv';
export class HistoryListCtrl {
appending: boolean;
@@ -200,7 +198,7 @@ export class HistoryListCtrl {
export function dashboardHistoryDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/history/history.html',
+ templateUrl: 'public/app/features/dashboard/components/VersionHistory/template.html',
controller: HistoryListCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/specs/history_srv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
similarity index 90%
rename from public/app/features/dashboard/specs/history_srv.test.ts
rename to public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
index 1e2bd57a221..75766060e7f 100644
--- a/public/app/features/dashboard/specs/history_srv.test.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
@@ -1,7 +1,6 @@
-import '../history/history_srv';
-import { versions, restore } from './history_mocks';
-import { HistorySrv } from '../history/history_srv';
-import { DashboardModel } from '../dashboard_model';
+import { versions, restore } from './__mocks__/history';
+import { HistorySrv } from './HistorySrv';
+import { DashboardModel } from '../../dashboard_model';
jest.mock('app/core/store');
describe('historySrv', () => {
diff --git a/public/app/features/dashboard/history/history_srv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
similarity index 96%
rename from public/app/features/dashboard/history/history_srv.ts
rename to public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
index 7f7dc950de3..d52f3ab879c 100644
--- a/public/app/features/dashboard/history/history_srv.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
@@ -1,6 +1,6 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../dashboard_model';
export interface HistoryListOpts {
limit: number;
diff --git a/public/app/features/dashboard/specs/history_mocks.ts b/public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts
similarity index 100%
rename from public/app/features/dashboard/specs/history_mocks.ts
rename to public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts
diff --git a/public/app/features/dashboard/components/VersionHistory/index.ts b/public/app/features/dashboard/components/VersionHistory/index.ts
new file mode 100644
index 00000000000..138de434bf3
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/index.ts
@@ -0,0 +1,2 @@
+export { HistoryListCtrl } from './HistoryListCtrl';
+export { HistorySrv } from './HistorySrv';
diff --git a/public/app/features/dashboard/history/history.html b/public/app/features/dashboard/components/VersionHistory/template.html
similarity index 100%
rename from public/app/features/dashboard/history/history.html
rename to public/app/features/dashboard/components/VersionHistory/template.html
diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts
index 6611a728803..5c4480dbad5 100644
--- a/public/app/features/dashboard/dashboard_ctrl.ts
+++ b/public/app/features/dashboard/dashboard_ctrl.ts
@@ -22,7 +22,6 @@ export class DashboardCtrl {
private keybindingSrv,
private timeSrv,
private variableSrv,
- private alertingSrv,
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
@@ -54,7 +53,6 @@ export class DashboardCtrl {
// init services
this.timeSrv.init(dashboard);
- this.alertingSrv.init(dashboard, data.alerts);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
index f0e97162d43..cfff64cb042 100644
--- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
@@ -5,7 +5,7 @@ import classNames from 'classnames';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { importPluginModule } from 'app/features/plugins/plugin_loader';
-import { AddPanelPanel } from './AddPanelPanel';
+import { AddPanelWidget } from '../components/AddPanelWidget';
import { getPanelPluginNotFound } from './PanelPluginNotFound';
import { DashboardRow } from './DashboardRow';
import { PanelChrome } from './PanelChrome';
@@ -53,7 +53,7 @@ export class DashboardPanel extends PureComponent
{
}
renderAddPanel() {
- return ;
+ return ;
}
onPluginTypeChanged = (plugin: PanelPlugin) => {
diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx
index d71a274ab10..d4f6859f1b6 100644
--- a/public/app/features/dashboard/dashgrid/DataPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx
@@ -12,8 +12,7 @@ import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource
import kbn from 'app/core/utils/kbn';
// Types
-import { DataQueryOptions, DataQueryResponse } from 'app/types';
-import { TimeRange, TimeSeries, LoadingState } from '@grafana/ui';
+import { TimeRange, TimeSeries, LoadingState, DataQueryResponse, DataQueryOptions } from '@grafana/ui/src/types';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx
index 46534cac065..6b4ef48c32e 100644
--- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx
@@ -20,6 +20,7 @@ import { PanelPlugin } from 'app/types';
import { TimeRange } from '@grafana/ui';
import variables from 'sass/_variables.scss';
+import templateSrv from 'app/features/templating/template_srv';
export interface Props {
panel: PanelModel;
@@ -78,6 +79,10 @@ export class PanelChrome extends PureComponent {
});
};
+ onInterpolate = (value: string, format?: string) => {
+ return templateSrv.replace(value, this.props.panel.scopedVars, format);
+ };
+
get isVisible() {
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
}
@@ -124,9 +129,10 @@ export class PanelChrome extends PureComponent {
timeSeries={timeSeries}
timeRange={timeRange}
options={panel.getOptions(plugin.exports.PanelDefaults)}
- width={width - 2 * variables.panelHorizontalPadding }
+ width={width - 2 * variables.panelHorizontalPadding}
height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding}
renderCounter={renderCounter}
+ onInterpolate={this.onInterpolate}
/>
);
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
index 8b7afd7d09e..b5cd9258c08 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
@@ -3,6 +3,7 @@ import classNames from 'classnames';
import PanelHeaderCorner from './PanelHeaderCorner';
import { PanelHeaderMenu } from './PanelHeaderMenu';
+import templateSrv from 'app/features/templating/template_srv';
import { DashboardModel } from 'app/features/dashboard/dashboard_model';
import { PanelModel } from 'app/features/dashboard/panel_model';
@@ -45,7 +46,9 @@ export class PanelHeader extends Component
{
const isFullscreen = false;
const isLoading = false;
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
- const { panel, dashboard, timeInfo } = this.props;
+ const { panel, dashboard, timeInfo, scopedVars } = this.props;
+ const title = templateSrv.replaceWithText(panel.title, scopedVars);
+
return (
<>
{
- {panel.title}
+ {title}
{this.state.panelMenuOpen && (
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
index 6b6f81fc579..01f790f73ee 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
@@ -3,7 +3,7 @@ import Remarkable from 'remarkable';
import { Tooltip } from '@grafana/ui';
import { PanelModel } from 'app/features/dashboard/panel_model';
import templateSrv from 'app/features/templating/template_srv';
-import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
enum InfoModes {
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
index d42b48fe1d6..66a942f0afc 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
@@ -1,11 +1,11 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { PanelMenuItem } from '@grafana/ui';
interface Props {
children: any;
}
-export const PanelHeaderMenuItem: SFC
= props => {
+export const PanelHeaderMenuItem: FC = props => {
const isSubMenu = props.type === 'submenu';
const isDivider = props.type === 'divider';
return isDivider ? (
diff --git a/public/app/features/dashboard/dashgrid/PanelResizer.tsx b/public/app/features/dashboard/dashgrid/PanelResizer.tsx
index 2a4bf8379a6..ca8abd0d1e3 100644
--- a/public/app/features/dashboard/dashgrid/PanelResizer.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelResizer.tsx
@@ -15,7 +15,7 @@ interface State {
}
export class PanelResizer extends PureComponent {
- initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.4);
+ initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.3);
prevEditorHeight: number;
throttledChangeHeight: (height: number) => void;
throttledResizeDone: () => void;
diff --git a/public/app/features/dashboard/folder_permissions_ctrl.ts b/public/app/features/dashboard/folder_permissions_ctrl.ts
deleted file mode 100644
index 4ab91acb3d9..00000000000
--- a/public/app/features/dashboard/folder_permissions_ctrl.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { FolderPageLoader } from './folder_page_loader';
-
-export class FolderPermissionsCtrl {
- navModel: any;
- folderId: number;
- uid: string;
- dashboard: any;
- meta: any;
-
- /** @ngInject */
- constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
- if (this.$routeParams.uid) {
- this.uid = $routeParams.uid;
-
- new FolderPageLoader(this.backendSrv).load(this, this.uid, 'manage-folder-permissions').then(folder => {
- if ($location.path() !== folder.meta.url) {
- $location.path(`${folder.meta.url}/permissions`).replace();
- }
-
- this.dashboard = folder.dashboard;
- this.meta = folder.meta;
- });
- }
- }
-}
diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts
new file mode 100644
index 00000000000..efa54f0ee07
--- /dev/null
+++ b/public/app/features/dashboard/index.ts
@@ -0,0 +1,33 @@
+import './dashboard_ctrl';
+import './time_srv';
+import './dashgrid/DashboardGridDirective';
+
+// Services
+import './services/DashboardViewStateSrv';
+import './services/UnsavedChangesSrv';
+import './services/DashboardLoaderSrv';
+import './services/DashboardSrv';
+
+// Components
+import './components/DashLinks';
+import './components/DashExportModal';
+import './components/DashNav';
+import './components/ExportDataModal';
+import './components/FolderPicker';
+import './components/VersionHistory';
+import './components/DashboardSettings';
+import './components/SubMenu';
+import './components/TimePicker';
+import './components/UnsavedChangesModal';
+import './components/SaveModals';
+import './components/ShareModal';
+import './components/AdHocFilters';
+import './components/RowOptions';
+
+import DashboardPermissions from './components/DashboardPermissions/DashboardPermissions';
+
+// angular wrappers
+import { react2AngularDirective } from 'app/core/utils/react2angular';
+
+react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);
+
diff --git a/public/app/features/dashboard/panel_editor/DataSourceOption.tsx b/public/app/features/dashboard/panel_editor/DataSourceOption.tsx
index 9a3ce527510..e4bbcfffe1d 100644
--- a/public/app/features/dashboard/panel_editor/DataSourceOption.tsx
+++ b/public/app/features/dashboard/panel_editor/DataSourceOption.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { Tooltip } from '@grafana/ui';
interface Props {
@@ -10,7 +10,7 @@ interface Props {
tooltipInfo?: any;
}
-export const DataSourceOptions: SFC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
+export const DataSourceOptions: FC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
const dsOption = (
{label}
diff --git a/public/app/features/dashboard/panel_editor/EditorTabBody.tsx b/public/app/features/dashboard/panel_editor/EditorTabBody.tsx
index dbea7ed59bc..0413cae8a7b 100644
--- a/public/app/features/dashboard/panel_editor/EditorTabBody.tsx
+++ b/public/app/features/dashboard/panel_editor/EditorTabBody.tsx
@@ -10,6 +10,8 @@ interface Props {
heading: string;
renderToolbar?: () => JSX.Element;
toolbarItems?: EditorToolbarView[];
+ scrollTop?: number;
+ setScrollTop?: (value: React.MouseEvent
) => void;
}
export interface EditorToolbarView {
@@ -103,23 +105,20 @@ export class EditorTabBody extends PureComponent {
}
render() {
- const { children, renderToolbar, heading, toolbarItems } = this.props;
+ const { children, renderToolbar, heading, toolbarItems, scrollTop, setScrollTop } = this.props;
const { openView, fadeIn, isOpen } = this.state;
return (
<>
-
{heading}
- {renderToolbar && renderToolbar()}
- {toolbarItems.length > 0 && (
- <>
-
- {toolbarItems.map(item => this.renderButton(item))}
- >
- )}
+
+
{heading}
+ {renderToolbar && renderToolbar()}
+
+ {toolbarItems.map(item => this.renderButton(item))}
-
+
{openView && this.renderOpenView(openView)}
diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx
index 47c4f358136..28d822e3ad5 100644
--- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx
+++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx
@@ -3,24 +3,22 @@ import React, { PureComponent } from 'react';
import _ from 'lodash';
// Components
-import 'app/features/panel/metrics_tab';
import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { QueryInspector } from './QueryInspector';
import { QueryOptions } from './QueryOptions';
-import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
import { PanelOptionsGroup } from '@grafana/ui';
+import { QueryEditorRow } from './QueryEditorRow';
// Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
-import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
import config from 'app/core/config';
// Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
-import { DataQuery, DataSourceSelectItem } from 'app/types';
+import { DataQuery, DataSourceSelectItem } from '@grafana/ui/src/types';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
interface Props {
@@ -34,66 +32,27 @@ interface State {
isLoadingHelp: boolean;
isPickerOpen: boolean;
isAddingMixed: boolean;
+ scrollTop: number;
}
export class QueriesTab extends PureComponent {
- element: HTMLElement;
- component: AngularComponent;
datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
backendSrv: BackendSrv = getBackendSrv();
- constructor(props) {
- super(props);
-
- this.state = {
- isLoadingHelp: false,
- currentDS: this.findCurrentDataSource(),
- helpContent: null,
- isPickerOpen: false,
- isAddingMixed: false,
- };
- }
+ state: State = {
+ isLoadingHelp: false,
+ currentDS: this.findCurrentDataSource(),
+ helpContent: null,
+ isPickerOpen: false,
+ isAddingMixed: false,
+ scrollTop: 0,
+ };
findCurrentDataSource(): DataSourceSelectItem {
const { panel } = this.props;
return this.datasources.find(datasource => datasource.value === panel.datasource) || this.datasources[0];
}
- getAngularQueryComponentScope(): AngularQueryComponentScope {
- const { panel, dashboard } = this.props;
-
- return {
- panel: panel,
- dashboard: dashboard,
- refresh: () => panel.refresh(),
- render: () => panel.render,
- addQuery: this.onAddQuery,
- moveQuery: this.onMoveQuery,
- removeQuery: this.onRemoveQuery,
- events: panel.events,
- };
- }
-
- componentDidMount() {
- if (!this.element) {
- return;
- }
-
- const loader = getAngularLoader();
- const template = ' ';
- const scopeProps = {
- ctrl: this.getAngularQueryComponentScope(),
- };
-
- this.component = loader.load(this.element, scopeProps, template);
- }
-
- componentWillUnmount() {
- if (this.component) {
- this.component.destroy();
- }
- }
-
onChangeDataSource = datasource => {
const { panel } = this.props;
const { currentDS } = this.state;
@@ -137,7 +96,7 @@ export class QueriesTab extends PureComponent {
onAddQuery = (query?: Partial) => {
this.props.panel.addQuery(query);
- this.forceUpdate();
+ this.setState({ scrollTop: this.state.scrollTop + 100000 });
};
onAddQueryClick = () => {
@@ -146,9 +105,7 @@ export class QueriesTab extends PureComponent {
return;
}
- this.props.panel.addQuery();
- this.component.digest();
- this.forceUpdate();
+ this.onAddQuery();
};
onRemoveQuery = (query: DataQuery) => {
@@ -171,9 +128,20 @@ export class QueriesTab extends PureComponent {
};
renderToolbar = () => {
- const { currentDS } = this.state;
+ const { currentDS, isAddingMixed } = this.state;
- return ;
+ return (
+ <>
+
+
+ {!isAddingMixed && (
+
+ Add Query
+
+ )}
+ {isAddingMixed && this.renderMixedPicker()}
+ >
+ );
};
renderMixedPicker = () => {
@@ -190,17 +158,21 @@ export class QueriesTab extends PureComponent {
onAddMixedQuery = datasource => {
this.onAddQuery({ datasource: datasource.name });
- this.component.digest();
- this.setState({ isAddingMixed: false });
+ this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 });
};
onMixedPickerBlur = () => {
this.setState({ isAddingMixed: false });
};
+ setScrollTop = (event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ this.setState({ scrollTop: target.scrollTop });
+ };
+
render() {
const { panel } = this.props;
- const { currentDS, isAddingMixed } = this.state;
+ const { currentDS, scrollTop } = this.state;
const queryInspector: EditorToolbarView = {
title: 'Query Inspector',
@@ -214,32 +186,28 @@ export class QueriesTab extends PureComponent {
};
return (
-
+
<>
-
-
-
(this.element = element)} />
-
-
-
-
-
-
- {' '}
- {panel.getNextQueryLetter()}
-
-
-
- {!isAddingMixed && (
-
- Add Query
-
- )}
- {isAddingMixed && this.renderMixedPicker()}
-
-
-
-
+
+ {panel.targets.map((query, index) => (
+
+ ))}
+
diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
new file mode 100644
index 00000000000..2651ab0608c
--- /dev/null
+++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
@@ -0,0 +1,257 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import _ from 'lodash';
+
+// Utils & Services
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import { Emitter } from 'app/core/utils/emitter';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { DataQuery, DataSourceApi } from '@grafana/ui';
+
+interface Props {
+ panel: PanelModel;
+ query: DataQuery;
+ onAddQuery: (query?: DataQuery) => void;
+ onRemoveQuery: (query: DataQuery) => void;
+ onMoveQuery: (query: DataQuery, direction: number) => void;
+ dataSourceValue: string | null;
+ inMixedMode: boolean;
+}
+
+interface State {
+ loadedDataSourceValue: string | null | undefined;
+ datasource: DataSourceApi | null;
+ isCollapsed: boolean;
+ angularScope: AngularQueryComponentScope | null;
+}
+
+export class QueryEditorRow extends PureComponent
{
+ element: HTMLElement | null = null;
+ angularQueryEditor: AngularComponent | null = null;
+
+ state: State = {
+ datasource: null,
+ isCollapsed: false,
+ angularScope: null,
+ loadedDataSourceValue: undefined,
+ };
+
+ componentDidMount() {
+ this.loadDatasource();
+ }
+
+ getAngularQueryComponentScope(): AngularQueryComponentScope {
+ const { panel, query } = this.props;
+ const { datasource } = this.state;
+
+ return {
+ datasource: datasource,
+ target: query,
+ panel: panel,
+ refresh: () => panel.refresh(),
+ render: () => panel.render(),
+ events: panel.events,
+ };
+ }
+
+ async loadDatasource() {
+ const { query, panel } = this.props;
+ const dataSourceSrv = getDatasourceSrv();
+ const datasource = await dataSourceSrv.get(query.datasource || panel.datasource);
+
+ this.setState({ datasource, loadedDataSourceValue: this.props.dataSourceValue });
+ }
+
+ componentDidUpdate() {
+ const { loadedDataSourceValue } = this.state;
+
+ // check if we need to load another datasource
+ if (loadedDataSourceValue !== this.props.dataSourceValue) {
+ if (this.angularQueryEditor) {
+ this.angularQueryEditor.destroy();
+ this.angularQueryEditor = null;
+ }
+ this.loadDatasource();
+ return;
+ }
+
+ if (!this.element || this.angularQueryEditor) {
+ return;
+ }
+
+ const loader = getAngularLoader();
+ const template = ' ';
+ const scopeProps = { ctrl: this.getAngularQueryComponentScope() };
+
+ this.angularQueryEditor = loader.load(this.element, scopeProps, template);
+
+ // give angular time to compile
+ setTimeout(() => {
+ this.setState({ angularScope: scopeProps.ctrl });
+ }, 10);
+ }
+
+ componentWillUnmount() {
+ if (this.angularQueryEditor) {
+ this.angularQueryEditor.destroy();
+ }
+ }
+
+ onToggleCollapse = () => {
+ this.setState({ isCollapsed: !this.state.isCollapsed });
+ };
+
+ onQueryChange = (query: DataQuery) => {
+ Object.assign(this.props.query, query);
+ this.onExecuteQuery();
+ };
+
+ onExecuteQuery = () => {
+ this.props.panel.refresh();
+ };
+
+ renderPluginEditor() {
+ const { query } = this.props;
+ const { datasource } = this.state;
+
+ if (datasource.pluginExports.QueryCtrl) {
+ return (this.element = element)} />;
+ }
+
+ if (datasource.pluginExports.QueryEditor) {
+ const QueryEditor = datasource.pluginExports.QueryEditor;
+ return (
+
+ );
+ }
+
+ return
Data source plugin does not export any Query Editor component
;
+ }
+
+ onToggleEditMode = () => {
+ const { angularScope } = this.state;
+
+ if (angularScope && angularScope.toggleEditorMode) {
+ angularScope.toggleEditorMode();
+ this.angularQueryEditor.digest();
+ }
+
+ if (this.state.isCollapsed) {
+ this.setState({ isCollapsed: false });
+ }
+ };
+
+ get hasTextEditMode() {
+ const { angularScope } = this.state;
+ return angularScope && angularScope.toggleEditorMode;
+ }
+
+ onRemoveQuery = () => {
+ this.props.onRemoveQuery(this.props.query);
+ };
+
+ onCopyQuery = () => {
+ const copy = _.cloneDeep(this.props.query);
+ this.props.onAddQuery(copy);
+ };
+
+ onDisableQuery = () => {
+ this.props.query.hide = !this.props.query.hide;
+ this.onExecuteQuery();
+ this.forceUpdate();
+ };
+
+ renderCollapsedText(): string | null {
+ const { angularScope } = this.state;
+
+ if (angularScope && angularScope.getCollapsedText) {
+ return angularScope.getCollapsedText();
+ }
+
+ return null;
+ }
+
+ render() {
+ const { query, inMixedMode } = this.props;
+ const { datasource, isCollapsed } = this.state;
+ const isDisabled = query.hide;
+
+ const bodyClasses = classNames('query-editor-row__body gf-form-query', {
+ 'query-editor-row__body--collapsed': isCollapsed,
+ });
+
+ const rowClasses = classNames('query-editor-row', {
+ 'query-editor-row--disabled': isDisabled,
+ 'gf-form-disabled': isDisabled,
+ });
+
+ if (!datasource) {
+ return null;
+ }
+
+ return (
+
+
+
+ {isCollapsed && }
+ {!isCollapsed && }
+ {query.refId}
+ {inMixedMode && ({datasource.name}) }
+ {isDisabled && Disabled }
+
+
+ {isCollapsed &&
{this.renderCollapsedText()}
}
+
+
+ {this.hasTextEditMode && (
+
+
+
+ )}
+ this.props.onMoveQuery(query, 1)}>
+
+
+ this.props.onMoveQuery(query, -1)}>
+
+
+
+
+
+
+ {isDisabled && }
+ {!isDisabled && }
+
+
+
+
+
+
+
{this.renderPluginEditor()}
+
+ );
+ }
+}
+
+export interface AngularQueryComponentScope {
+ target: DataQuery;
+ panel: PanelModel;
+ events: Emitter;
+ refresh: () => void;
+ render: () => void;
+ datasource: DataSourceApi;
+ toggleEditorMode?: () => void;
+ getCollapsedText?: () => string;
+}
diff --git a/public/app/features/dashboard/panel_editor/QueryInspector.tsx b/public/app/features/dashboard/panel_editor/QueryInspector.tsx
index 8e490f6b622..25c3c68e21e 100644
--- a/public/app/features/dashboard/panel_editor/QueryInspector.tsx
+++ b/public/app/features/dashboard/panel_editor/QueryInspector.tsx
@@ -177,7 +177,6 @@ export class QueryInspector extends PureComponent
{
render() {
const { response, isLoading } = this.state.dsQuery;
- const { isMocking } = this.state;
const openNodes = this.getNrOfOpenNodes();
if (isLoading) {
@@ -199,20 +198,7 @@ export class QueryInspector extends PureComponent {
- {!isMocking && }
- {isMocking && (
-
- )}
+
>
);
}
diff --git a/public/app/features/dashboard/panel_editor/QueryOptions.tsx b/public/app/features/dashboard/panel_editor/QueryOptions.tsx
index fad70d92990..61cdbefbce2 100644
--- a/public/app/features/dashboard/panel_editor/QueryOptions.tsx
+++ b/public/app/features/dashboard/panel_editor/QueryOptions.tsx
@@ -10,11 +10,12 @@ import { Input } from 'app/core/components/Form';
import { EventsWithValidation } from 'app/core/components/Form/Input';
import { InputStatus } from 'app/core/components/Form/Input';
import DataSourceOption from './DataSourceOption';
-import { GfFormLabel } from '@grafana/ui';
+import { FormLabel } from '@grafana/ui';
// Types
import { PanelModel } from '../panel_model';
-import { ValidationEvents, DataSourceSelectItem } from 'app/types';
+import { DataSourceSelectItem } from '@grafana/ui/src/types';
+import { ValidationEvents } from 'app/types';
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
@@ -164,7 +165,7 @@ export class QueryOptions extends PureComponent {
{this.renderOptions()}
-
Relative time
+
Relative time
{
@@ -39,6 +40,7 @@ export class VisualizationTab extends PureComponent
{
this.state = {
isVizPickerOpen: false,
searchQuery: '',
+ scrollTop: 0,
};
}
@@ -143,7 +145,7 @@ export class VisualizationTab extends PureComponent {
};
onOpenVizPicker = () => {
- this.setState({ isVizPickerOpen: true });
+ this.setState({ isVizPickerOpen: true, scrollTop: 0 });
};
onCloseVizPicker = () => {
@@ -201,9 +203,14 @@ export class VisualizationTab extends PureComponent {
renderHelp = () => ;
+ setScrollTop = (event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ this.setState({ scrollTop: target.scrollTop });
+ };
+
render() {
const { plugin } = this.props;
- const { isVizPickerOpen, searchQuery } = this.state;
+ const { isVizPickerOpen, searchQuery, scrollTop } = this.state;
const pluginHelp: EditorToolbarView = {
heading: 'Help',
@@ -212,7 +219,8 @@ export class VisualizationTab extends PureComponent {
};
return (
-
+
<>
) {
query = query || { refId: 'A' };
query.refId = this.getNextQueryLetter();
- query.isNew = true;
-
- this.targets.push(query);
+ this.targets.push(query as DataQuery);
}
getNextQueryLetter(): string {
diff --git a/public/app/features/dashboard/partials/folder_permissions.html b/public/app/features/dashboard/partials/folder_permissions.html
deleted file mode 100644
index be44c1051f2..00000000000
--- a/public/app/features/dashboard/partials/folder_permissions.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/public/app/features/dashboard/partials/folder_settings.html b/public/app/features/dashboard/partials/folder_settings.html
deleted file mode 100644
index 8e819be5fe8..00000000000
--- a/public/app/features/dashboard/partials/folder_settings.html
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
diff --git a/public/app/features/dashboard/partials/inspector.html b/public/app/features/dashboard/partials/inspector.html
deleted file mode 100644
index b30bce3c5fe..00000000000
--- a/public/app/features/dashboard/partials/inspector.html
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
-
-
Request details
-
-
-
Request parameters
-
-
-
- {{param.key}}
-
-
- {{param.value}}
-
-
-
-
-
-
-
{{message}}
-
-{{response}}
-
-
-
-
-
-
Message:
-
-{{message}}
-
-
-
Stack trace:
-
-{{stack_trace}}
-
-
-
-
-
-
-
diff --git a/public/app/features/dashboard/specs/change_tracker.test.ts b/public/app/features/dashboard/services/ChangeTracker.test.ts
similarity index 97%
rename from public/app/features/dashboard/specs/change_tracker.test.ts
rename to public/app/features/dashboard/services/ChangeTracker.test.ts
index e7f8ce977b1..dfc9b3fa03f 100644
--- a/public/app/features/dashboard/specs/change_tracker.test.ts
+++ b/public/app/features/dashboard/services/ChangeTracker.test.ts
@@ -1,4 +1,4 @@
-import { ChangeTracker } from 'app/features/dashboard/change_tracker';
+import { ChangeTracker } from './ChangeTracker';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardModel } from '../dashboard_model';
import { PanelModel } from '../panel_model';
diff --git a/public/app/features/dashboard/change_tracker.ts b/public/app/features/dashboard/services/ChangeTracker.ts
similarity index 98%
rename from public/app/features/dashboard/change_tracker.ts
rename to public/app/features/dashboard/services/ChangeTracker.ts
index aa71ac2e306..ef3d456db48 100644
--- a/public/app/features/dashboard/change_tracker.ts
+++ b/public/app/features/dashboard/services/ChangeTracker.ts
@@ -1,6 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
-import { DashboardModel } from './dashboard_model';
+import { DashboardModel } from '../dashboard_model';
export class ChangeTracker {
current: any;
diff --git a/public/app/features/dashboard/dashboard_loader_srv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts
similarity index 100%
rename from public/app/features/dashboard/dashboard_loader_srv.ts
rename to public/app/features/dashboard/services/DashboardLoaderSrv.ts
diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/services/DashboardSrv.ts
similarity index 98%
rename from public/app/features/dashboard/dashboard_srv.ts
rename to public/app/features/dashboard/services/DashboardSrv.ts
index d5695a577c5..67a4938c6aa 100644
--- a/public/app/features/dashboard/dashboard_srv.ts
+++ b/public/app/features/dashboard/services/DashboardSrv.ts
@@ -1,5 +1,5 @@
import coreModule from 'app/core/core_module';
-import { DashboardModel } from './dashboard_model';
+import { DashboardModel } from '../dashboard_model';
import locationUtil from 'app/core/utils/location_util';
export class DashboardSrv {
diff --git a/public/app/features/dashboard/specs/viewstate_srv.test.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
similarity index 82%
rename from public/app/features/dashboard/specs/viewstate_srv.test.ts
rename to public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
index f9963afbf85..aee6746ff36 100644
--- a/public/app/features/dashboard/specs/viewstate_srv.test.ts
+++ b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
@@ -1,7 +1,5 @@
-//import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
-import 'app/features/dashboard/view_state_srv';
import config from 'app/core/config';
-import { DashboardViewState } from '../view_state_srv';
+import { DashboardViewStateSrv } from './DashboardViewStateSrv';
import { DashboardModel } from '../dashboard_model';
describe('when updating view state', () => {
@@ -33,7 +31,7 @@ describe('when updating view state', () => {
location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 };
});
- viewState = new DashboardViewState($scope, location, {});
+ viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should update querystring and view state', () => {
@@ -55,7 +53,7 @@ describe('when updating view state', () => {
describe('to fullscreen false', () => {
beforeEach(() => {
- viewState = new DashboardViewState($scope, location, {});
+ viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true });
diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts
similarity index 96%
rename from public/app/features/dashboard/view_state_srv.ts
rename to public/app/features/dashboard/services/DashboardViewStateSrv.ts
index ff12d26233d..cb9794d6abb 100644
--- a/public/app/features/dashboard/view_state_srv.ts
+++ b/public/app/features/dashboard/services/DashboardViewStateSrv.ts
@@ -2,11 +2,11 @@ import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
-import { DashboardModel } from './dashboard_model';
+import { DashboardModel } from '../dashboard_model';
// represents the transient view state
// like fullscreen panel & edit
-export class DashboardViewState {
+export class DashboardViewStateSrv {
state: any;
panelScopes: any;
$scope: any;
@@ -168,7 +168,7 @@ export class DashboardViewState {
export function dashboardViewStateSrv($location, $timeout) {
return {
create: $scope => {
- return new DashboardViewState($scope, $location, $timeout);
+ return new DashboardViewStateSrv($scope, $location, $timeout);
},
};
}
diff --git a/public/app/features/dashboard/unsaved_changes_srv.ts b/public/app/features/dashboard/services/UnsavedChangesSrv.ts
similarity index 89%
rename from public/app/features/dashboard/unsaved_changes_srv.ts
rename to public/app/features/dashboard/services/UnsavedChangesSrv.ts
index f0a8bf40501..2691cc6ebf8 100644
--- a/public/app/features/dashboard/unsaved_changes_srv.ts
+++ b/public/app/features/dashboard/services/UnsavedChangesSrv.ts
@@ -1,5 +1,5 @@
import angular from 'angular';
-import { ChangeTracker } from './change_tracker';
+import { ChangeTracker } from './ChangeTracker';
/** @ngInject */
export function unsavedChangesSrv(this: any, $rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
diff --git a/public/app/features/dashboard/specs/panel_model.test.ts b/public/app/features/dashboard/specs/panel_model.test.ts
index 36bb8d6297e..89976fa275a 100644
--- a/public/app/features/dashboard/specs/panel_model.test.ts
+++ b/public/app/features/dashboard/specs/panel_model.test.ts
@@ -9,6 +9,10 @@ describe('PanelModel', () => {
model = new PanelModel({
type: 'table',
showColumns: true,
+ targets: [
+ {refId: 'A'},
+ {noRefId: true}
+ ]
});
});
@@ -20,6 +24,10 @@ describe('PanelModel', () => {
expect(model.showColumns).toBe(true);
});
+ it('should add missing refIds', () => {
+ expect(model.targets[1].refId).toBe('B');
+ });
+
it('getSaveModel should remove defaults', () => {
const saveModel = model.getSaveModel();
expect(saveModel.gridPos).toBe(undefined);
diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts
index 00c960bdfaa..cfbe094125f 100644
--- a/public/app/features/dashboard/utils/panel.ts
+++ b/public/app/features/dashboard/utils/panel.ts
@@ -80,7 +80,7 @@ export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => {
export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => {
appEvents.emit('show-modal', {
- src: 'public/app/features/dashboard/partials/shareModal.html',
+ src: 'public/app/features/dashboard/components/ShareModal/template.html',
model: {
dashboard: dashboard,
panel: panel,
diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/DashboardsTable.tsx
index b732782c23b..077dc1dba63 100644
--- a/public/app/features/datasources/DashboardsTable.tsx
+++ b/public/app/features/datasources/DashboardsTable.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { PluginDashboard } from '../../types';
export interface Props {
@@ -7,7 +7,7 @@ export interface Props {
onRemove: (dashboard) => void;
}
-const DashboardsTable: SFC = ({ dashboards, onImport, onRemove }) => {
+const DashboardsTable: FC = ({ dashboards, onImport, onRemove }) => {
function buttonText(dashboard: PluginDashboard) {
return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
}
diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx
index 7409572b9cd..1cc4933519e 100644
--- a/public/app/features/datasources/DataSourceDashboards.test.tsx
+++ b/public/app/features/datasources/DataSourceDashboards.test.tsx
@@ -1,13 +1,14 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataSourceDashboards, Props } from './DataSourceDashboards';
-import { DataSource, NavModel, PluginDashboard } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
+import { NavModel, PluginDashboard } from 'app/types';
const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {} as NavModel,
dashboards: [] as PluginDashboard[],
- dataSource: {} as DataSource,
+ dataSource: {} as DataSourceSettings,
pageId: 1,
importDashboard: jest.fn(),
loadDataSource: jest.fn(),
diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx
index 51f9c9ca945..327908af44a 100644
--- a/public/app/features/datasources/DataSourceDashboards.tsx
+++ b/public/app/features/datasources/DataSourceDashboards.tsx
@@ -1,9 +1,13 @@
+// Libraries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
+
+// Components
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import DashboardTable from './DashboardsTable';
-import { DataSource, NavModel, PluginDashboard } from 'app/types';
+
+// Actions & Selectors
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
import { loadDataSource } from './state/actions';
@@ -11,10 +15,14 @@ import { loadPluginDashboards } from '../plugins/state/actions';
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
import { getDataSource } from './state/selectors';
+// Types
+import { NavModel, PluginDashboard } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
+
export interface Props {
navModel: NavModel;
dashboards: PluginDashboard[];
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
pageId: number;
importDashboard: typeof importDashboard;
loadDataSource: typeof loadDataSource;
diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx
index 0895b92461b..0da12263aed 100644
--- a/public/app/features/datasources/DataSourcesList.tsx
+++ b/public/app/features/datasources/DataSourcesList.tsx
@@ -1,11 +1,16 @@
+// Libraries
import React, { PureComponent } from 'react';
import classNames from 'classnames';
+
+// Components
import DataSourcesListItem from './DataSourcesListItem';
-import { DataSource } from 'app/types';
+
+// Types
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
export interface Props {
- dataSources: DataSource[];
+ dataSources: DataSourceSettings[];
layoutMode: LayoutMode;
}
diff --git a/public/app/features/datasources/DataSourcesListItem.tsx b/public/app/features/datasources/DataSourcesListItem.tsx
index a4fedb893fb..157e9447852 100644
--- a/public/app/features/datasources/DataSourcesListItem.tsx
+++ b/public/app/features/datasources/DataSourcesListItem.tsx
@@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
-import { DataSource } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
export interface Props {
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
}
export class DataSourcesListItem extends PureComponent {
diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx
index 0ea716d62c9..44ef7a1cc49 100644
--- a/public/app/features/datasources/DataSourcesListPage.test.tsx
+++ b/public/app/features/datasources/DataSourcesListPage.test.tsx
@@ -1,16 +1,24 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataSourcesListPage, Props } from './DataSourcesListPage';
-import { DataSource, NavModel } from 'app/types';
+import { NavModel } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
- dataSources: [] as DataSource[],
+ dataSources: [] as DataSourceSettings[],
layoutMode: LayoutModes.Grid,
loadDataSources: jest.fn(),
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Data Sources'
+ }
+ } as NavModel,
dataSourcesCount: 0,
searchQuery: '',
setDataSourcesSearchQuery: jest.fn(),
diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx
index 6a292d63e53..b0b2ee56169 100644
--- a/public/app/features/datasources/DataSourcesListPage.tsx
+++ b/public/app/features/datasources/DataSourcesListPage.tsx
@@ -1,15 +1,23 @@
+// Libraries
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
-import PageHeader from '../../core/components/PageHeader/PageHeader';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
-import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
-import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
+
+// Components
+import Page from 'app/core/components/Page/Page';
+import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
+import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import DataSourcesList from './DataSourcesList';
-import { DataSource, NavModel } from 'app/types';
-import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+
+// Types
+import { DataSourceSettings } from '@grafana/ui/src/types';
+import { NavModel, StoreState } from 'app/types';
+import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
+
+// Actions
import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
-import { getNavModel } from '../../core/selectors/navModel';
+import { getNavModel } from 'app/core/selectors/navModel';
+
import {
getDataSources,
getDataSourcesCount,
@@ -19,7 +27,7 @@ import {
export interface Props {
navModel: NavModel;
- dataSources: DataSource[];
+ dataSources: DataSourceSettings[];
dataSourcesCount: number;
layoutMode: LayoutMode;
searchQuery: string;
@@ -67,30 +75,30 @@ export class DataSourcesListPage extends PureComponent {
};
return (
-
-
-
- {!hasFetched &&
}
- {hasFetched && dataSourcesCount === 0 &&
}
- {hasFetched &&
- dataSourcesCount > 0 && [
-
setDataSourcesLayoutMode(mode)}
- setSearchQuery={query => setDataSourcesSearchQuery(query)}
- linkButton={linkButton}
- key="action-bar"
- />,
- ,
- ]}
-
-
+
+
+ <>
+ {hasFetched && dataSourcesCount === 0 && }
+ {hasFetched &&
+ dataSourcesCount > 0 && [
+ setDataSourcesLayoutMode(mode)}
+ setSearchQuery={query => setDataSourcesSearchQuery(query)}
+ linkButton={linkButton}
+ key="action-bar"
+ />,
+ ,
+ ]}
+ >
+
+
);
}
}
-function mapStateToProps(state) {
+function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'datasources'),
dataSources: getDataSources(state.dataSources),
diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
index 755d8eef74a..6658464ef7f 100644
--- a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
+++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
@@ -1,6 +1,6 @@
-import { DataSource } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
-export const getMockDataSources = (amount: number): DataSource[] => {
+export const getMockDataSources = (amount: number): DataSourceSettings[] => {
const dataSources = [];
for (let i = 0; i <= amount; i++) {
@@ -25,7 +25,7 @@ export const getMockDataSources = (amount: number): DataSource[] => {
return dataSources;
};
-export const getMockDataSource = (): DataSource => {
+export const getMockDataSource = (): DataSourceSettings => {
return {
access: '',
basicAuth: false,
diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
index c26ac50fed8..63998d43870 100644
--- a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
+++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
@@ -1,12 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render action bar and datasources 1`] = `
-
+
+
`;
exports[`Render should render component 1`] = `
-
+
`;
diff --git a/public/app/features/datasources/settings/BasicSettings.tsx b/public/app/features/datasources/settings/BasicSettings.tsx
index 120e002ac68..77c4f3d13c6 100644
--- a/public/app/features/datasources/settings/BasicSettings.tsx
+++ b/public/app/features/datasources/settings/BasicSettings.tsx
@@ -1,5 +1,5 @@
-import React, { SFC } from 'react';
-import { Label } from 'app/core/components/Label/Label';
+import React, { FC } from 'react';
+import { FormLabel } from '@grafana/ui';
import { Switch } from '../../../core/components/Switch/Switch';
export interface Props {
@@ -9,19 +9,19 @@ export interface Props {
onDefaultChange: (value: boolean) => void;
}
-const BasicSettings: SFC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
+const BasicSettings: FC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
return (
-
Name
-
+
void;
}
-const ButtonRow: SFC
= ({ isReadOnly, onDelete, onSubmit }) => {
+const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit }) => {
return (
onSubmit(event)}>
diff --git a/public/app/features/datasources/settings/DataSourceSettings.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
similarity index 84%
rename from public/app/features/datasources/settings/DataSourceSettings.test.tsx
rename to public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
index d6c934aa6a4..8efc92be5be 100644
--- a/public/app/features/datasources/settings/DataSourceSettings.test.tsx
+++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
@@ -1,7 +1,8 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { DataSourceSettings, Props } from './DataSourceSettings';
-import { DataSource, NavModel } from '../../../types';
+import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage';
+import { NavModel } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui';
import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
@@ -20,7 +21,7 @@ const setup = (propOverrides?: object) => {
Object.assign(props, propOverrides);
- return shallow( );
+ return shallow( );
};
describe('Render', () => {
@@ -32,7 +33,7 @@ describe('Render', () => {
it('should render loader', () => {
const wrapper = setup({
- dataSource: {} as DataSource,
+ dataSource: {} as DataSourceSettings,
});
expect(wrapper).toMatchSnapshot();
diff --git a/public/app/features/datasources/settings/DataSourceSettings.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx
similarity index 94%
rename from public/app/features/datasources/settings/DataSourceSettings.tsx
rename to public/app/features/datasources/settings/DataSourceSettingsPage.tsx
index 5786bd1db57..87efed266b0 100644
--- a/public/app/features/datasources/settings/DataSourceSettings.tsx
+++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx
@@ -1,28 +1,34 @@
+// Libraries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
+// Components
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import PluginSettings from './PluginSettings';
import BasicSettings from './BasicSettings';
import ButtonRow from './ButtonRow';
+// Services & Utils
import appEvents from 'app/core/app_events';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+// Actions & selectors
import { getDataSource, getDataSourceMeta } from '../state/selectors';
import { deleteDataSource, loadDataSource, setDataSourceName, setIsDefault, updateDataSource } from '../state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
-import { DataSource, NavModel, Plugin } from 'app/types/';
+// Types
+import { NavModel, Plugin } from 'app/types/';
+import { DataSourceSettings } from '@grafana/ui/src/types/';
import { getDataSourceLoadingNav } from '../state/navModel';
export interface Props {
navModel: NavModel;
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
dataSourceMeta: Plugin;
pageId: number;
deleteDataSource: typeof deleteDataSource;
@@ -33,7 +39,7 @@ export interface Props {
}
interface State {
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
isTesting?: boolean;
testingMessage?: string;
testingStatus?: string;
@@ -44,12 +50,12 @@ enum DataSourceStates {
Beta = 'beta',
}
-export class DataSourceSettings extends PureComponent {
+export class DataSourceSettingsPage extends PureComponent {
constructor(props) {
super(props);
this.state = {
- dataSource: {} as DataSource,
+ dataSource: {} as DataSourceSettings,
};
}
@@ -246,4 +252,4 @@ const mapDispatchToProps = {
setIsDefault,
};
-export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceSettings));
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceSettingsPage));
diff --git a/public/app/features/datasources/settings/PluginSettings.tsx b/public/app/features/datasources/settings/PluginSettings.tsx
index e0b742985cc..8b65accd50a 100644
--- a/public/app/features/datasources/settings/PluginSettings.tsx
+++ b/public/app/features/datasources/settings/PluginSettings.tsx
@@ -1,20 +1,21 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
-import { DataSource, Plugin } from 'app/types/';
+import { Plugin } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
export interface Props {
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
dataSourceMeta: Plugin;
- onModelChange: (dataSource: DataSource) => void;
+ onModelChange: (dataSource: DataSourceSettings) => void;
}
export class PluginSettings extends PureComponent {
element: any;
component: AngularComponent;
scopeProps: {
- ctrl: { datasourceMeta: Plugin; current: DataSource };
- onModelChanged: (dataSource: DataSource) => void;
+ ctrl: { datasourceMeta: Plugin; current: DataSourceSettings };
+ onModelChanged: (dataSource: DataSourceSettings) => void;
};
constructor(props) {
@@ -51,7 +52,7 @@ export class PluginSettings extends PureComponent {
}
}
- onModelChanged = (dataSource: DataSource) => {
+ onModelChanged = (dataSource: DataSourceSettings) => {
this.props.onModelChange(dataSource);
};
diff --git a/public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap
similarity index 100%
rename from public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap
rename to public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap
diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts
index fa9d47eb6a1..008dc9fe816 100644
--- a/public/app/features/datasources/state/actions.ts
+++ b/public/app/features/datasources/state/actions.ts
@@ -6,7 +6,8 @@ import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
import { updateLocation, updateNavIndex, UpdateNavIndexAction } from 'app/core/actions';
import { UpdateLocationAction } from 'app/core/actions/location';
import { buildNavModel } from './navModel';
-import { DataSource, Plugin, StoreState } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
+import { Plugin, StoreState } from 'app/types';
export enum ActionTypes {
LoadDataSources = 'LOAD_DATA_SOURCES',
@@ -22,7 +23,7 @@ export enum ActionTypes {
interface LoadDataSourcesAction {
type: ActionTypes.LoadDataSources;
- payload: DataSource[];
+ payload: DataSourceSettings[];
}
interface SetDataSourcesSearchQueryAction {
@@ -47,7 +48,7 @@ interface SetDataSourceTypeSearchQueryAction {
interface LoadDataSourceAction {
type: ActionTypes.LoadDataSource;
- payload: DataSource;
+ payload: DataSourceSettings;
}
interface LoadDataSourceMetaAction {
@@ -65,12 +66,12 @@ interface SetIsDefaultAction {
payload: boolean;
}
-const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
+const dataSourcesLoaded = (dataSources: DataSourceSettings[]): LoadDataSourcesAction => ({
type: ActionTypes.LoadDataSources,
payload: dataSources,
});
-const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({
+const dataSourceLoaded = (dataSource: DataSourceSettings): LoadDataSourceAction => ({
type: ActionTypes.LoadDataSource,
payload: dataSource,
});
@@ -171,7 +172,7 @@ export function loadDataSourceTypes(): ThunkResult {
};
}
-export function updateDataSource(dataSource: DataSource): ThunkResult {
+export function updateDataSource(dataSource: DataSourceSettings): ThunkResult {
return async dispatch => {
await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource);
await updateFrontendSettings();
diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts
index 19fc422e3b8..b0b121a9997 100644
--- a/public/app/features/datasources/state/navModel.ts
+++ b/public/app/features/datasources/state/navModel.ts
@@ -1,7 +1,8 @@
-import { DataSource, NavModel, NavModelItem, PluginMeta } from 'app/types';
+import { NavModel, NavModelItem } from 'app/types';
+import { PluginMeta, DataSourceSettings } from '@grafana/ui/src/types';
import config from 'app/core/config';
-export function buildNavModel(dataSource: DataSource, pluginMeta: PluginMeta): NavModelItem {
+export function buildNavModel(dataSource: DataSourceSettings, pluginMeta: PluginMeta): NavModelItem {
const navModel = {
img: pluginMeta.info.logos.large,
id: 'datasource-' + dataSource.id,
diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts
index 6e86c304fa7..7be93f5a644 100644
--- a/public/app/features/datasources/state/reducers.ts
+++ b/public/app/features/datasources/state/reducers.ts
@@ -1,10 +1,11 @@
-import { DataSource, DataSourcesState, Plugin } from 'app/types';
+import { DataSourcesState, Plugin } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { Action, ActionTypes } from './actions';
-import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
+import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
const initialState: DataSourcesState = {
- dataSources: [] as DataSource[],
- dataSource: {} as DataSource,
+ dataSources: [] as DataSourceSettings[],
+ dataSource: {} as DataSourceSettings,
layoutMode: LayoutModes.List,
searchQuery: '',
dataSourcesCount: 0,
diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts
index 2466e465d1d..bb95d95e59a 100644
--- a/public/app/features/datasources/state/selectors.ts
+++ b/public/app/features/datasources/state/selectors.ts
@@ -1,4 +1,4 @@
-import { DataSource } from '../../../types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
export const getDataSources = state => {
const regex = new RegExp(state.searchQuery, 'i');
@@ -16,11 +16,11 @@ export const getDataSourceTypes = state => {
});
};
-export const getDataSource = (state, dataSourceId): DataSource | null => {
+export const getDataSource = (state, dataSourceId): DataSourceSettings | null => {
if (state.dataSource.id === parseInt(dataSourceId, 10)) {
return state.dataSource;
}
- return {} as DataSource;
+ return {} as DataSourceSettings;
};
export const getDataSourceMeta = (state, type): Plugin => {
diff --git a/public/app/features/explore/Error.tsx b/public/app/features/explore/Error.tsx
index 2bfd366b3c9..0c4e0e7ea85 100644
--- a/public/app/features/explore/Error.tsx
+++ b/public/app/features/explore/Error.tsx
@@ -1,10 +1,10 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
interface Props {
message: any;
}
-export const Alert: SFC = props => {
+export const Alert: FC = props => {
const { message } = props;
return (
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index d4d645950c1..b6f57a76004 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -1,55 +1,80 @@
+// Libraries
import React from 'react';
import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
import _ from 'lodash';
+import { AutoSizer } from 'react-virtualized';
-import { DataSource } from 'app/types/datasources';
-import {
- ExploreState,
- ExploreUrlState,
- QueryTransaction,
- ResultType,
- QueryHintGetter,
- QueryHint,
-} from 'app/types/explore';
-import { TimeRange } from '@grafana/ui';
-import { DataQuery } from 'app/types/series';
+// Services & Utils
import store from 'app/core/store';
-import {
- DEFAULT_RANGE,
- calculateResultsFromQueryTransactions,
- ensureQueries,
- getIntervals,
- generateKey,
- generateQueryKeys,
- hasNonEmptyQuery,
- makeTimeSeriesList,
- updateHistory,
-} from 'app/core/utils/explore';
-import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
-import TableModel from 'app/core/table_model';
-import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
-import { Emitter } from 'app/core/utils/emitter';
-import * as dateMath from 'app/core/utils/datemath';
-import Panel from './Panel';
-import QueryRows from './QueryRows';
-import Graph from './Graph';
-import Logs from './Logs';
-import Table from './Table';
-import ErrorBoundary from './ErrorBoundary';
+// Components
+import { DataSourceSelectItem } from '@grafana/ui/src/types';
+import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { Alert } from './Error';
+import ErrorBoundary from './ErrorBoundary';
+import GraphContainer from './GraphContainer';
+import LogsContainer from './LogsContainer';
+import QueryRows from './QueryRows';
+import TableContainer from './TableContainer';
import TimePicker, { parseTime } from './TimePicker';
-const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
+// Actions
+import {
+ changeDatasource,
+ changeSize,
+ changeTime,
+ clearQueries,
+ initializeExplore,
+ modifyQueries,
+ runQueries,
+ scanStart,
+ scanStop,
+ setQueries,
+ splitClose,
+ splitOpen,
+} from './state/actions';
+
+// Types
+import { RawTimeRange, TimeRange, DataQuery } from '@grafana/ui';
+import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore';
+import { StoreState } from 'app/types';
+import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore';
+import { Emitter } from 'app/core/utils/emitter';
interface ExploreProps {
- datasourceSrv: DatasourceSrv;
- onChangeSplit: (split: boolean, state?: ExploreState) => void;
- onSaveState: (key: string, state: ExploreState) => void;
- position: string;
+ StartPage?: any;
+ changeDatasource: typeof changeDatasource;
+ changeSize: typeof changeSize;
+ changeTime: typeof changeTime;
+ clearQueries: typeof clearQueries;
+ datasourceError: string;
+ datasourceInstance: any;
+ datasourceLoading: boolean | null;
+ datasourceMissing: boolean;
+ exploreDatasources: DataSourceSelectItem[];
+ exploreId: ExploreId;
+ initialDatasource?: string;
+ initialQueries: DataQuery[];
+ initializeExplore: typeof initializeExplore;
+ initialized: boolean;
+ loading: boolean;
+ modifyQueries: typeof modifyQueries;
+ range: RawTimeRange;
+ runQueries: typeof runQueries;
+ scanner?: RangeScanner;
+ scanning?: boolean;
+ scanRange?: RawTimeRange;
+ scanStart: typeof scanStart;
+ scanStop: typeof scanStop;
+ setQueries: typeof setQueries;
split: boolean;
- splitState?: ExploreState;
- stateKey: string;
+ splitClose: typeof splitClose;
+ splitOpen: typeof splitOpen;
+ showingStartPage?: boolean;
+ supportsGraph: boolean | null;
+ supportsLogs: boolean | null;
+ supportsTable: boolean | null;
urlState: ExploreUrlState;
}
@@ -58,26 +83,14 @@ interface ExploreProps {
* Once a datasource is selected it populates the query section at the top.
* When queries are run, their results are being displayed in the main section.
* The datasource determines what kind of query editor it brings, and what kind
- * of results viewers it supports.
+ * of results viewers it supports. The state is managed entirely in Redux.
*
- * QUERY HANDLING
+ * SPLIT VIEW
*
- * TLDR: to not re-render Explore during edits, query editing is not "controlled"
- * in a React sense: values need to be pushed down via `initialQueries`, while
- * edits travel up via `this.modifiedQueries`.
- *
- * By default the query rows start without prior state: `initialQueries` will
- * contain one empty DataQuery. While the user modifies the DataQuery, the
- * modifications are being tracked in `this.modifiedQueries`, which need to be
- * used whenever a query is sent to the datasource to reflect what the user sees
- * on the screen. Query"react-popper": "^0.7.5", rows can be initialized or reset using `initialQueries`,
- * by giving the respec"react-popper": "^0.7.5",tive row a new key. This wipes the old row and its state.
- * This property is als"react-popper": "^0.7.5",o used to govern how many query rows there are (minimum 1).
- *
- * This flow makes sure that a query row can be arbitrarily complex without the
- * fear of being wiped or re-initialized via props. The query row is free to keep
- * its own state while the user edits or builds a query. Valid queries can be sent
- * up to Explore via the `onChangeQuery` prop.
+ * Explore can have two Explore areas side-by-side. This is handled in `Wrapper.tsx`.
+ * Since there can be multiple Explores (e.g., left and right) each action needs
+ * the `exploreId` as first parameter so that the reducer knows which Explore state
+ * is affected.
*
* DATASOURCE REQUESTS
*
@@ -89,23 +102,9 @@ interface ExploreProps {
* The result viewers determine some of the query options sent to the datasource, e.g.,
* `format`, to indicate eventual transformations by the datasources' result transformers.
*/
-export class Explore extends React.PureComponent
{
+export class Explore extends React.PureComponent {
el: any;
exploreEvents: Emitter;
- /**
- * Set via URL or local storage
- */
- initialDatasource: string;
- /**
- * Current query expressions of the rows including their modifications, used for running queries.
- * Not kept in component state to prevent edit-render roundtrips.
- */
- modifiedQueries: DataQuery[];
- /**
- * Local ID cache to compare requested vs selected datasource
- */
- requestedDatasourceId: string;
- scanTimer: NodeJS.Timer;
/**
* Timepicker to control scanning
*/
@@ -113,359 +112,65 @@ export class Explore extends React.PureComponent {
constructor(props) {
super(props);
- const splitState: ExploreState = props.splitState;
- let initialQueries: DataQuery[];
- if (splitState) {
- // Split state overrides everything
- this.state = splitState;
- initialQueries = splitState.initialQueries;
- } else {
- const { datasource, queries, range } = props.urlState as ExploreUrlState;
- const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
- initialQueries = ensureQueries(queries);
- const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE };
- // Millies step for helper bar charts
- const initialGraphInterval = 15 * 1000;
- this.state = {
- datasource: null,
- datasourceError: null,
- datasourceLoading: null,
- datasourceMissing: false,
- exploreDatasources: [],
- graphInterval: initialGraphInterval,
- graphResult: [],
- initialDatasource,
- initialQueries,
- history: [],
- logsResult: null,
- queryTransactions: [],
- range: initialRange,
- scanning: false,
- showingGraph: true,
- showingLogs: true,
- showingStartPage: false,
- showingTable: true,
- supportsGraph: null,
- supportsLogs: null,
- supportsTable: null,
- tableResult: new TableModel(),
- };
- }
- this.modifiedQueries = initialQueries.slice();
this.exploreEvents = new Emitter();
this.timepickerRef = React.createRef();
}
async componentDidMount() {
- const { datasourceSrv } = this.props;
- const { initialDatasource } = this.state;
- if (!datasourceSrv) {
- throw new Error('No datasource service passed as props.');
- }
-
- const datasources = datasourceSrv.getExternal();
- const exploreDatasources = datasources.map(ds => ({
- value: ds.name,
- name: ds.name,
- meta: ds.meta,
- }));
-
- if (datasources.length > 0) {
- this.setState({ datasourceLoading: true, exploreDatasources });
- // Priority for datasource preselection: URL, localstorage, default datasource
- let datasource;
- if (initialDatasource) {
- datasource = await datasourceSrv.get(initialDatasource);
- } else {
- datasource = await datasourceSrv.get();
- }
- await this.setDatasource(datasource);
- } else {
- this.setState({ datasourceMissing: true });
+ const { exploreId, initialized, urlState } = this.props;
+ // Don't initialize on split, but need to initialize urlparameters when present
+ if (!initialized) {
+ // Load URL state and parse range
+ const { datasource, queries, range = DEFAULT_RANGE } = (urlState || {}) as ExploreUrlState;
+ const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
+ const initialQueries: DataQuery[] = ensureQueries(queries);
+ const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
+ const width = this.el ? this.el.offsetWidth : 0;
+ this.props.initializeExplore(
+ exploreId,
+ initialDatasource,
+ initialQueries,
+ initialRange,
+ width,
+ this.exploreEvents
+ );
}
}
componentWillUnmount() {
this.exploreEvents.removeAllListeners();
- clearTimeout(this.scanTimer);
- }
-
- async setDatasource(datasource: any, origin?: DataSource) {
- const { initialQueries, range } = this.state;
-
- const supportsGraph = datasource.meta.metrics;
- const supportsLogs = datasource.meta.logs;
- const supportsTable = datasource.meta.tables;
- const datasourceId = datasource.meta.id;
- let datasourceError = null;
-
- // Keep ID to track selection
- this.requestedDatasourceId = datasourceId;
-
- try {
- const testResult = await datasource.testDatasource();
- datasourceError = testResult.status === 'success' ? null : testResult.message;
- } catch (error) {
- datasourceError = (error && error.statusText) || 'Network error';
- }
-
- if (datasourceId !== this.requestedDatasourceId) {
- // User already changed datasource again, discard results
- return;
- }
-
- const historyKey = `grafana.explore.history.${datasourceId}`;
- const history = store.getObject(historyKey, []);
-
- if (datasource.init) {
- datasource.init();
- }
-
- // Check if queries can be imported from previously selected datasource
- let modifiedQueries = this.modifiedQueries;
- if (origin) {
- if (origin.meta.id === datasource.meta.id) {
- // Keep same queries if same type of datasource
- modifiedQueries = [...this.modifiedQueries];
- } else if (datasource.importQueries) {
- // Datasource-specific importers
- modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta);
- } else {
- // Default is blank queries
- modifiedQueries = ensureQueries();
- }
- }
-
- // Reset edit state with new queries
- const nextQueries = initialQueries.map((q, i) => ({
- ...modifiedQueries[i],
- ...generateQueryKeys(i),
- }));
- this.modifiedQueries = modifiedQueries;
-
- // Custom components
- const StartPage = datasource.pluginExports.ExploreStartPage;
-
- // Calculate graph bucketing interval
- const graphInterval = getIntervals(range, datasource, this.el ? this.el.offsetWidth : 0).intervalMs;
-
- this.setState(
- {
- StartPage,
- datasource,
- datasourceError,
- graphInterval,
- history,
- supportsGraph,
- supportsLogs,
- supportsTable,
- datasourceLoading: false,
- initialDatasource: datasource.name,
- initialQueries: nextQueries,
- logsHighlighterExpressions: undefined,
- showingStartPage: Boolean(StartPage),
- },
- () => {
- if (datasourceError === null) {
- // Save last-used datasource
- store.set(LAST_USED_DATASOURCE_KEY, datasource.name);
- this.onSubmit();
- }
- }
- );
}
getRef = el => {
this.el = el;
};
- onAddQueryRow = index => {
- // Local cache
- this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) };
-
- this.setState(state => {
- const { initialQueries, queryTransactions } = state;
-
- const nextQueries = [
- ...initialQueries.slice(0, index + 1),
- { ...this.modifiedQueries[index + 1] },
- ...initialQueries.slice(index + 1),
- ];
-
- // Ongoing transactions need to update their row indices
- const nextQueryTransactions = queryTransactions.map(qt => {
- if (qt.rowIndex > index) {
- return {
- ...qt,
- rowIndex: qt.rowIndex + 1,
- };
- }
- return qt;
- });
-
- return {
- initialQueries: nextQueries,
- logsHighlighterExpressions: undefined,
- queryTransactions: nextQueryTransactions,
- };
- });
- };
-
onChangeDatasource = async option => {
- const origin = this.state.datasource;
- this.setState({
- datasource: null,
- datasourceError: null,
- datasourceLoading: true,
- queryTransactions: [],
- });
- const datasourceName = option.value;
- const datasource = await this.props.datasourceSrv.get(datasourceName);
- this.setDatasource(datasource as any, origin);
+ this.props.changeDatasource(this.props.exploreId, option.value);
};
- onChangeQuery = (value: DataQuery, index: number, override?: boolean) => {
- // Null value means reset
- if (value === null) {
- value = { ...generateQueryKeys(index) };
- }
-
- // Keep current value in local cache
- this.modifiedQueries[index] = value;
-
- if (override) {
- this.setState(state => {
- // Replace query row by injecting new key
- const { initialQueries, queryTransactions } = state;
- const query: DataQuery = {
- ...value,
- ...generateQueryKeys(index),
- };
- const nextQueries = [...initialQueries];
- nextQueries[index] = query;
- this.modifiedQueries = [...nextQueries];
-
- // Discard ongoing transaction related to row query
- const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
-
- return {
- initialQueries: nextQueries,
- queryTransactions: nextQueryTransactions,
- };
- }, this.onSubmit);
- } else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) {
- // Live preview of log search matches. Can only work on single row query for now
- this.updateLogsHighlights(value);
- }
- };
-
- onChangeTime = (nextRange: TimeRange, scanning?: boolean) => {
- const range: TimeRange = {
- ...nextRange,
- };
- if (this.state.scanning && !scanning) {
+ onChangeTime = (range: TimeRange, changedByScanner?: boolean) => {
+ if (this.props.scanning && !changedByScanner) {
this.onStopScanning();
}
- this.setState({ range, scanning }, () => this.onSubmit());
+ this.props.changeTime(this.props.exploreId, range);
};
onClickClear = () => {
- this.onStopScanning();
- this.modifiedQueries = ensureQueries();
- this.setState(
- prevState => ({
- initialQueries: [...this.modifiedQueries],
- queryTransactions: [],
- showingStartPage: Boolean(prevState.StartPage),
- }),
- this.saveState
- );
+ this.props.clearQueries(this.props.exploreId);
};
onClickCloseSplit = () => {
- const { onChangeSplit } = this.props;
- if (onChangeSplit) {
- onChangeSplit(false);
- }
- };
-
- onClickGraphButton = () => {
- this.setState(
- state => {
- const showingGraph = !state.showingGraph;
- let nextQueryTransactions = state.queryTransactions;
- if (!showingGraph) {
- // Discard transactions related to Graph query
- nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph');
- }
- return { queryTransactions: nextQueryTransactions, showingGraph };
- },
- () => {
- if (this.state.showingGraph) {
- this.onSubmit();
- }
- }
- );
- };
-
- onClickLogsButton = () => {
- this.setState(
- state => {
- const showingLogs = !state.showingLogs;
- let nextQueryTransactions = state.queryTransactions;
- if (!showingLogs) {
- // Discard transactions related to Logs query
- nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs');
- }
- return { queryTransactions: nextQueryTransactions, showingLogs };
- },
- () => {
- if (this.state.showingLogs) {
- this.onSubmit();
- }
- }
- );
+ this.props.splitClose();
};
// Use this in help pages to set page to a single query
onClickExample = (query: DataQuery) => {
- const nextQueries = [{ ...query, ...generateQueryKeys() }];
- this.modifiedQueries = [...nextQueries];
- this.setState({ initialQueries: nextQueries }, this.onSubmit);
+ this.props.setQueries(this.props.exploreId, [query]);
};
onClickSplit = () => {
- const { onChangeSplit } = this.props;
- if (onChangeSplit) {
- const state = this.cloneState();
- onChangeSplit(true, state);
- }
- };
-
- onClickTableButton = () => {
- this.setState(
- state => {
- const showingTable = !state.showingTable;
- if (showingTable) {
- return { showingTable, queryTransactions: state.queryTransactions };
- }
-
- // Toggle off needs discarding of table queries
- const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- return { ...results, queryTransactions: nextQueryTransactions, showingTable };
- },
- () => {
- if (this.state.showingTable) {
- this.onSubmit();
- }
- }
- );
+ this.props.splitOpen();
};
onClickLabel = (key: string, value: string) => {
@@ -473,438 +178,63 @@ export class Explore extends React.PureComponent {
};
onModifyQueries = (action, index?: number) => {
- const { datasource } = this.state;
- if (datasource && datasource.modifyQuery) {
- const preventSubmit = action.preventSubmit;
- this.setState(
- state => {
- const { initialQueries, queryTransactions } = state;
- let nextQueries: DataQuery[];
- let nextQueryTransactions;
- if (index === undefined) {
- // Modify all queries
- nextQueries = initialQueries.map((query, i) => ({
- ...datasource.modifyQuery(this.modifiedQueries[i], action),
- ...generateQueryKeys(i),
- }));
- // Discard all ongoing transactions
- nextQueryTransactions = [];
- } else {
- // Modify query only at index
- nextQueries = initialQueries.map((query, i) => {
- // Synchronize all queries with local query cache to ensure consistency
- // TODO still needed?
- return i === index
- ? {
- ...datasource.modifyQuery(this.modifiedQueries[i], action),
- ...generateQueryKeys(i),
- }
- : query;
- });
- nextQueryTransactions = queryTransactions
- // Consume the hint corresponding to the action
- .map(qt => {
- if (qt.hints != null && qt.rowIndex === index) {
- qt.hints = qt.hints.filter(hint => hint.fix.action !== action);
- }
- return qt;
- })
- // Preserve previous row query transaction to keep results visible if next query is incomplete
- .filter(qt => preventSubmit || qt.rowIndex !== index);
- }
- this.modifiedQueries = [...nextQueries];
- return {
- initialQueries: nextQueries,
- queryTransactions: nextQueryTransactions,
- };
- },
- // Accepting certain fixes do not result in a well-formed query which should not be submitted
- !preventSubmit ? () => this.onSubmit() : null
- );
+ const { datasourceInstance } = this.props;
+ if (datasourceInstance && datasourceInstance.modifyQuery) {
+ const modifier = (queries: DataQuery, modification: any) => datasourceInstance.modifyQuery(queries, modification);
+ this.props.modifyQueries(this.props.exploreId, action, index, modifier);
}
};
- onRemoveQueryRow = index => {
- // Remove from local cache
- this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)];
-
- this.setState(
- state => {
- const { initialQueries, queryTransactions } = state;
- if (initialQueries.length <= 1) {
- return null;
- }
- // Remove row from react state
- const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
-
- // Discard transactions related to row query
- const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- return {
- ...results,
- initialQueries: nextQueries,
- logsHighlighterExpressions: undefined,
- queryTransactions: nextQueryTransactions,
- };
- },
- () => this.onSubmit()
- );
+ onResize = (size: { height: number; width: number }) => {
+ this.props.changeSize(this.props.exploreId, size);
};
onStartScanning = () => {
- this.setState({ scanning: true }, this.scanPreviousRange);
+ // Scanner will trigger a query
+ const scanner = this.scanPreviousRange;
+ this.props.scanStart(this.props.exploreId, scanner);
};
- scanPreviousRange = () => {
- const scanRange = this.timepickerRef.current.move(-1, true);
- this.setState({ scanRange });
+ scanPreviousRange = (): RawTimeRange => {
+ // Calling move() on the timepicker will trigger this.onChangeTime()
+ return this.timepickerRef.current.move(-1, true);
};
onStopScanning = () => {
- clearTimeout(this.scanTimer);
- this.setState(state => {
- const { queryTransactions } = state;
- const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
- return { queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined };
- });
+ this.props.scanStop(this.props.exploreId);
};
onSubmit = () => {
- const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
- // Keep table queries first since they need to return quickly
- if (showingTable && supportsTable) {
- this.runQueries(
- 'Table',
- {
- format: 'table',
- instant: true,
- valueWithRefId: true,
- },
- data => data[0]
- );
- }
- if (showingGraph && supportsGraph) {
- this.runQueries(
- 'Graph',
- {
- format: 'time_series',
- instant: false,
- },
- makeTimeSeriesList
- );
- }
- if (showingLogs && supportsLogs) {
- this.runQueries('Logs', { format: 'logs' });
- }
- this.saveState();
- };
-
- buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) {
- const { datasource, range } = this.state;
- const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth);
-
- const configuredQueries = [
- {
- ...query,
- ...queryOptions,
- },
- ];
-
- // Clone range for query request
- // const queryRange: RawTimeRange = { ...range };
- // const { from, to, raw } = this.timeSrv.timeRange();
- // Most datasource is using `panelId + query.refId` for cancellation logic.
- // Using `format` here because it relates to the view panel that the request is for.
- // However, some datasources don't use `panelId + query.refId`, but only `panelId`.
- // Therefore panel id has to be unique.
- const panelId = `${queryOptions.format}-${query.key}`;
-
- return {
- interval,
- intervalMs,
- panelId,
- targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
- range: {
- from: dateMath.parse(range.from, false),
- to: dateMath.parse(range.to, true),
- raw: range,
- },
- rangeRaw: range,
- scopedVars: {
- __interval: { text: interval, value: interval },
- __interval_ms: { text: intervalMs, value: intervalMs },
- },
- };
- }
-
- startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
- const queryOptions = this.buildQueryOptions(query, options);
- const transaction: QueryTransaction = {
- query,
- resultType,
- rowIndex,
- id: generateKey(), // reusing for unique ID
- done: false,
- latency: 0,
- options: queryOptions,
- scanning: this.state.scanning,
- };
-
- // Using updater style because we might be modifying queryTransactions in quick succession
- this.setState(state => {
- const { queryTransactions } = state;
- // Discarding existing transactions of same type
- const remainingTransactions = queryTransactions.filter(
- qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
- );
-
- // Append new transaction
- const nextQueryTransactions = [...remainingTransactions, transaction];
-
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- return {
- ...results,
- queryTransactions: nextQueryTransactions,
- showingStartPage: false,
- graphInterval: queryOptions.intervalMs,
- };
- });
-
- return transaction;
- }
-
- completeQueryTransaction(
- transactionId: string,
- result: any,
- latency: number,
- queries: DataQuery[],
- datasourceId: string
- ) {
- const { datasource } = this.state;
- if (datasource.meta.id !== datasourceId) {
- // Navigated away, queries did not matter
- return;
- }
-
- this.setState(state => {
- const { history, queryTransactions } = state;
- let { scanning } = state;
-
- // Transaction might have been discarded
- const transaction = queryTransactions.find(qt => qt.id === transactionId);
- if (!transaction) {
- return null;
- }
-
- // Get query hints
- let hints: QueryHint[];
- if (datasource.getQueryHints as QueryHintGetter) {
- hints = datasource.getQueryHints(transaction.query, result);
- }
-
- // Mark transactions as complete
- const nextQueryTransactions = queryTransactions.map(qt => {
- if (qt.id === transactionId) {
- return {
- ...qt,
- hints,
- latency,
- result,
- done: true,
- };
- }
- return qt;
- });
-
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- const nextHistory = updateHistory(history, datasourceId, queries);
-
- // Keep scanning for results if this was the last scanning transaction
- if (scanning) {
- if (_.size(result) === 0) {
- const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
- if (!other) {
- this.scanTimer = setTimeout(this.scanPreviousRange, 1000);
- }
- } else {
- // We can stop scanning if we have a result
- scanning = false;
- }
- }
-
- return {
- ...results,
- scanning,
- history: nextHistory,
- queryTransactions: nextQueryTransactions,
- };
- });
- }
-
- failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
- const { datasource } = this.state;
- if (datasource.meta.id !== datasourceId || response.cancelled) {
- // Navigated away, queries did not matter
- return;
- }
-
- console.error(response);
-
- let error: string | JSX.Element;
- if (response.data) {
- if (typeof response.data === 'string') {
- error = response.data;
- } else if (response.data.error) {
- error = response.data.error;
- if (response.data.response) {
- error = (
- <>
- {response.data.error}
- {response.data.response}
- >
- );
- }
- } else {
- throw new Error('Could not handle error response');
- }
- } else if (response.message) {
- error = response.message;
- } else if (typeof response === 'string') {
- error = response;
- } else {
- error = 'Unknown error during query transaction. Please check JS console logs.';
- }
-
- this.setState(state => {
- // Transaction might have been discarded
- if (!state.queryTransactions.find(qt => qt.id === transactionId)) {
- return null;
- }
-
- // Mark transactions as complete
- const nextQueryTransactions = state.queryTransactions.map(qt => {
- if (qt.id === transactionId) {
- return {
- ...qt,
- error,
- done: true,
- };
- }
- return qt;
- });
-
- return {
- queryTransactions: nextQueryTransactions,
- };
- });
- }
-
- async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) {
- const queries = [...this.modifiedQueries];
- if (!hasNonEmptyQuery(queries)) {
- this.setState({
- queryTransactions: [],
- });
- return;
- }
- const { datasource } = this.state;
- const datasourceId = datasource.meta.id;
- // Run all queries concurrentlyso
- queries.forEach(async (query, rowIndex) => {
- const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions);
- try {
- const now = Date.now();
- const res = await datasource.query(transaction.options);
- this.exploreEvents.emit('data-received', res.data || []);
- const latency = Date.now() - now;
- const results = resultGetter ? resultGetter(res.data) : res.data;
- this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
- } catch (response) {
- this.exploreEvents.emit('data-error', response);
- this.failQueryTransaction(transaction.id, response, datasourceId);
- }
- });
- }
-
- updateLogsHighlights = _.debounce((value: DataQuery, index: number) => {
- this.setState(state => {
- const { datasource } = state;
- if (datasource.getHighlighterExpression) {
- const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)];
- return { logsHighlighterExpressions };
- }
- return null;
- });
- }, 500);
-
- cloneState(): ExploreState {
- // Copy state, but copy queries including modifications
- return {
- ...this.state,
- queryTransactions: [],
- initialQueries: [...this.modifiedQueries],
- };
- }
-
- saveState = () => {
- const { stateKey, onSaveState } = this.props;
- onSaveState(stateKey, this.cloneState());
+ this.props.runQueries(this.props.exploreId);
};
render() {
- const { position, split } = this.props;
const {
StartPage,
- datasource,
+ datasourceInstance,
datasourceError,
datasourceLoading,
datasourceMissing,
exploreDatasources,
- graphResult,
- history,
+ exploreId,
+ loading,
initialQueries,
- logsHighlighterExpressions,
- logsResult,
- queryTransactions,
range,
- scanning,
- scanRange,
- showingGraph,
- showingLogs,
showingStartPage,
- showingTable,
+ split,
supportsGraph,
supportsLogs,
supportsTable,
- tableResult,
- } = this.state;
- const graphHeight = showingGraph && showingTable ? '200px' : '400px';
+ } = this.props;
const exploreClass = split ? 'explore explore-split' : 'explore';
- const selectedDatasource = datasource ? exploreDatasources.find(d => d.name === datasource.name) : undefined;
- const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
- const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
- const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
- const loading = queryTransactions.some(qt => !qt.done);
+ const selectedDatasource = datasourceInstance
+ ? exploreDatasources.find(d => d.name === datasourceInstance.name)
+ : undefined;
return (
- {position === 'left' ? (
+ {exploreId === 'left' ? (
) : (
-
-
- Close Split
-
-
+ <>
+
+
+
+ Close Split
+
+
+ >
)}
{!datasourceMissing ? (
@@ -928,7 +261,7 @@ export class Explore extends React.PureComponent {
) : null}
- {position === 'left' && !split ? (
+ {exploreId === 'left' && !split ? (
Split
@@ -944,7 +277,11 @@ export class Explore extends React.PureComponent {
Run Query{' '}
- {loading ? : }
+ {loading ? (
+
+ ) : (
+
+ )}
@@ -959,80 +296,97 @@ export class Explore extends React.PureComponent
{
)}
- {datasource && !datasourceError ? (
-
-
-
-
- {showingStartPage && }
- {!showingStartPage && (
- <>
- {supportsGraph && (
-
-
-
- )}
- {supportsTable && (
-
-
-
- )}
- {supportsLogs && (
-
-
-
- )}
- >
+ {datasourceInstance &&
+ !datasourceError && (
+
+
+
+ {({ width }) => (
+
+
+ {showingStartPage && }
+ {!showingStartPage && (
+ <>
+ {supportsGraph && }
+ {supportsTable && }
+ {supportsLogs && (
+
+ )}
+ >
+ )}
+
+
)}
-
-
-
- ) : null}
+
+
+ )}
);
}
}
-export default hot(module)(Explore);
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const { split } = explore;
+ const item: ExploreItemState = explore[exploreId];
+ const {
+ StartPage,
+ datasourceError,
+ datasourceInstance,
+ datasourceLoading,
+ datasourceMissing,
+ exploreDatasources,
+ initialDatasource,
+ initialQueries,
+ initialized,
+ queryTransactions,
+ range,
+ showingStartPage,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ } = item;
+ const loading = queryTransactions.some(qt => !qt.done);
+ return {
+ StartPage,
+ datasourceError,
+ datasourceInstance,
+ datasourceLoading,
+ datasourceMissing,
+ exploreDatasources,
+ initialDatasource,
+ initialQueries,
+ initialized,
+ loading,
+ queryTransactions,
+ range,
+ showingStartPage,
+ split,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ };
+}
+
+const mapDispatchToProps = {
+ changeDatasource,
+ changeSize,
+ changeTime,
+ clearQueries,
+ initializeExplore,
+ modifyQueries,
+ runQueries,
+ scanStart,
+ scanStop,
+ setQueries,
+ splitClose,
+ splitOpen,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Explore));
diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx
new file mode 100644
index 00000000000..e2610bcc781
--- /dev/null
+++ b/public/app/features/explore/GraphContainer.tsx
@@ -0,0 +1,61 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleGraph } from './state/actions';
+import Graph from './Graph';
+import Panel from './Panel';
+
+interface GraphContainerProps {
+ onChangeTime: (range: TimeRange) => void;
+ exploreId: ExploreId;
+ graphResult?: any[];
+ loading: boolean;
+ range: RawTimeRange;
+ showingGraph: boolean;
+ showingTable: boolean;
+ split: boolean;
+ toggleGraph: typeof toggleGraph;
+}
+
+export class GraphContainer extends PureComponent {
+ onClickGraphButton = () => {
+ this.props.toggleGraph(this.props.exploreId);
+ };
+
+ render() {
+ const { exploreId, graphResult, loading, onChangeTime, showingGraph, showingTable, range, split } = this.props;
+ const graphHeight = showingGraph && showingTable ? '200px' : '400px';
+ return (
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const { split } = explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { graphResult, queryTransactions, range, showingGraph, showingTable } = item;
+ const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
+ return { graphResult, loading, range, showingGraph, showingTable, split };
+}
+
+const mapDispatchToProps = {
+ toggleGraph,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(GraphContainer));
diff --git a/public/app/features/explore/LogLabel.tsx b/public/app/features/explore/LogLabel.tsx
new file mode 100644
index 00000000000..b4570f10c82
--- /dev/null
+++ b/public/app/features/explore/LogLabel.tsx
@@ -0,0 +1,74 @@
+import React, { PureComponent } from 'react';
+
+import { calculateLogsLabelStats, LogLabelStatsModel, LogRowModel } from 'app/core/logs_model';
+import { LogLabelStats } from './LogLabelStats';
+
+interface Props {
+ getRows?: () => LogRowModel[];
+ label: string;
+ plain?: boolean;
+ value: string;
+ onClickLabel?: (label: string, value: string) => void;
+}
+
+interface State {
+ showStats: boolean;
+ stats: LogLabelStatsModel[];
+}
+
+export class LogLabel extends PureComponent {
+ state = {
+ stats: null,
+ showStats: false,
+ };
+
+ onClickClose = () => {
+ this.setState({ showStats: false });
+ };
+
+ onClickLabel = () => {
+ const { onClickLabel, label, value } = this.props;
+ if (onClickLabel) {
+ onClickLabel(label, value);
+ }
+ };
+
+ onClickStats = () => {
+ this.setState(state => {
+ if (state.showStats) {
+ return { showStats: false, stats: null };
+ }
+ const allRows = this.props.getRows();
+ const stats = calculateLogsLabelStats(allRows, this.props.label);
+ return { showStats: true, stats };
+ });
+ };
+
+ render() {
+ const { getRows, label, plain, value } = this.props;
+ const { showStats, stats } = this.state;
+ const tooltip = `${label}: ${value}`;
+ return (
+
+
+ {value}
+
+ {!plain && (
+
+ )}
+ {!plain && getRows && }
+ {showStats && (
+
+
+
+ )}
+
+ );
+ }
+}
diff --git a/public/app/features/explore/LogLabelStats.tsx b/public/app/features/explore/LogLabelStats.tsx
new file mode 100644
index 00000000000..b0bd69170c5
--- /dev/null
+++ b/public/app/features/explore/LogLabelStats.tsx
@@ -0,0 +1,72 @@
+import React, { PureComponent } from 'react';
+import classnames from 'classnames';
+import { LogLabelStatsModel } from 'app/core/logs_model';
+
+function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) {
+ const { active, count, proportion, value } = logLabelStatsModel;
+ const percent = `${Math.round(proportion * 100)}%`;
+ const barStyle = { width: percent };
+ const className = classnames('logs-stats-row', { 'logs-stats-row--active': active });
+
+ return (
+
+
+
{value}
+
{count}
+
{percent}
+
+
+
+ );
+}
+
+const STATS_ROW_LIMIT = 5;
+
+interface Props {
+ stats: LogLabelStatsModel[];
+ label: string;
+ value: string;
+ rowCount: number;
+ onClickClose: () => void;
+}
+
+export class LogLabelStats extends PureComponent {
+ render() {
+ const { label, rowCount, stats, value, onClickClose } = this.props;
+ const topRows = stats.slice(0, STATS_ROW_LIMIT);
+ let activeRow = topRows.find(row => row.value === value);
+ let otherRows = stats.slice(STATS_ROW_LIMIT);
+ const insertActiveRow = !activeRow;
+
+ // Remove active row from other to show extra
+ if (insertActiveRow) {
+ activeRow = otherRows.find(row => row.value === value);
+ otherRows = otherRows.filter(row => row.value !== value);
+ }
+
+ const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
+ const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
+ const total = topCount + otherCount;
+ const otherProportion = otherCount / total;
+
+ return (
+
+
+
+ {label}: {total} of {rowCount} rows have that label
+
+
+
+
+ {topRows.map(stat => )}
+ {insertActiveRow && activeRow && }
+ {otherCount > 0 && (
+
+ )}
+
+
+ );
+ }
+}
diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx
index 7675fb13152..7105a2a5370 100644
--- a/public/app/features/explore/LogLabels.tsx
+++ b/public/app/features/explore/LogLabels.tsx
@@ -1,147 +1,20 @@
import React, { PureComponent } from 'react';
-import classnames from 'classnames';
-import { calculateLogsLabelStats, LogsLabelStat, LogsStreamLabels, LogRow } from 'app/core/logs_model';
+import { LogsStreamLabels, LogRowModel } from 'app/core/logs_model';
+import { LogLabel } from './LogLabel';
-function StatsRow({ active, count, proportion, value }: LogsLabelStat) {
- const percent = `${Math.round(proportion * 100)}%`;
- const barStyle = { width: percent };
- const className = classnames('logs-stats-row', { 'logs-stats-row--active': active });
-
- return (
-
-
-
{value}
-
{count}
-
{percent}
-
-
-
- );
-}
-
-const STATS_ROW_LIMIT = 5;
-export class Stats extends PureComponent<{
- stats: LogsLabelStat[];
- label: string;
- value: string;
- rowCount: number;
- onClickClose: () => void;
-}> {
- render() {
- const { label, rowCount, stats, value, onClickClose } = this.props;
- const topRows = stats.slice(0, STATS_ROW_LIMIT);
- let activeRow = topRows.find(row => row.value === value);
- let otherRows = stats.slice(STATS_ROW_LIMIT);
- const insertActiveRow = !activeRow;
- // Remove active row from other to show extra
- if (insertActiveRow) {
- activeRow = otherRows.find(row => row.value === value);
- otherRows = otherRows.filter(row => row.value !== value);
- }
- const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
- const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
- const total = topCount + otherCount;
- const otherProportion = otherCount / total;
-
- return (
-
-
-
- {label}: {total} of {rowCount} rows have that label
-
-
-
-
- {topRows.map(stat => )}
- {insertActiveRow && activeRow && }
- {otherCount > 0 && (
-
- )}
-
-
- );
- }
-}
-
-class Label extends PureComponent<
- {
- getRows?: () => LogRow[];
- label: string;
- plain?: boolean;
- value: string;
- onClickLabel?: (label: string, value: string) => void;
- },
- { showStats: boolean; stats: LogsLabelStat[] }
-> {
- state = {
- stats: null,
- showStats: false,
- };
-
- onClickClose = () => {
- this.setState({ showStats: false });
- };
-
- onClickLabel = () => {
- const { onClickLabel, label, value } = this.props;
- if (onClickLabel) {
- onClickLabel(label, value);
- }
- };
-
- onClickStats = () => {
- this.setState(state => {
- if (state.showStats) {
- return { showStats: false, stats: null };
- }
- const allRows = this.props.getRows();
- const stats = calculateLogsLabelStats(allRows, this.props.label);
- return { showStats: true, stats };
- });
- };
-
- render() {
- const { getRows, label, plain, value } = this.props;
- const { showStats, stats } = this.state;
- const tooltip = `${label}: ${value}`;
- return (
-
-
- {value}
-
- {!plain && (
-
- )}
- {!plain && getRows && }
- {showStats && (
-
-
-
- )}
-
- );
- }
-}
-
-export default class LogLabels extends PureComponent<{
- getRows?: () => LogRow[];
+interface Props {
+ getRows?: () => LogRowModel[];
labels: LogsStreamLabels;
plain?: boolean;
onClickLabel?: (label: string, value: string) => void;
-}> {
+}
+
+export class LogLabels extends PureComponent {
render() {
const { getRows, labels, onClickLabel, plain } = this.props;
return Object.keys(labels).map(key => (
-
+
));
}
}
diff --git a/public/app/features/explore/LogRow.tsx b/public/app/features/explore/LogRow.tsx
new file mode 100644
index 00000000000..66b0e6a69fe
--- /dev/null
+++ b/public/app/features/explore/LogRow.tsx
@@ -0,0 +1,194 @@
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+import Highlighter from 'react-highlight-words';
+import classnames from 'classnames';
+
+import { LogRowModel, LogLabelStatsModel, LogsParser, calculateFieldStats, getParser } from 'app/core/logs_model';
+import { LogLabels } from './LogLabels';
+import { findHighlightChunksInText } from 'app/core/utils/text';
+import { LogLabelStats } from './LogLabelStats';
+
+interface Props {
+ highlighterExpressions?: string[];
+ row: LogRowModel;
+ showDuplicates: boolean;
+ showLabels: boolean | null; // Tristate: null means auto
+ showLocalTime: boolean;
+ showUtc: boolean;
+ getRows: () => LogRowModel[];
+ onClickLabel?: (label: string, value: string) => void;
+}
+
+interface State {
+ fieldCount: number;
+ fieldLabel: string;
+ fieldStats: LogLabelStatsModel[];
+ fieldValue: string;
+ parsed: boolean;
+ parser?: LogsParser;
+ parsedFieldHighlights: string[];
+ showFieldStats: boolean;
+}
+
+/**
+ * Renders a highlighted field.
+ * When hovering, a stats icon is shown.
+ */
+const FieldHighlight = onClick => props => {
+ return (
+
+ {props.children}
+ onClick(props.children)} />
+
+ );
+};
+
+/**
+ * Renders a log line.
+ *
+ * When user hovers over it for a certain time, it lazily parses the log line.
+ * Once a parser is found, it will determine fields, that will be highlighted.
+ * When the user requests stats for a field, they will be calculated and rendered below the row.
+ */
+export class LogRow extends PureComponent {
+ mouseMessageTimer: NodeJS.Timer;
+
+ state = {
+ fieldCount: 0,
+ fieldLabel: null,
+ fieldStats: null,
+ fieldValue: null,
+ parsed: false,
+ parser: undefined,
+ parsedFieldHighlights: [],
+ showFieldStats: false,
+ };
+
+ componentWillUnmount() {
+ clearTimeout(this.mouseMessageTimer);
+ }
+
+ onClickClose = () => {
+ this.setState({ showFieldStats: false });
+ };
+
+ onClickHighlight = (fieldText: string) => {
+ const { getRows } = this.props;
+ const { parser } = this.state;
+ const allRows = getRows();
+
+ // Build value-agnostic row matcher based on the field label
+ const fieldLabel = parser.getLabelFromField(fieldText);
+ const fieldValue = parser.getValueFromField(fieldText);
+ const matcher = parser.buildMatcher(fieldLabel);
+ const fieldStats = calculateFieldStats(allRows, matcher);
+ const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
+
+ this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true });
+ };
+
+ onMouseOverMessage = () => {
+ // Don't parse right away, user might move along
+ this.mouseMessageTimer = setTimeout(this.parseMessage, 500);
+ };
+
+ onMouseOutMessage = () => {
+ clearTimeout(this.mouseMessageTimer);
+ this.setState({ parsed: false });
+ };
+
+ parseMessage = () => {
+ if (!this.state.parsed) {
+ const { row } = this.props;
+ const parser = getParser(row.entry);
+ if (parser) {
+ // Use parser to highlight detected fields
+ const parsedFieldHighlights = parser.getFields(this.props.row.entry);
+ this.setState({ parsedFieldHighlights, parsed: true, parser });
+ }
+ }
+ };
+
+ render() {
+ const {
+ getRows,
+ highlighterExpressions,
+ onClickLabel,
+ row,
+ showDuplicates,
+ showLabels,
+ showLocalTime,
+ showUtc,
+ } = this.props;
+ const {
+ fieldCount,
+ fieldLabel,
+ fieldStats,
+ fieldValue,
+ parsed,
+ parsedFieldHighlights,
+ showFieldStats,
+ } = this.state;
+ const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
+ const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
+ const needsHighlighter = highlights && highlights.length > 0;
+ const highlightClassName = classnames('logs-row__match-highlight', {
+ 'logs-row__match-highlight--preview': previewHighlights,
+ });
+ return (
+
+ {showDuplicates && (
+
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
+ )}
+
+ {showUtc && (
+
+ {row.timestamp}
+
+ )}
+ {showLocalTime && (
+
+ {row.timeLocal}
+
+ )}
+ {showLabels && (
+
+
+
+ )}
+
+ {parsed && (
+
+ )}
+ {!parsed &&
+ needsHighlighter && (
+
+ )}
+ {!parsed && !needsHighlighter && row.entry}
+ {showFieldStats && (
+
+
+
+ )}
+
+
+ );
+ }
+}
diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx
index 1a384cf011d..30db1ec349c 100644
--- a/public/app/features/explore/Logs.tsx
+++ b/public/app/features/explore/Logs.tsx
@@ -1,7 +1,5 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
-import Highlighter from 'react-highlight-words';
-import classnames from 'classnames';
import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange } from '@grafana/ui';
@@ -11,20 +9,16 @@ import {
LogsModel,
dedupLogRows,
filterLogLevels,
- getParser,
LogLevel,
LogsMetaKind,
- LogsLabelStat,
- LogsParser,
- LogRow,
- calculateFieldStats,
} from 'app/core/logs_model';
-import { findHighlightChunksInText } from 'app/core/utils/text';
+
import { Switch } from 'app/core/components/Switch/Switch';
import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
import Graph from './Graph';
-import LogLabels, { Stats } from './LogLabels';
+import { LogLabels } from './LogLabels';
+import { LogRow } from './LogRow';
const PREVIEW_LIMIT = 100;
@@ -43,191 +37,6 @@ const graphOptions = {
},
};
-/**
- * Renders a highlighted field.
- * When hovering, a stats icon is shown.
- */
-const FieldHighlight = onClick => props => {
- return (
-
- {props.children}
- onClick(props.children)} />
-
- );
-};
-
-interface RowProps {
- highlighterExpressions?: string[];
- row: LogRow;
- showDuplicates: boolean;
- showLabels: boolean | null; // Tristate: null means auto
- showLocalTime: boolean;
- showUtc: boolean;
- getRows: () => LogRow[];
- onClickLabel?: (label: string, value: string) => void;
-}
-
-interface RowState {
- fieldCount: number;
- fieldLabel: string;
- fieldStats: LogsLabelStat[];
- fieldValue: string;
- parsed: boolean;
- parser?: LogsParser;
- parsedFieldHighlights: string[];
- showFieldStats: boolean;
-}
-
-/**
- * Renders a log line.
- *
- * When user hovers over it for a certain time, it lazily parses the log line.
- * Once a parser is found, it will determine fields, that will be highlighted.
- * When the user requests stats for a field, they will be calculated and rendered below the row.
- */
-class Row extends PureComponent {
- mouseMessageTimer: NodeJS.Timer;
-
- state = {
- fieldCount: 0,
- fieldLabel: null,
- fieldStats: null,
- fieldValue: null,
- parsed: false,
- parser: undefined,
- parsedFieldHighlights: [],
- showFieldStats: false,
- };
-
- componentWillUnmount() {
- clearTimeout(this.mouseMessageTimer);
- }
-
- onClickClose = () => {
- this.setState({ showFieldStats: false });
- };
-
- onClickHighlight = (fieldText: string) => {
- const { getRows } = this.props;
- const { parser } = this.state;
- const allRows = getRows();
-
- // Build value-agnostic row matcher based on the field label
- const fieldLabel = parser.getLabelFromField(fieldText);
- const fieldValue = parser.getValueFromField(fieldText);
- const matcher = parser.buildMatcher(fieldLabel);
- const fieldStats = calculateFieldStats(allRows, matcher);
- const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
-
- this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true });
- };
-
- onMouseOverMessage = () => {
- // Don't parse right away, user might move along
- this.mouseMessageTimer = setTimeout(this.parseMessage, 500);
- };
-
- onMouseOutMessage = () => {
- clearTimeout(this.mouseMessageTimer);
- this.setState({ parsed: false });
- };
-
- parseMessage = () => {
- if (!this.state.parsed) {
- const { row } = this.props;
- const parser = getParser(row.entry);
- if (parser) {
- // Use parser to highlight detected fields
- const parsedFieldHighlights = parser.getFields(this.props.row.entry);
- this.setState({ parsedFieldHighlights, parsed: true, parser });
- }
- }
- };
-
- render() {
- const {
- getRows,
- highlighterExpressions,
- onClickLabel,
- row,
- showDuplicates,
- showLabels,
- showLocalTime,
- showUtc,
- } = this.props;
- const {
- fieldCount,
- fieldLabel,
- fieldStats,
- fieldValue,
- parsed,
- parsedFieldHighlights,
- showFieldStats,
- } = this.state;
- const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
- const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
- const needsHighlighter = highlights && highlights.length > 0;
- const highlightClassName = classnames('logs-row__match-highlight', {
- 'logs-row__match-highlight--preview': previewHighlights,
- });
- return (
-
- {showDuplicates && (
-
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
- )}
-
- {showUtc && (
-
- {row.timestamp}
-
- )}
- {showLocalTime && (
-
- {row.timeLocal}
-
- )}
- {showLabels && (
-
-
-
- )}
-
- {parsed && (
-
- )}
- {!parsed &&
- needsHighlighter && (
-
- )}
- {!parsed && !needsHighlighter && row.entry}
- {showFieldStats && (
-
-
-
- )}
-
-
- );
- }
-}
-
function renderMetaItem(value: any, kind: LogsMetaKind) {
if (kind === LogsMetaKind.LabelsMap) {
return (
@@ -239,11 +48,11 @@ function renderMetaItem(value: any, kind: LogsMetaKind) {
return value;
}
-interface LogsProps {
+interface Props {
data: LogsModel;
+ exploreId: string;
highlighterExpressions: string[];
loading: boolean;
- position: string;
range?: RawTimeRange;
scanning?: boolean;
scanRange?: RawTimeRange;
@@ -253,7 +62,7 @@ interface LogsProps {
onStopScanning?: () => void;
}
-interface LogsState {
+interface State {
dedup: LogsDedupStrategy;
deferLogs: boolean;
hiddenLogLevels: Set;
@@ -263,7 +72,7 @@ interface LogsState {
showUtc: boolean;
}
-export default class Logs extends PureComponent {
+export default class Logs extends PureComponent {
deferLogsTimer: NodeJS.Timer;
renderAllTimer: NodeJS.Timer;
@@ -348,10 +157,10 @@ export default class Logs extends PureComponent {
render() {
const {
data,
+ exploreId,
highlighterExpressions,
loading = false,
onClickLabel,
- position,
range,
scanning,
scanRange,
@@ -400,7 +209,7 @@ export default class Logs extends PureComponent {
data={data.series}
height="100px"
range={range}
- id={`explore-logs-graph-${position}`}
+ id={`explore-logs-graph-${exploreId}`}
onChangeTime={this.props.onChangeTime}
onToggleSeries={this.onToggleLogLevel}
userOptions={graphOptions}
@@ -441,10 +250,9 @@ export default class Logs extends PureComponent {
{hasData &&
- !deferLogs &&
- // Only inject highlighterExpression in the first set for performance reasons
+ !deferLogs && // Only inject highlighterExpression in the first set for performance reasons
firstRows.map(row => (
-
{
!deferLogs &&
renderAll &&
lastRows.map(row => (
- void;
+ onClickLabel: (key: string, value: string) => void;
+ onStartScanning: () => void;
+ onStopScanning: () => void;
+ range: RawTimeRange;
+ scanning?: boolean;
+ scanRange?: RawTimeRange;
+ showingLogs: boolean;
+ toggleLogs: typeof toggleLogs;
+}
+
+export class LogsContainer extends PureComponent {
+ onClickLogsButton = () => {
+ this.props.toggleLogs(this.props.exploreId);
+ };
+
+ render() {
+ const {
+ exploreId,
+ loading,
+ logsHighlighterExpressions,
+ logsResult,
+ onChangeTime,
+ onClickLabel,
+ onStartScanning,
+ onStopScanning,
+ range,
+ showingLogs,
+ scanning,
+ scanRange,
+ } = this.props;
+ return (
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { logsHighlighterExpressions, logsResult, queryTransactions, scanning, scanRange, showingLogs, range } = item;
+ const loading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
+ return {
+ loading,
+ logsHighlighterExpressions,
+ logsResult,
+ scanning,
+ scanRange,
+ showingLogs,
+ range,
+ };
+}
+
+const mapDispatchToProps = {
+ toggleLogs,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer));
diff --git a/public/app/features/explore/QueryEditor.tsx b/public/app/features/explore/QueryEditor.tsx
index ce0a8a6e03e..266e6fb42df 100644
--- a/public/app/features/explore/QueryEditor.tsx
+++ b/public/app/features/explore/QueryEditor.tsx
@@ -1,10 +1,13 @@
+// Libraries
import React, { PureComponent } from 'react';
+
+// Services
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
-import { Emitter } from 'app/core/utils/emitter';
-import { getIntervals } from 'app/core/utils/explore';
-import { DataQuery } from 'app/types';
-import { RawTimeRange } from '@grafana/ui';
import { getTimeSrv } from 'app/features/dashboard/time_srv';
+
+// Types
+import { Emitter } from 'app/core/utils/emitter';
+import { RawTimeRange, DataQuery } from '@grafana/ui';
import 'app/features/plugins/plugin_loader';
interface QueryEditorProps {
@@ -33,8 +36,9 @@ export default class QueryEditor extends PureComponent {
const template = ' ';
const target = { datasource: datasource.name, ...initialQuery };
const scopeProps = {
- target,
ctrl: {
+ datasource,
+ target,
refresh: () => {
this.props.onQueryChange(target, false);
this.props.onExecuteQuery();
@@ -44,11 +48,7 @@ export default class QueryEditor extends PureComponent {
datasource,
targets: [target],
},
- dashboard: {
- getNextQueryLetter: x => '',
- },
- hideEditorRowActions: true,
- ...getIntervals(range, datasource, null), // Possible to get resolution?
+ dashboard: {},
},
};
@@ -73,6 +73,6 @@ export default class QueryEditor extends PureComponent {
}
render() {
- return (this.element = element)} style={{ width: '100%' }} />;
+ return
(this.element = element)} style={{ width: '100%' }} />;
}
}
diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx
new file mode 100644
index 00000000000..3bc2b556a63
--- /dev/null
+++ b/public/app/features/explore/QueryRow.tsx
@@ -0,0 +1,167 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+// Components
+import QueryEditor from './QueryEditor';
+import QueryTransactionStatus from './QueryTransactionStatus';
+
+// Actions
+import {
+ addQueryRow,
+ changeQuery,
+ highlightLogsExpression,
+ modifyQueries,
+ removeQueryRow,
+ runQueries,
+} from './state/actions';
+
+// Types
+import { StoreState } from 'app/types';
+import { RawTimeRange, DataQuery, QueryHint } from '@grafana/ui';
+import { QueryTransaction, HistoryItem, ExploreItemState, ExploreId } from 'app/types/explore';
+import { Emitter } from 'app/core/utils/emitter';
+
+function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
+ const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
+ if (transaction) {
+ return transaction.hints[0];
+ }
+ return undefined;
+}
+
+interface QueryRowProps {
+ addQueryRow: typeof addQueryRow;
+ changeQuery: typeof changeQuery;
+ className?: string;
+ exploreId: ExploreId;
+ datasourceInstance: any;
+ highlightLogsExpression: typeof highlightLogsExpression;
+ history: HistoryItem[];
+ index: number;
+ initialQuery: DataQuery;
+ modifyQueries: typeof modifyQueries;
+ queryTransactions: QueryTransaction[];
+ exploreEvents: Emitter;
+ range: RawTimeRange;
+ removeQueryRow: typeof removeQueryRow;
+ runQueries: typeof runQueries;
+}
+
+export class QueryRow extends PureComponent
{
+ onExecuteQuery = () => {
+ const { exploreId } = this.props;
+ this.props.runQueries(exploreId);
+ };
+
+ onChangeQuery = (query: DataQuery, override?: boolean) => {
+ const { datasourceInstance, exploreId, index } = this.props;
+ this.props.changeQuery(exploreId, query, index, override);
+ if (query && !override && datasourceInstance.getHighlighterExpression && index === 0) {
+ // Live preview of log search matches. Only use on first row for now
+ this.updateLogsHighlights(query);
+ }
+ };
+
+ onClickAddButton = () => {
+ const { exploreId, index } = this.props;
+ this.props.addQueryRow(exploreId, index);
+ };
+
+ onClickClearButton = () => {
+ this.onChangeQuery(null, true);
+ };
+
+ onClickHintFix = action => {
+ const { datasourceInstance, exploreId, index } = this.props;
+ if (datasourceInstance && datasourceInstance.modifyQuery) {
+ const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action);
+ this.props.modifyQueries(exploreId, action, index, modifier);
+ }
+ };
+
+ onClickRemoveButton = () => {
+ const { exploreId, index } = this.props;
+ this.props.removeQueryRow(exploreId, index);
+ };
+
+ updateLogsHighlights = _.debounce((value: DataQuery) => {
+ const { datasourceInstance } = this.props;
+ if (datasourceInstance.getHighlighterExpression) {
+ const expressions = [datasourceInstance.getHighlighterExpression(value)];
+ this.props.highlightLogsExpression(this.props.exploreId, expressions);
+ }
+ }, 500);
+
+ render() {
+ const { datasourceInstance, history, index, initialQuery, queryTransactions, exploreEvents, range } = this.props;
+ const transactions = queryTransactions.filter(t => t.rowIndex === index);
+ const transactionWithError = transactions.find(t => t.error !== undefined);
+ const hint = getFirstHintFromTransactions(transactions);
+ const queryError = transactionWithError ? transactionWithError.error : null;
+ const QueryField = datasourceInstance.pluginExports.ExploreQueryField;
+ return (
+
+
+
+
+
+ {QueryField ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId, index }) {
+ const explore = state.explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { datasourceInstance, history, initialQueries, queryTransactions, range } = item;
+ const initialQuery = initialQueries[index];
+ return { datasourceInstance, history, initialQuery, queryTransactions, range };
+}
+
+const mapDispatchToProps = {
+ addQueryRow,
+ changeQuery,
+ highlightLogsExpression,
+ modifyQueries,
+ removeQueryRow,
+ runQueries,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(QueryRow));
diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx
index 4101475092b..f8bb6e5ce6b 100644
--- a/public/app/features/explore/QueryRows.tsx
+++ b/public/app/features/explore/QueryRows.tsx
@@ -1,159 +1,29 @@
+// Libraries
import React, { PureComponent } from 'react';
-import { QueryTransaction, HistoryItem, QueryHint } from 'app/types/explore';
+// Components
+import QueryRow from './QueryRow';
+
+// Types
import { Emitter } from 'app/core/utils/emitter';
+import { DataQuery } from '@grafana/ui/src/types';
+import { ExploreId } from 'app/types/explore';
-// import DefaultQueryField from './QueryField';
-import QueryEditor from './QueryEditor';
-import QueryTransactionStatus from './QueryTransactionStatus';
-import { DataSource, DataQuery } from 'app/types';
-import { RawTimeRange } from '@grafana/ui';
-
-function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
- const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
- if (transaction) {
- return transaction.hints[0];
- }
- return undefined;
-}
-
-interface QueryRowEventHandlers {
- onAddQueryRow: (index: number) => void;
- onChangeQuery: (value: DataQuery, index: number, override?: boolean) => void;
- onClickHintFix: (action: object, index?: number) => void;
- onExecuteQuery: () => void;
- onRemoveQueryRow: (index: number) => void;
-}
-
-interface QueryRowCommonProps {
+interface QueryRowsProps {
className?: string;
- datasource: DataSource;
- history: HistoryItem[];
- transactions: QueryTransaction[];
exploreEvents: Emitter;
- range: RawTimeRange;
+ exploreId: ExploreId;
+ initialQueries: DataQuery[];
}
-type QueryRowProps = QueryRowCommonProps &
- QueryRowEventHandlers & {
- index: number;
- initialQuery: DataQuery;
- };
-
-class QueryRow extends PureComponent {
- onExecuteQuery = () => {
- const { onExecuteQuery } = this.props;
- onExecuteQuery();
- };
-
- onChangeQuery = (value: DataQuery, override?: boolean) => {
- const { index, onChangeQuery } = this.props;
- if (onChangeQuery) {
- onChangeQuery(value, index, override);
- }
- };
-
- onClickAddButton = () => {
- const { index, onAddQueryRow } = this.props;
- if (onAddQueryRow) {
- onAddQueryRow(index);
- }
- };
-
- onClickClearButton = () => {
- this.onChangeQuery(null, true);
- };
-
- onClickHintFix = action => {
- const { index, onClickHintFix } = this.props;
- if (onClickHintFix) {
- onClickHintFix(action, index);
- }
- };
-
- onClickRemoveButton = () => {
- const { index, onRemoveQueryRow } = this.props;
- if (onRemoveQueryRow) {
- onRemoveQueryRow(index);
- }
- };
-
- onPressEnter = () => {
- const { onExecuteQuery } = this.props;
- if (onExecuteQuery) {
- onExecuteQuery();
- }
- };
-
- render() {
- const { datasource, history, initialQuery, transactions, exploreEvents, range } = this.props;
- const transactionWithError = transactions.find(t => t.error !== undefined);
- const hint = getFirstHintFromTransactions(transactions);
- const queryError = transactionWithError ? transactionWithError.error : null;
- const QueryField = datasource.pluginExports.ExploreQueryField;
- return (
-
-
-
-
-
- {QueryField ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-type QueryRowsProps = QueryRowCommonProps &
- QueryRowEventHandlers & {
- initialQueries: DataQuery[];
- };
-
export default class QueryRows extends PureComponent {
render() {
- const { className = '', initialQueries, transactions, ...handlers } = this.props;
+ const { className = '', exploreEvents, exploreId, initialQueries } = this.props;
return (
{initialQueries.map((query, index) => (
- t.rowIndex === index)}
- {...handlers}
- />
+ // TODO instead of relying on initialQueries, move to react key list in redux
+
))}
);
diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx
new file mode 100644
index 00000000000..ed26ce5147f
--- /dev/null
+++ b/public/app/features/explore/TableContainer.tsx
@@ -0,0 +1,49 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleTable } from './state/actions';
+import Table from './Table';
+import Panel from './Panel';
+import TableModel from 'app/core/table_model';
+
+interface TableContainerProps {
+ exploreId: ExploreId;
+ loading: boolean;
+ onClickCell: (key: string, value: string) => void;
+ showingTable: boolean;
+ tableResult?: TableModel;
+ toggleTable: typeof toggleTable;
+}
+
+export class TableContainer extends PureComponent {
+ onClickTableButton = () => {
+ this.props.toggleTable(this.props.exploreId);
+ };
+
+ render() {
+ const { loading, onClickCell, showingTable, tableResult } = this.props;
+ return (
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { queryTransactions, showingTable, tableResult } = item;
+ const loading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
+ return { loading, showingTable, tableResult };
+}
+
+const mapDispatchToProps = {
+ toggleTable,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer));
diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx
index de1eee4c662..770b6bd6588 100644
--- a/public/app/features/explore/Wrapper.tsx
+++ b/public/app/features/explore/Wrapper.tsx
@@ -3,103 +3,76 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { updateLocation } from 'app/core/actions';
-import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
import { StoreState } from 'app/types';
-import { ExploreState } from 'app/types/explore';
+import { ExploreId, ExploreUrlState } from 'app/types/explore';
+import { parseUrlState } from 'app/core/utils/explore';
+import { initializeExploreSplit } from './state/actions';
import ErrorBoundary from './ErrorBoundary';
import Explore from './Explore';
+import { CustomScrollbar } from '@grafana/ui';
interface WrapperProps {
- backendSrv?: any;
- datasourceSrv?: any;
+ initializeExploreSplit: typeof initializeExploreSplit;
+ split: boolean;
updateLocation: typeof updateLocation;
urlStates: { [key: string]: string };
}
-interface WrapperState {
- split: boolean;
- splitState: ExploreState;
-}
-
-const STATE_KEY_LEFT = 'state';
-const STATE_KEY_RIGHT = 'stateRight';
-
-export class Wrapper extends Component {
- urlStates: { [key: string]: string };
+export class Wrapper extends Component {
+ initialSplit: boolean;
+ urlStates: { [key: string]: ExploreUrlState };
constructor(props: WrapperProps) {
super(props);
- this.urlStates = props.urlStates;
- this.state = {
- split: Boolean(props.urlStates[STATE_KEY_RIGHT]),
- splitState: undefined,
- };
+ this.urlStates = {};
+ const { left, right } = props.urlStates;
+ if (props.urlStates.left) {
+ this.urlStates.leftState = parseUrlState(left);
+ }
+ if (props.urlStates.right) {
+ this.urlStates.rightState = parseUrlState(right);
+ this.initialSplit = true;
+ }
}
- onChangeSplit = (split: boolean, splitState: ExploreState) => {
- this.setState({ split, splitState });
- // When closing split, remove URL state for split part
- if (!split) {
- delete this.urlStates[STATE_KEY_RIGHT];
- this.props.updateLocation({
- query: this.urlStates,
- });
+ componentDidMount() {
+ if (this.initialSplit) {
+ this.props.initializeExploreSplit();
}
- };
-
- onSaveState = (key: string, state: ExploreState) => {
- const urlState = serializeStateToUrlParam(state, true);
- this.urlStates[key] = urlState;
- this.props.updateLocation({
- query: this.urlStates,
- });
- };
+ }
render() {
- const { datasourceSrv } = this.props;
- // State overrides for props from first Explore
- const { split, splitState } = this.state;
- const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]);
- const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]);
+ const { split } = this.props;
+ const { leftState, rightState } = this.urlStates;
return (
-
-
-
-
- {split && (
-
-
-
- )}
+
+
+
+
+
+
+ {split && (
+
+
+
+ )}
+
+
);
}
}
-const mapStateToProps = (state: StoreState) => ({
- urlStates: state.location.query,
-});
+const mapStateToProps = (state: StoreState) => {
+ const urlStates = state.location.query;
+ const { split } = state.explore;
+ return { split, urlStates };
+};
const mapDispatchToProps = {
+ initializeExploreSplit,
updateLocation,
};
diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts
new file mode 100644
index 00000000000..4e1d658f072
--- /dev/null
+++ b/public/app/features/explore/state/actionTypes.ts
@@ -0,0 +1,300 @@
+// Types
+import { Emitter } from 'app/core/core';
+import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem } from '@grafana/ui/src/types';
+import {
+ ExploreId,
+ ExploreItemState,
+ HistoryItem,
+ RangeScanner,
+ ResultType,
+ QueryTransaction,
+} from 'app/types/explore';
+
+export enum ActionTypes {
+ AddQueryRow = 'explore/ADD_QUERY_ROW',
+ ChangeDatasource = 'explore/CHANGE_DATASOURCE',
+ ChangeQuery = 'explore/CHANGE_QUERY',
+ ChangeSize = 'explore/CHANGE_SIZE',
+ ChangeTime = 'explore/CHANGE_TIME',
+ ClearQueries = 'explore/CLEAR_QUERIES',
+ HighlightLogsExpression = 'explore/HIGHLIGHT_LOGS_EXPRESSION',
+ InitializeExplore = 'explore/INITIALIZE_EXPLORE',
+ InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT',
+ LoadDatasourceFailure = 'explore/LOAD_DATASOURCE_FAILURE',
+ LoadDatasourceMissing = 'explore/LOAD_DATASOURCE_MISSING',
+ LoadDatasourcePending = 'explore/LOAD_DATASOURCE_PENDING',
+ LoadDatasourceSuccess = 'explore/LOAD_DATASOURCE_SUCCESS',
+ ModifyQueries = 'explore/MODIFY_QUERIES',
+ QueryTransactionFailure = 'explore/QUERY_TRANSACTION_FAILURE',
+ QueryTransactionStart = 'explore/QUERY_TRANSACTION_START',
+ QueryTransactionSuccess = 'explore/QUERY_TRANSACTION_SUCCESS',
+ RemoveQueryRow = 'explore/REMOVE_QUERY_ROW',
+ RunQueries = 'explore/RUN_QUERIES',
+ RunQueriesEmpty = 'explore/RUN_QUERIES_EMPTY',
+ ScanRange = 'explore/SCAN_RANGE',
+ ScanStart = 'explore/SCAN_START',
+ ScanStop = 'explore/SCAN_STOP',
+ SetQueries = 'explore/SET_QUERIES',
+ SplitClose = 'explore/SPLIT_CLOSE',
+ SplitOpen = 'explore/SPLIT_OPEN',
+ StateSave = 'explore/STATE_SAVE',
+ ToggleGraph = 'explore/TOGGLE_GRAPH',
+ ToggleLogs = 'explore/TOGGLE_LOGS',
+ ToggleTable = 'explore/TOGGLE_TABLE',
+}
+
+export interface AddQueryRowAction {
+ type: ActionTypes.AddQueryRow;
+ payload: {
+ exploreId: ExploreId;
+ index: number;
+ query: DataQuery;
+ };
+}
+
+export interface ChangeQueryAction {
+ type: ActionTypes.ChangeQuery;
+ payload: {
+ exploreId: ExploreId;
+ query: DataQuery;
+ index: number;
+ override: boolean;
+ };
+}
+
+export interface ChangeSizeAction {
+ type: ActionTypes.ChangeSize;
+ payload: {
+ exploreId: ExploreId;
+ width: number;
+ height: number;
+ };
+}
+
+export interface ChangeTimeAction {
+ type: ActionTypes.ChangeTime;
+ payload: {
+ exploreId: ExploreId;
+ range: TimeRange;
+ };
+}
+
+export interface ClearQueriesAction {
+ type: ActionTypes.ClearQueries;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface HighlightLogsExpressionAction {
+ type: ActionTypes.HighlightLogsExpression;
+ payload: {
+ exploreId: ExploreId;
+ expressions: string[];
+ };
+}
+
+export interface InitializeExploreAction {
+ type: ActionTypes.InitializeExplore;
+ payload: {
+ exploreId: ExploreId;
+ containerWidth: number;
+ datasource: string;
+ eventBridge: Emitter;
+ exploreDatasources: DataSourceSelectItem[];
+ queries: DataQuery[];
+ range: RawTimeRange;
+ };
+}
+
+export interface InitializeExploreSplitAction {
+ type: ActionTypes.InitializeExploreSplit;
+}
+
+export interface LoadDatasourceFailureAction {
+ type: ActionTypes.LoadDatasourceFailure;
+ payload: {
+ exploreId: ExploreId;
+ error: string;
+ };
+}
+
+export interface LoadDatasourcePendingAction {
+ type: ActionTypes.LoadDatasourcePending;
+ payload: {
+ exploreId: ExploreId;
+ datasourceName: string;
+ };
+}
+
+export interface LoadDatasourceMissingAction {
+ type: ActionTypes.LoadDatasourceMissing;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface LoadDatasourceSuccessAction {
+ type: ActionTypes.LoadDatasourceSuccess;
+ payload: {
+ exploreId: ExploreId;
+ StartPage?: any;
+ datasourceInstance: any;
+ history: HistoryItem[];
+ initialDatasource: string;
+ initialQueries: DataQuery[];
+ logsHighlighterExpressions?: any[];
+ showingStartPage: boolean;
+ supportsGraph: boolean;
+ supportsLogs: boolean;
+ supportsTable: boolean;
+ };
+}
+
+export interface ModifyQueriesAction {
+ type: ActionTypes.ModifyQueries;
+ payload: {
+ exploreId: ExploreId;
+ modification: any;
+ index: number;
+ modifier: (queries: DataQuery[], modification: any) => DataQuery[];
+ };
+}
+
+export interface QueryTransactionFailureAction {
+ type: ActionTypes.QueryTransactionFailure;
+ payload: {
+ exploreId: ExploreId;
+ queryTransactions: QueryTransaction[];
+ };
+}
+
+export interface QueryTransactionStartAction {
+ type: ActionTypes.QueryTransactionStart;
+ payload: {
+ exploreId: ExploreId;
+ resultType: ResultType;
+ rowIndex: number;
+ transaction: QueryTransaction;
+ };
+}
+
+export interface QueryTransactionSuccessAction {
+ type: ActionTypes.QueryTransactionSuccess;
+ payload: {
+ exploreId: ExploreId;
+ history: HistoryItem[];
+ queryTransactions: QueryTransaction[];
+ };
+}
+
+export interface RemoveQueryRowAction {
+ type: ActionTypes.RemoveQueryRow;
+ payload: {
+ exploreId: ExploreId;
+ index: number;
+ };
+}
+
+export interface RunQueriesEmptyAction {
+ type: ActionTypes.RunQueriesEmpty;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface ScanStartAction {
+ type: ActionTypes.ScanStart;
+ payload: {
+ exploreId: ExploreId;
+ scanner: RangeScanner;
+ };
+}
+
+export interface ScanRangeAction {
+ type: ActionTypes.ScanRange;
+ payload: {
+ exploreId: ExploreId;
+ range: RawTimeRange;
+ };
+}
+
+export interface ScanStopAction {
+ type: ActionTypes.ScanStop;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface SetQueriesAction {
+ type: ActionTypes.SetQueries;
+ payload: {
+ exploreId: ExploreId;
+ queries: DataQuery[];
+ };
+}
+
+export interface SplitCloseAction {
+ type: ActionTypes.SplitClose;
+}
+
+export interface SplitOpenAction {
+ type: ActionTypes.SplitOpen;
+ payload: {
+ itemState: ExploreItemState;
+ };
+}
+
+export interface StateSaveAction {
+ type: ActionTypes.StateSave;
+}
+
+export interface ToggleTableAction {
+ type: ActionTypes.ToggleTable;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface ToggleGraphAction {
+ type: ActionTypes.ToggleGraph;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface ToggleLogsAction {
+ type: ActionTypes.ToggleLogs;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export type Action =
+ | AddQueryRowAction
+ | ChangeQueryAction
+ | ChangeSizeAction
+ | ChangeTimeAction
+ | ClearQueriesAction
+ | HighlightLogsExpressionAction
+ | InitializeExploreAction
+ | InitializeExploreSplitAction
+ | LoadDatasourceFailureAction
+ | LoadDatasourceMissingAction
+ | LoadDatasourcePendingAction
+ | LoadDatasourceSuccessAction
+ | ModifyQueriesAction
+ | QueryTransactionFailureAction
+ | QueryTransactionStartAction
+ | QueryTransactionSuccessAction
+ | RemoveQueryRowAction
+ | RunQueriesEmptyAction
+ | ScanRangeAction
+ | ScanStartAction
+ | ScanStopAction
+ | SetQueriesAction
+ | SplitCloseAction
+ | SplitOpenAction
+ | ToggleGraphAction
+ | ToggleLogsAction
+ | ToggleTableAction;
diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts
new file mode 100644
index 00000000000..d4c42ffa9c7
--- /dev/null
+++ b/public/app/features/explore/state/actions.ts
@@ -0,0 +1,768 @@
+// Libraries
+import _ from 'lodash';
+import { ThunkAction } from 'redux-thunk';
+
+// Services & Utils
+import store from 'app/core/store';
+import {
+ LAST_USED_DATASOURCE_KEY,
+ clearQueryKeys,
+ ensureQueries,
+ generateEmptyQuery,
+ hasNonEmptyQuery,
+ makeTimeSeriesList,
+ updateHistory,
+ buildQueryTransaction,
+ serializeStateToUrlParam,
+} from 'app/core/utils/explore';
+
+// Actions
+import { updateLocation } from 'app/core/actions';
+
+// Types
+import { StoreState } from 'app/types';
+import { DataQuery, DataSourceSelectItem, QueryHint } from '@grafana/ui/src/types';
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import {
+ ExploreId,
+ ExploreUrlState,
+ RangeScanner,
+ ResultType,
+ QueryOptions,
+ QueryTransaction,
+} from 'app/types/explore';
+
+import { Emitter } from 'app/core/core';
+import { RawTimeRange, TimeRange, DataSourceApi } from '@grafana/ui';
+import {
+ Action as ThunkableAction,
+ ActionTypes,
+ AddQueryRowAction,
+ ChangeSizeAction,
+ HighlightLogsExpressionAction,
+ LoadDatasourceFailureAction,
+ LoadDatasourceMissingAction,
+ LoadDatasourcePendingAction,
+ LoadDatasourceSuccessAction,
+ QueryTransactionStartAction,
+ ScanStopAction,
+} from './actionTypes';
+
+
+type ThunkResult
= ThunkAction;
+
+/**
+ * Adds a query row after the row with the given index.
+ */
+export function addQueryRow(exploreId: ExploreId, index: number): AddQueryRowAction {
+ const query = generateEmptyQuery(index + 1);
+ return { type: ActionTypes.AddQueryRow, payload: { exploreId, index, query } };
+}
+
+/**
+ * Loads a new datasource identified by the given name.
+ */
+export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult {
+ return async dispatch => {
+ const instance = await getDatasourceSrv().get(datasource);
+ dispatch(loadDatasource(exploreId, instance));
+ };
+}
+
+/**
+ * Query change handler for the query row with the given index.
+ * If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
+ */
+export function changeQuery(
+ exploreId: ExploreId,
+ query: DataQuery,
+ index: number,
+ override: boolean
+): ThunkResult {
+ return dispatch => {
+ // Null query means reset
+ if (query === null) {
+ query = { ...generateEmptyQuery(index) };
+ }
+
+ dispatch({ type: ActionTypes.ChangeQuery, payload: { exploreId, query, index, override } });
+ if (override) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Keep track of the Explore container size, in particular the width.
+ * The width will be used to calculate graph intervals (number of datapoints).
+ */
+export function changeSize(
+ exploreId: ExploreId,
+ { height, width }: { height: number; width: number }
+): ChangeSizeAction {
+ return { type: ActionTypes.ChangeSize, payload: { exploreId, height, width } };
+}
+
+/**
+ * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
+ */
+export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.ChangeTime, payload: { exploreId, range } });
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Clear all queries and results.
+ */
+export function clearQueries(exploreId: ExploreId): ThunkResult {
+ return dispatch => {
+ dispatch(scanStop(exploreId));
+ dispatch({ type: ActionTypes.ClearQueries, payload: { exploreId } });
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Highlight expressions in the log results
+ */
+export function highlightLogsExpression(exploreId: ExploreId, expressions: string[]): HighlightLogsExpressionAction {
+ return { type: ActionTypes.HighlightLogsExpression, payload: { exploreId, expressions } };
+}
+
+/**
+ * Initialize Explore state with state from the URL and the React component.
+ * Call this only on components for with the Explore state has not been initialized.
+ */
+export function initializeExplore(
+ exploreId: ExploreId,
+ datasource: string,
+ queries: DataQuery[],
+ range: RawTimeRange,
+ containerWidth: number,
+ eventBridge: Emitter
+): ThunkResult {
+ return async dispatch => {
+ const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
+ .getExternal()
+ .map(ds => ({
+ value: ds.name,
+ name: ds.name,
+ meta: ds.meta,
+ }));
+
+ dispatch({
+ type: ActionTypes.InitializeExplore,
+ payload: {
+ exploreId,
+ containerWidth,
+ datasource,
+ eventBridge,
+ exploreDatasources,
+ queries,
+ range,
+ },
+ });
+
+ if (exploreDatasources.length >= 1) {
+ let instance;
+ if (datasource) {
+ try {
+ instance = await getDatasourceSrv().get(datasource);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ // Checking on instance here because requested datasource could be deleted already
+ if (!instance) {
+ instance = await getDatasourceSrv().get();
+ }
+ dispatch(loadDatasource(exploreId, instance));
+ } else {
+ dispatch(loadDatasourceMissing(exploreId));
+ }
+ };
+}
+
+/**
+ * Initialize the wrapper split state
+ */
+export function initializeExploreSplit() {
+ return async dispatch => {
+ dispatch({ type: ActionTypes.InitializeExploreSplit });
+ };
+}
+
+/**
+ * Display an error that happened during the selection of a datasource
+ */
+export const loadDatasourceFailure = (exploreId: ExploreId, error: string): LoadDatasourceFailureAction => ({
+ type: ActionTypes.LoadDatasourceFailure,
+ payload: {
+ exploreId,
+ error,
+ },
+});
+
+/**
+ * Display an error when no datasources have been configured
+ */
+export const loadDatasourceMissing = (exploreId: ExploreId): LoadDatasourceMissingAction => ({
+ type: ActionTypes.LoadDatasourceMissing,
+ payload: { exploreId },
+});
+
+/**
+ * Start the async process of loading a datasource to display a loading indicator
+ */
+export const loadDatasourcePending = (exploreId: ExploreId, datasourceName: string): LoadDatasourcePendingAction => ({
+ type: ActionTypes.LoadDatasourcePending,
+ payload: {
+ exploreId,
+ datasourceName,
+ },
+});
+
+/**
+ * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
+ * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
+ * e.g., Prometheus -> Loki queries.
+ */
+export const loadDatasourceSuccess = (
+ exploreId: ExploreId,
+ instance: any,
+ queries: DataQuery[]
+): LoadDatasourceSuccessAction => {
+ // Capabilities
+ const supportsGraph = instance.meta.metrics;
+ const supportsLogs = instance.meta.logs;
+ const supportsTable = instance.meta.tables;
+ // Custom components
+ const StartPage = instance.pluginExports.ExploreStartPage;
+
+ const historyKey = `grafana.explore.history.${instance.meta.id}`;
+ const history = store.getObject(historyKey, []);
+ // Save last-used datasource
+ store.set(LAST_USED_DATASOURCE_KEY, instance.name);
+
+ return {
+ type: ActionTypes.LoadDatasourceSuccess,
+ payload: {
+ exploreId,
+ StartPage,
+ datasourceInstance: instance,
+ history,
+ initialDatasource: instance.name,
+ initialQueries: queries,
+ showingStartPage: Boolean(StartPage),
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ },
+ };
+};
+
+/**
+ * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
+ */
+export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): ThunkResult {
+ return async (dispatch, getState) => {
+ const datasourceName = instance.name;
+
+ // Keep ID to track selection
+ dispatch(loadDatasourcePending(exploreId, datasourceName));
+
+ let datasourceError = null;
+ try {
+ const testResult = await instance.testDatasource();
+ datasourceError = testResult.status === 'success' ? null : testResult.message;
+ } catch (error) {
+ datasourceError = (error && error.statusText) || 'Network error';
+ }
+
+ if (datasourceError) {
+ dispatch(loadDatasourceFailure(exploreId, datasourceError));
+ return;
+ }
+
+ if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
+ // User already changed datasource again, discard results
+ return;
+ }
+
+ if (instance.init) {
+ instance.init();
+ }
+
+ // Check if queries can be imported from previously selected datasource
+ const queries = getState().explore[exploreId].modifiedQueries;
+ let importedQueries = queries;
+ const origin = getState().explore[exploreId].datasourceInstance;
+ if (origin) {
+ if (origin.meta.id === instance.meta.id) {
+ // Keep same queries if same type of datasource
+ importedQueries = [...queries];
+ } else if (instance.importQueries) {
+ // Datasource-specific importers
+ importedQueries = await instance.importQueries(queries, origin.meta);
+ } else {
+ // Default is blank queries
+ importedQueries = ensureQueries();
+ }
+ }
+
+ if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
+ // User already changed datasource again, discard results
+ return;
+ }
+
+ // Reset edit state with new queries
+ const nextQueries = importedQueries.map((q, i) => ({
+ ...importedQueries[i],
+ ...generateEmptyQuery(i),
+ }));
+
+ dispatch(loadDatasourceSuccess(exploreId, instance, nextQueries));
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Action to modify a query given a datasource-specific modifier action.
+ * @param exploreId Explore area
+ * @param modification Action object with a type, e.g., ADD_FILTER
+ * @param index Optional query row index. If omitted, the modification is applied to all query rows.
+ * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
+ */
+export function modifyQueries(
+ exploreId: ExploreId,
+ modification: any,
+ index: number,
+ modifier: any
+): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.ModifyQueries, payload: { exploreId, modification, index, modifier } });
+ if (!modification.preventSubmit) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Mark a query transaction as failed with an error extracted from the query response.
+ * The transaction will be marked as `done`.
+ */
+export function queryTransactionFailure(
+ exploreId: ExploreId,
+ transactionId: string,
+ response: any,
+ datasourceId: string
+): ThunkResult {
+ return (dispatch, getState) => {
+ const { datasourceInstance, queryTransactions } = getState().explore[exploreId];
+ if (datasourceInstance.meta.id !== datasourceId || response.cancelled) {
+ // Navigated away, queries did not matter
+ return;
+ }
+
+ // Transaction might have been discarded
+ if (!queryTransactions.find(qt => qt.id === transactionId)) {
+ return;
+ }
+
+ console.error(response);
+
+ let error: string;
+ let errorDetails: string;
+ if (response.data) {
+ if (typeof response.data === 'string') {
+ error = response.data;
+ } else if (response.data.error) {
+ error = response.data.error;
+ if (response.data.response) {
+ errorDetails = response.data.response;
+ }
+ } else {
+ throw new Error('Could not handle error response');
+ }
+ } else if (response.message) {
+ error = response.message;
+ } else if (typeof response === 'string') {
+ error = response;
+ } else {
+ error = 'Unknown error during query transaction. Please check JS console logs.';
+ }
+
+ // Mark transactions as complete
+ const nextQueryTransactions = queryTransactions.map(qt => {
+ if (qt.id === transactionId) {
+ return {
+ ...qt,
+ error,
+ errorDetails,
+ done: true,
+ };
+ }
+ return qt;
+ });
+
+ dispatch({
+ type: ActionTypes.QueryTransactionFailure,
+ payload: { exploreId, queryTransactions: nextQueryTransactions },
+ });
+ };
+}
+
+/**
+ * Start a query transaction for the given result type.
+ * @param exploreId Explore area
+ * @param transaction Query options and `done` status.
+ * @param resultType Associate the transaction with a result viewer, e.g., Graph
+ * @param rowIndex Index is used to associate latency for this transaction with a query row
+ */
+export function queryTransactionStart(
+ exploreId: ExploreId,
+ transaction: QueryTransaction,
+ resultType: ResultType,
+ rowIndex: number
+): QueryTransactionStartAction {
+ return { type: ActionTypes.QueryTransactionStart, payload: { exploreId, resultType, rowIndex, transaction } };
+}
+
+/**
+ * Complete a query transaction, mark the transaction as `done` and store query state in URL.
+ * If the transaction was started by a scanner, it keeps on scanning for more results.
+ * Side-effect: the query is stored in localStorage.
+ * @param exploreId Explore area
+ * @param transactionId ID
+ * @param result Response from `datasourceInstance.query()`
+ * @param latency Duration between request and response
+ * @param queries Queries from all query rows
+ * @param datasourceId Origin datasource instance, used to discard results if current datasource is different
+ */
+export function queryTransactionSuccess(
+ exploreId: ExploreId,
+ transactionId: string,
+ result: any,
+ latency: number,
+ queries: DataQuery[],
+ datasourceId: string
+): ThunkResult {
+ return (dispatch, getState) => {
+ const { datasourceInstance, history, queryTransactions, scanner, scanning } = getState().explore[exploreId];
+
+ // If datasource already changed, results do not matter
+ if (datasourceInstance.meta.id !== datasourceId) {
+ return;
+ }
+
+ // Transaction might have been discarded
+ const transaction = queryTransactions.find(qt => qt.id === transactionId);
+ if (!transaction) {
+ return;
+ }
+
+ // Get query hints
+ let hints: QueryHint[];
+ if (datasourceInstance.getQueryHints) {
+ hints = datasourceInstance.getQueryHints(transaction.query, result);
+ }
+
+ // Mark transactions as complete and attach result
+ const nextQueryTransactions = queryTransactions.map(qt => {
+ if (qt.id === transactionId) {
+ return {
+ ...qt,
+ hints,
+ latency,
+ result,
+ done: true,
+ };
+ }
+ return qt;
+ });
+
+ // Side-effect: Saving history in localstorage
+ const nextHistory = updateHistory(history, datasourceId, queries);
+
+ dispatch({
+ type: ActionTypes.QueryTransactionSuccess,
+ payload: {
+ exploreId,
+ history: nextHistory,
+ queryTransactions: nextQueryTransactions,
+ },
+ });
+
+ // Keep scanning for results if this was the last scanning transaction
+ if (scanning) {
+ if (_.size(result) === 0) {
+ const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
+ if (!other) {
+ const range = scanner();
+ dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+ }
+ } else {
+ // We can stop scanning if we have a result
+ dispatch(scanStop(exploreId));
+ }
+ }
+ };
+}
+
+/**
+ * Remove query row of the given index, as well as associated query results.
+ */
+export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.RemoveQueryRow, payload: { exploreId, index } });
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Main action to run queries and dispatches sub-actions based on which result viewers are active
+ */
+export function runQueries(exploreId: ExploreId) {
+ return (dispatch, getState) => {
+ const {
+ datasourceInstance,
+ modifiedQueries,
+ showingLogs,
+ showingGraph,
+ showingTable,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ } = getState().explore[exploreId];
+
+ if (!hasNonEmptyQuery(modifiedQueries)) {
+ dispatch({ type: ActionTypes.RunQueriesEmpty, payload: { exploreId } });
+ return;
+ }
+
+ // Some datasource's query builders allow per-query interval limits,
+ // but we're using the datasource interval limit for now
+ const interval = datasourceInstance.interval;
+
+ // Keep table queries first since they need to return quickly
+ if (showingTable && supportsTable) {
+ dispatch(
+ runQueriesForType(
+ exploreId,
+ 'Table',
+ {
+ interval,
+ format: 'table',
+ instant: true,
+ valueWithRefId: true,
+ },
+ data => data[0]
+ )
+ );
+ }
+ if (showingGraph && supportsGraph) {
+ dispatch(
+ runQueriesForType(
+ exploreId,
+ 'Graph',
+ {
+ interval,
+ format: 'time_series',
+ instant: false,
+ },
+ makeTimeSeriesList
+ )
+ );
+ }
+ if (showingLogs && supportsLogs) {
+ dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
+ }
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Helper action to build a query transaction object and handing the query to the datasource.
+ * @param exploreId Explore area
+ * @param resultType Result viewer that will be associated with this query result
+ * @param queryOptions Query options as required by the datasource's `query()` function.
+ * @param resultGetter Optional result extractor, e.g., if the result is a list and you only need the first element.
+ */
+function runQueriesForType(
+ exploreId: ExploreId,
+ resultType: ResultType,
+ queryOptions: QueryOptions,
+ resultGetter?: any
+) {
+ return async (dispatch, getState) => {
+ const {
+ datasourceInstance,
+ eventBridge,
+ modifiedQueries: queries,
+ queryIntervals,
+ range,
+ scanning,
+ } = getState().explore[exploreId];
+ const datasourceId = datasourceInstance.meta.id;
+
+ // Run all queries concurrently
+ queries.forEach(async (query, rowIndex) => {
+ const transaction = buildQueryTransaction(
+ query,
+ rowIndex,
+ resultType,
+ queryOptions,
+ range,
+ queryIntervals,
+ scanning
+ );
+ dispatch(queryTransactionStart(exploreId, transaction, resultType, rowIndex));
+ try {
+ const now = Date.now();
+ const res = await datasourceInstance.query(transaction.options);
+ eventBridge.emit('data-received', res.data || []);
+ const latency = Date.now() - now;
+ const results = resultGetter ? resultGetter(res.data) : res.data;
+ dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId));
+ } catch (response) {
+ eventBridge.emit('data-error', response);
+ dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId));
+ }
+ });
+ };
+}
+
+/**
+ * Start a scan for more results using the given scanner.
+ * @param exploreId Explore area
+ * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
+ */
+export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult {
+ return dispatch => {
+ // Register the scanner
+ dispatch({ type: ActionTypes.ScanStart, payload: { exploreId, scanner } });
+ // Scanning must trigger query run, and return the new range
+ const range = scanner();
+ // Set the new range to be displayed
+ dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+ };
+}
+
+/**
+ * Stop any scanning for more results.
+ */
+export function scanStop(exploreId: ExploreId): ScanStopAction {
+ return { type: ActionTypes.ScanStop, payload: { exploreId } };
+}
+
+/**
+ * Reset queries to the given queries. Any modifications will be discarded.
+ * Use this action for clicks on query examples. Triggers a query run.
+ */
+export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult {
+ return dispatch => {
+ // Inject react keys into query objects
+ const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
+ dispatch({
+ type: ActionTypes.SetQueries,
+ payload: {
+ exploreId,
+ queries,
+ },
+ });
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Close the split view and save URL state.
+ */
+export function splitClose(): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.SplitClose });
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Open the split view and copy the left state to be the right state.
+ * The right state is automatically initialized.
+ * The copy keeps all query modifications but wipes the query results.
+ */
+export function splitOpen(): ThunkResult {
+ return (dispatch, getState) => {
+ // Clone left state to become the right state
+ const leftState = getState().explore.left;
+ const itemState = {
+ ...leftState,
+ queryTransactions: [],
+ initialQueries: leftState.modifiedQueries.slice(),
+ };
+ dispatch({ type: ActionTypes.SplitOpen, payload: { itemState } });
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Saves Explore state to URL using the `left` and `right` parameters.
+ * If split view is not active, `right` will not be set.
+ */
+export function stateSave() {
+ return (dispatch, getState) => {
+ const { left, right, split } = getState().explore;
+ const urlStates: { [index: string]: string } = {};
+ const leftUrlState: ExploreUrlState = {
+ datasource: left.datasourceInstance.name,
+ queries: left.modifiedQueries.map(clearQueryKeys),
+ range: left.range,
+ };
+ urlStates.left = serializeStateToUrlParam(leftUrlState, true);
+ if (split) {
+ const rightUrlState: ExploreUrlState = {
+ datasource: right.datasourceInstance.name,
+ queries: right.modifiedQueries.map(clearQueryKeys),
+ range: right.range,
+ };
+ urlStates.right = serializeStateToUrlParam(rightUrlState, true);
+ }
+ dispatch(updateLocation({ query: urlStates }));
+ };
+}
+
+/**
+ * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
+ */
+export function toggleGraph(exploreId: ExploreId): ThunkResult {
+ return (dispatch, getState) => {
+ dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } });
+ if (getState().explore[exploreId].showingGraph) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
+ */
+export function toggleLogs(exploreId: ExploreId): ThunkResult {
+ return (dispatch, getState) => {
+ dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } });
+ if (getState().explore[exploreId].showingLogs) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Expand/collapse the table result viewer. When collapsed, table queries won't be run.
+ */
+export function toggleTable(exploreId: ExploreId): ThunkResult {
+ return (dispatch, getState) => {
+ dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } });
+ if (getState().explore[exploreId].showingTable) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts
new file mode 100644
index 00000000000..8227a947c5b
--- /dev/null
+++ b/public/app/features/explore/state/reducers.test.ts
@@ -0,0 +1,42 @@
+import { Action, ActionTypes } from './actionTypes';
+import { itemReducer, makeExploreItemState } from './reducers';
+import { ExploreId } from 'app/types/explore';
+
+describe('Explore item reducer', () => {
+ describe('scanning', () => {
+ test('should start scanning', () => {
+ let state = makeExploreItemState();
+ const action: Action = {
+ type: ActionTypes.ScanStart,
+ payload: {
+ exploreId: ExploreId.left,
+ scanner: jest.fn(),
+ },
+ };
+ state = itemReducer(state, action);
+ expect(state.scanning).toBeTruthy();
+ expect(state.scanner).toBe(action.payload.scanner);
+ });
+ test('should stop scanning', () => {
+ let state = makeExploreItemState();
+ const start: Action = {
+ type: ActionTypes.ScanStart,
+ payload: {
+ exploreId: ExploreId.left,
+ scanner: jest.fn(),
+ },
+ };
+ state = itemReducer(state, start);
+ expect(state.scanning).toBeTruthy();
+ const action: Action = {
+ type: ActionTypes.ScanStop,
+ payload: {
+ exploreId: ExploreId.left,
+ },
+ };
+ state = itemReducer(state, action);
+ expect(state.scanning).toBeFalsy();
+ expect(state.scanner).toBeUndefined();
+ });
+ });
+});
diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts
new file mode 100644
index 00000000000..8885f972d06
--- /dev/null
+++ b/public/app/features/explore/state/reducers.ts
@@ -0,0 +1,469 @@
+import {
+ calculateResultsFromQueryTransactions,
+ generateEmptyQuery,
+ getIntervals,
+ ensureQueries,
+} from 'app/core/utils/explore';
+import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore';
+import { DataQuery } from '@grafana/ui/src/types';
+
+import { Action, ActionTypes } from './actionTypes';
+
+export const DEFAULT_RANGE = {
+ from: 'now-6h',
+ to: 'now',
+};
+
+// Millies step for helper bar charts
+const DEFAULT_GRAPH_INTERVAL = 15 * 1000;
+
+/**
+ * Returns a fresh Explore area state
+ */
+export const makeExploreItemState = (): ExploreItemState => ({
+ StartPage: undefined,
+ containerWidth: 0,
+ datasourceInstance: null,
+ datasourceError: null,
+ datasourceLoading: null,
+ datasourceMissing: false,
+ exploreDatasources: [],
+ history: [],
+ initialQueries: [],
+ initialized: false,
+ modifiedQueries: [],
+ queryTransactions: [],
+ queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL },
+ range: DEFAULT_RANGE,
+ scanning: false,
+ scanRange: null,
+ showingGraph: true,
+ showingLogs: true,
+ showingTable: true,
+ supportsGraph: null,
+ supportsLogs: null,
+ supportsTable: null,
+});
+
+/**
+ * Global Explore state that handles multiple Explore areas and the split state
+ */
+export const initialExploreState: ExploreState = {
+ split: null,
+ left: makeExploreItemState(),
+ right: makeExploreItemState(),
+};
+
+/**
+ * Reducer for an Explore area, to be used by the global Explore reducer.
+ */
+export const itemReducer = (state, action: Action): ExploreItemState => {
+ switch (action.type) {
+ case ActionTypes.AddQueryRow: {
+ const { initialQueries, modifiedQueries, queryTransactions } = state;
+ const { index, query } = action.payload;
+
+ // Add new query row after given index, keep modifications of existing rows
+ const nextModifiedQueries = [
+ ...modifiedQueries.slice(0, index + 1),
+ { ...query },
+ ...initialQueries.slice(index + 1),
+ ];
+
+ // Add to initialQueries, which will cause a new row to be rendered
+ const nextQueries = [...initialQueries.slice(0, index + 1), { ...query }, ...initialQueries.slice(index + 1)];
+
+ // Ongoing transactions need to update their row indices
+ const nextQueryTransactions = queryTransactions.map(qt => {
+ if (qt.rowIndex > index) {
+ return {
+ ...qt,
+ rowIndex: qt.rowIndex + 1,
+ };
+ }
+ return qt;
+ });
+
+ return {
+ ...state,
+ initialQueries: nextQueries,
+ logsHighlighterExpressions: undefined,
+ modifiedQueries: nextModifiedQueries,
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.ChangeQuery: {
+ const { initialQueries, queryTransactions } = state;
+ let { modifiedQueries } = state;
+ const { query, index, override } = action.payload;
+
+ // Fast path: only change modifiedQueries to not trigger an update
+ modifiedQueries[index] = query;
+ if (!override) {
+ return {
+ ...state,
+ modifiedQueries,
+ };
+ }
+
+ // Override path: queries are completely reset
+ const nextQuery: DataQuery = {
+ ...query,
+ ...generateEmptyQuery(index),
+ };
+ const nextQueries = [...initialQueries];
+ nextQueries[index] = nextQuery;
+ modifiedQueries = [...nextQueries];
+
+ // Discard ongoing transaction related to row query
+ const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+ return {
+ ...state,
+ initialQueries: nextQueries,
+ modifiedQueries: nextQueries.slice(),
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.ChangeSize: {
+ const { range, datasourceInstance } = state;
+ let interval = '1s';
+ if (datasourceInstance && datasourceInstance.interval) {
+ interval = datasourceInstance.interval;
+ }
+ const containerWidth = action.payload.width;
+ const queryIntervals = getIntervals(range, interval, containerWidth);
+ return { ...state, containerWidth, queryIntervals };
+ }
+
+ case ActionTypes.ChangeTime: {
+ return {
+ ...state,
+ range: action.payload.range,
+ };
+ }
+
+ case ActionTypes.ClearQueries: {
+ const queries = ensureQueries();
+ return {
+ ...state,
+ initialQueries: queries.slice(),
+ modifiedQueries: queries.slice(),
+ queryTransactions: [],
+ showingStartPage: Boolean(state.StartPage),
+ };
+ }
+
+ case ActionTypes.HighlightLogsExpression: {
+ const { expressions } = action.payload;
+ return { ...state, logsHighlighterExpressions: expressions };
+ }
+
+ case ActionTypes.InitializeExplore: {
+ const { containerWidth, datasource, eventBridge, exploreDatasources, queries, range } = action.payload;
+ return {
+ ...state,
+ containerWidth,
+ eventBridge,
+ exploreDatasources,
+ range,
+ initialDatasource: datasource,
+ initialQueries: queries,
+ initialized: true,
+ modifiedQueries: queries.slice(),
+ };
+ }
+
+ case ActionTypes.LoadDatasourceFailure: {
+ return { ...state, datasourceError: action.payload.error, datasourceLoading: false };
+ }
+
+ case ActionTypes.LoadDatasourceMissing: {
+ return { ...state, datasourceMissing: true, datasourceLoading: false };
+ }
+
+ case ActionTypes.LoadDatasourcePending: {
+ return { ...state, datasourceLoading: true, requestedDatasourceName: action.payload.datasourceName };
+ }
+
+ case ActionTypes.LoadDatasourceSuccess: {
+ const { containerWidth, range } = state;
+ const {
+ StartPage,
+ datasourceInstance,
+ history,
+ initialDatasource,
+ initialQueries,
+ showingStartPage,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ } = action.payload;
+ const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth);
+
+ return {
+ ...state,
+ queryIntervals,
+ StartPage,
+ datasourceInstance,
+ history,
+ initialDatasource,
+ initialQueries,
+ showingStartPage,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ datasourceLoading: false,
+ datasourceMissing: false,
+ datasourceError: null,
+ logsHighlighterExpressions: undefined,
+ modifiedQueries: initialQueries.slice(),
+ queryTransactions: [],
+ };
+ }
+
+ case ActionTypes.ModifyQueries: {
+ const { initialQueries, modifiedQueries, queryTransactions } = state;
+ const { modification, index, modifier } = action.payload as any;
+ let nextQueries: DataQuery[];
+ let nextQueryTransactions;
+ if (index === undefined) {
+ // Modify all queries
+ nextQueries = initialQueries.map((query, i) => ({
+ ...modifier(modifiedQueries[i], modification),
+ ...generateEmptyQuery(i),
+ }));
+ // Discard all ongoing transactions
+ nextQueryTransactions = [];
+ } else {
+ // Modify query only at index
+ nextQueries = initialQueries.map((query, i) => {
+ // Synchronize all queries with local query cache to ensure consistency
+ // TODO still needed?
+ return i === index
+ ? {
+ ...modifier(modifiedQueries[i], modification),
+ ...generateEmptyQuery(i),
+ }
+ : query;
+ });
+ nextQueryTransactions = queryTransactions
+ // Consume the hint corresponding to the action
+ .map(qt => {
+ if (qt.hints != null && qt.rowIndex === index) {
+ qt.hints = qt.hints.filter(hint => hint.fix.action !== modification);
+ }
+ return qt;
+ })
+ // Preserve previous row query transaction to keep results visible if next query is incomplete
+ .filter(qt => modification.preventSubmit || qt.rowIndex !== index);
+ }
+ return {
+ ...state,
+ initialQueries: nextQueries,
+ modifiedQueries: nextQueries.slice(),
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.QueryTransactionFailure: {
+ const { queryTransactions } = action.payload;
+ return {
+ ...state,
+ queryTransactions,
+ showingStartPage: false,
+ };
+ }
+
+ case ActionTypes.QueryTransactionStart: {
+ const { datasourceInstance, queryIntervals, queryTransactions } = state;
+ const { resultType, rowIndex, transaction } = action.payload;
+ // Discarding existing transactions of same type
+ const remainingTransactions = queryTransactions.filter(
+ qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
+ );
+
+ // Append new transaction
+ const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction];
+
+ const results = calculateResultsFromQueryTransactions(
+ nextQueryTransactions,
+ datasourceInstance,
+ queryIntervals.intervalMs
+ );
+
+ return {
+ ...state,
+ ...results,
+ queryTransactions: nextQueryTransactions,
+ showingStartPage: false,
+ };
+ }
+
+ case ActionTypes.QueryTransactionSuccess: {
+ const { datasourceInstance, queryIntervals } = state;
+ const { history, queryTransactions } = action.payload;
+ const results = calculateResultsFromQueryTransactions(
+ queryTransactions,
+ datasourceInstance,
+ queryIntervals.intervalMs
+ );
+
+ return {
+ ...state,
+ ...results,
+ history,
+ queryTransactions,
+ showingStartPage: false,
+ };
+ }
+
+ case ActionTypes.RemoveQueryRow: {
+ const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state;
+ let { modifiedQueries } = state;
+ const { index } = action.payload;
+
+ modifiedQueries = [...modifiedQueries.slice(0, index), ...modifiedQueries.slice(index + 1)];
+
+ if (initialQueries.length <= 1) {
+ return state;
+ }
+
+ const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
+
+ // Discard transactions related to row query
+ const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+ const results = calculateResultsFromQueryTransactions(
+ nextQueryTransactions,
+ datasourceInstance,
+ queryIntervals.intervalMs
+ );
+
+ return {
+ ...state,
+ ...results,
+ initialQueries: nextQueries,
+ logsHighlighterExpressions: undefined,
+ modifiedQueries: nextQueries.slice(),
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.RunQueriesEmpty: {
+ return { ...state, queryTransactions: [] };
+ }
+
+ case ActionTypes.ScanRange: {
+ return { ...state, scanRange: action.payload.range };
+ }
+
+ case ActionTypes.ScanStart: {
+ return { ...state, scanning: true, scanner: action.payload.scanner };
+ }
+
+ case ActionTypes.ScanStop: {
+ const { queryTransactions } = state;
+ const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
+ return {
+ ...state,
+ queryTransactions: nextQueryTransactions,
+ scanning: false,
+ scanRange: undefined,
+ scanner: undefined,
+ };
+ }
+
+ case ActionTypes.SetQueries: {
+ const { queries } = action.payload;
+ return { ...state, initialQueries: queries.slice(), modifiedQueries: queries.slice() };
+ }
+
+ case ActionTypes.ToggleGraph: {
+ const showingGraph = !state.showingGraph;
+ let nextQueryTransactions = state.queryTransactions;
+ if (!showingGraph) {
+ // Discard transactions related to Graph query
+ nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph');
+ }
+ return { ...state, queryTransactions: nextQueryTransactions, showingGraph };
+ }
+
+ case ActionTypes.ToggleLogs: {
+ const showingLogs = !state.showingLogs;
+ let nextQueryTransactions = state.queryTransactions;
+ if (!showingLogs) {
+ // Discard transactions related to Logs query
+ nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs');
+ }
+ return { ...state, queryTransactions: nextQueryTransactions, showingLogs };
+ }
+
+ case ActionTypes.ToggleTable: {
+ const showingTable = !state.showingTable;
+ if (showingTable) {
+ return { ...state, showingTable, queryTransactions: state.queryTransactions };
+ }
+
+ // Toggle off needs discarding of table queries and results
+ const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
+ const results = calculateResultsFromQueryTransactions(
+ nextQueryTransactions,
+ state.datasourceInstance,
+ state.queryIntervals.intervalMs
+ );
+
+ return { ...state, ...results, queryTransactions: nextQueryTransactions, showingTable };
+ }
+ }
+
+ return state;
+};
+
+/**
+ * Global Explore reducer that handles multiple Explore areas (left and right).
+ * Actions that have an `exploreId` get routed to the ExploreItemReducer.
+ */
+export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
+ switch (action.type) {
+ case ActionTypes.SplitClose: {
+ return {
+ ...state,
+ split: false,
+ };
+ }
+
+ case ActionTypes.SplitOpen: {
+ return {
+ ...state,
+ split: true,
+ right: action.payload.itemState,
+ };
+ }
+
+ case ActionTypes.InitializeExploreSplit: {
+ return {
+ ...state,
+ split: true,
+ };
+ }
+ }
+
+ if (action.payload) {
+ const { exploreId } = action.payload as any;
+ if (exploreId !== undefined) {
+ const exploreItemState = state[exploreId];
+ return {
+ ...state,
+ [exploreId]: itemReducer(exploreItemState, action),
+ };
+ }
+ }
+
+ return state;
+};
+
+export default {
+ explore: exploreReducer,
+};
diff --git a/public/app/features/dashboard/create_folder_ctrl.ts b/public/app/features/folders/CreateFolderCtrl.ts
similarity index 96%
rename from public/app/features/dashboard/create_folder_ctrl.ts
rename to public/app/features/folders/CreateFolderCtrl.ts
index 99b2e8d4853..db70c2a18a2 100644
--- a/public/app/features/dashboard/create_folder_ctrl.ts
+++ b/public/app/features/folders/CreateFolderCtrl.ts
@@ -1,7 +1,7 @@
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
-export class CreateFolderCtrl {
+export default class CreateFolderCtrl {
title = '';
navModel: any;
titleTouched = false;
@@ -38,3 +38,4 @@ export class CreateFolderCtrl {
});
}
}
+
diff --git a/public/app/features/dashboard/folder_dashboards_ctrl.ts b/public/app/features/folders/FolderDashboardsCtrl.ts
similarity index 84%
rename from public/app/features/dashboard/folder_dashboards_ctrl.ts
rename to public/app/features/folders/FolderDashboardsCtrl.ts
index 05cc420c489..95ff355937b 100644
--- a/public/app/features/dashboard/folder_dashboards_ctrl.ts
+++ b/public/app/features/folders/FolderDashboardsCtrl.ts
@@ -1,7 +1,7 @@
-import { FolderPageLoader } from './folder_page_loader';
+import { FolderPageLoader } from './services/FolderPageLoader';
import locationUtil from 'app/core/utils/location_util';
-export class FolderDashboardsCtrl {
+export default class FolderDashboardsCtrl {
navModel: any;
folderId: number;
uid: string;
@@ -23,3 +23,4 @@ export class FolderDashboardsCtrl {
}
}
}
+
diff --git a/public/app/features/dashboard/partials/create_folder.html b/public/app/features/folders/partials/create_folder.html
similarity index 100%
rename from public/app/features/dashboard/partials/create_folder.html
rename to public/app/features/folders/partials/create_folder.html
diff --git a/public/app/features/dashboard/partials/folder_dashboards.html b/public/app/features/folders/partials/folder_dashboards.html
similarity index 100%
rename from public/app/features/dashboard/partials/folder_dashboards.html
rename to public/app/features/folders/partials/folder_dashboards.html
diff --git a/public/app/features/dashboard/folder_page_loader.ts b/public/app/features/folders/services/FolderPageLoader.ts
similarity index 100%
rename from public/app/features/dashboard/folder_page_loader.ts
rename to public/app/features/folders/services/FolderPageLoader.ts
diff --git a/public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts b/public/app/features/manage-dashboards/DashboardImportCtrl.test.ts
similarity index 95%
rename from public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts
rename to public/app/features/manage-dashboards/DashboardImportCtrl.test.ts
index bcde009cb3a..c9037c0a62d 100644
--- a/public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts
+++ b/public/app/features/manage-dashboards/DashboardImportCtrl.test.ts
@@ -1,5 +1,5 @@
-import { DashboardImportCtrl } from '../dashboard_import_ctrl';
-import config from '../../../core/config';
+import { DashboardImportCtrl } from './DashboardImportCtrl';
+import config from 'app/core/config';
describe('DashboardImportCtrl', () => {
const ctx: any = {};
diff --git a/public/app/features/dashboard/dashboard_import_ctrl.ts b/public/app/features/manage-dashboards/DashboardImportCtrl.ts
similarity index 99%
rename from public/app/features/dashboard/dashboard_import_ctrl.ts
rename to public/app/features/manage-dashboards/DashboardImportCtrl.ts
index 455fa682edd..d2c6584d13d 100644
--- a/public/app/features/dashboard/dashboard_import_ctrl.ts
+++ b/public/app/features/manage-dashboards/DashboardImportCtrl.ts
@@ -232,3 +232,5 @@ export class DashboardImportCtrl {
this.gnetInfo = '';
}
}
+
+export default DashboardImportCtrl;
diff --git a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts b/public/app/features/manage-dashboards/components/MoveToFolderModal/MoveToFolderCtrl.ts
similarity index 93%
rename from public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts
rename to public/app/features/manage-dashboards/components/MoveToFolderModal/MoveToFolderCtrl.ts
index 075583b971b..c183f38d92a 100644
--- a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts
+++ b/public/app/features/manage-dashboards/components/MoveToFolderModal/MoveToFolderCtrl.ts
@@ -46,7 +46,7 @@ export class MoveToFolderCtrl {
export function moveToFolderModal() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/move_to_folder_modal/move_to_folder.html',
+ templateUrl: 'public/app/features/manage-dashboards/components/MoveToFolderModal/template.html',
controller: MoveToFolderCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/manage-dashboards/components/MoveToFolderModal/index.ts b/public/app/features/manage-dashboards/components/MoveToFolderModal/index.ts
new file mode 100644
index 00000000000..df0553aedb9
--- /dev/null
+++ b/public/app/features/manage-dashboards/components/MoveToFolderModal/index.ts
@@ -0,0 +1 @@
+export { MoveToFolderCtrl } from './MoveToFolderCtrl';
diff --git a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.html b/public/app/features/manage-dashboards/components/MoveToFolderModal/template.html
similarity index 100%
rename from public/app/features/dashboard/move_to_folder_modal/move_to_folder.html
rename to public/app/features/manage-dashboards/components/MoveToFolderModal/template.html
diff --git a/public/app/features/manage-dashboards/components/UploadDashboard/index.ts b/public/app/features/manage-dashboards/components/UploadDashboard/index.ts
new file mode 100644
index 00000000000..828b4f76982
--- /dev/null
+++ b/public/app/features/manage-dashboards/components/UploadDashboard/index.ts
@@ -0,0 +1 @@
+export { uploadDashboardDirective } from './uploadDashboardDirective';
diff --git a/public/app/features/dashboard/upload.ts b/public/app/features/manage-dashboards/components/UploadDashboard/uploadDashboardDirective.ts
similarity index 96%
rename from public/app/features/dashboard/upload.ts
rename to public/app/features/manage-dashboards/components/UploadDashboard/uploadDashboardDirective.ts
index ec4ad9a03cb..0c38a1247f1 100644
--- a/public/app/features/dashboard/upload.ts
+++ b/public/app/features/manage-dashboards/components/UploadDashboard/uploadDashboardDirective.ts
@@ -11,7 +11,7 @@ const template = `
`;
/** @ngInject */
-function uploadDashboardDirective(timer, $location) {
+export function uploadDashboardDirective(timer, $location) {
return {
restrict: 'E',
template: template,
diff --git a/public/app/features/manage-dashboards/index.ts b/public/app/features/manage-dashboards/index.ts
index 046740904e1..9d7c2bbc811 100644
--- a/public/app/features/manage-dashboards/index.ts
+++ b/public/app/features/manage-dashboards/index.ts
@@ -1,7 +1,15 @@
-import coreModule from 'app/core/core_module';
+// Services
+export { ValidationSrv } from './services/ValidationSrv';
+// Components
+export * from './components/MoveToFolderModal';
+export * from './components/UploadDashboard';
+
+// Controllers
import { DashboardListCtrl } from './DashboardListCtrl';
import { SnapshotListCtrl } from './SnapshotListCtrl';
+import coreModule from 'app/core/core_module';
+
coreModule.controller('DashboardListCtrl', DashboardListCtrl);
coreModule.controller('SnapshotListCtrl', SnapshotListCtrl);
diff --git a/public/app/features/dashboard/partials/dashboard_import.html b/public/app/features/manage-dashboards/partials/dashboard_import.html
similarity index 100%
rename from public/app/features/dashboard/partials/dashboard_import.html
rename to public/app/features/manage-dashboards/partials/dashboard_import.html
diff --git a/public/app/features/dashboard/validation_srv.ts b/public/app/features/manage-dashboards/services/ValidationSrv.ts
similarity index 100%
rename from public/app/features/dashboard/validation_srv.ts
rename to public/app/features/manage-dashboards/services/ValidationSrv.ts
diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx
index ea0e5c09388..ceeaa6c2ad5 100644
--- a/public/app/features/org/OrgDetailsPage.test.tsx
+++ b/public/app/features/org/OrgDetailsPage.test.tsx
@@ -6,7 +6,14 @@ import { NavModel, Organization } from '../../types';
const setup = (propOverrides?: object) => {
const props: Props = {
organization: {} as Organization,
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Org details'
+ }
+ } as NavModel,
loadOrganization: jest.fn(),
setOrganizationName: jest.fn(),
updateOrganization: jest.fn(),
diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx
index b011901aa71..ee644f0006f 100644
--- a/public/app/features/org/OrgDetailsPage.tsx
+++ b/public/app/features/org/OrgDetailsPage.tsx
@@ -1,13 +1,12 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
-import PageHeader from '../../core/components/PageHeader/PageHeader';
-import PageLoader from '../../core/components/PageLoader/PageLoader';
+import Page from 'app/core/components/Page/Page';
import OrgProfile from './OrgProfile';
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
import { loadOrganization, setOrganizationName, updateOrganization } from './state/actions';
import { NavModel, Organization, StoreState } from 'app/types';
-import { getNavModel } from '../../core/selectors/navModel';
+import { getNavModel } from 'app/core/selectors/navModel';
export interface Props {
navModel: NavModel;
@@ -35,22 +34,22 @@ export class OrgDetailsPage extends PureComponent {
const isLoading = Object.keys(organization).length === 0;
return (
-
-
-
- {isLoading &&
}
- {!isLoading && (
-
-
this.onOrgNameChange(name)}
- onSubmit={this.onUpdateOrganization}
- orgName={organization.name}
- />
-
+
+
+
+ {!isLoading && (
+
+ this.onOrgNameChange(name)}
+ onSubmit={this.onUpdateOrganization}
+ orgName={organization.name}
+ />
+
+
+ )}
- )}
-
-
+
+
);
}
}
diff --git a/public/app/features/org/OrgProfile.tsx b/public/app/features/org/OrgProfile.tsx
index 22dfa7bb1ce..d00f89bcff3 100644
--- a/public/app/features/org/OrgProfile.tsx
+++ b/public/app/features/org/OrgProfile.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
export interface Props {
orgName: string;
@@ -6,7 +6,7 @@ export interface Props {
onOrgNameChange: (orgName: string) => void;
}
-const OrgProfile: SFC
= ({ onSubmit, onOrgNameChange, orgName }) => {
+const OrgProfile: FC = ({ onSubmit, onOrgNameChange, orgName }) => {
return (
Organization profile
diff --git a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
index 582d626d315..9e13a73901e 100644
--- a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
+++ b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
@@ -1,38 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
-
+
+
`;
exports[`Render should render organization and preferences 1`] = `
-
-
-
+
+
`;
diff --git a/public/app/features/panel/all.ts b/public/app/features/panel/all.ts
index d461b491897..8bc2822a77c 100644
--- a/public/app/features/panel/all.ts
+++ b/public/app/features/panel/all.ts
@@ -4,3 +4,5 @@ import './solo_panel_ctrl';
import './query_ctrl';
import './panel_editor_tab';
import './query_editor_row';
+import './repeat_option';
+import './panellinks/module';
diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts
index 5557b477b8f..0b3bbc3080f 100644
--- a/public/app/features/panel/metrics_panel_ctrl.ts
+++ b/public/app/features/panel/metrics_panel_ctrl.ts
@@ -1,18 +1,18 @@
import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
-import config from 'app/core/config';
import { PanelCtrl } from 'app/features/panel/panel_ctrl';
import { getExploreUrl } from 'app/core/utils/explore';
import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/utils/panel';
+import { ContextSrv } from 'app/core/services/context_srv';
class MetricsPanelCtrl extends PanelCtrl {
scope: any;
datasource: any;
$q: any;
$timeout: any;
- contextSrv: any;
+ contextSrv: ContextSrv;
datasourceSrv: any;
timeSrv: any;
templateSrv: any;
@@ -231,7 +231,7 @@ class MetricsPanelCtrl extends PanelCtrl {
getAdditionalMenuItems() {
const items = [];
- if (config.exploreEnabled && this.contextSrv.isEditor && this.datasource) {
+ if (this.contextSrv.hasAccessToExplore() && this.datasource) {
items.push({
text: 'Explore',
click: 'ctrl.explore();',
diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts
deleted file mode 100644
index 74418484e3a..00000000000
--- a/public/app/features/panel/metrics_tab.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// Services & utils
-import coreModule from 'app/core/core_module';
-import { Emitter } from 'app/core/utils/emitter';
-
-// Types
-import { DashboardModel } from '../dashboard/dashboard_model';
-import { PanelModel } from '../dashboard/panel_model';
-import { DataQuery } from 'app/types';
-
-export interface AngularQueryComponentScope {
- panel: PanelModel;
- dashboard: DashboardModel;
- events: Emitter;
- refresh: () => void;
- render: () => void;
- removeQuery: (query: DataQuery) => void;
- addQuery: (query?: DataQuery) => void;
- moveQuery: (query: DataQuery, direction: number) => void;
-}
-
-/** @ngInject */
-export function metricsTabDirective() {
- 'use strict';
- return {
- restrict: 'E',
- scope: true,
- templateUrl: 'public/app/features/panel/partials/metrics_tab.html',
- };
-}
-
-coreModule.directive('metricsTab', metricsTabDirective);
diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts
index f68423315d7..2f1ef72cedd 100644
--- a/public/app/features/panel/panel_ctrl.ts
+++ b/public/app/features/panel/panel_ctrl.ts
@@ -290,17 +290,4 @@ export class PanelCtrl {
html += '
';
return sanitize(html);
}
-
- openInspector() {
- const modalScope = this.$scope.$new();
- modalScope.panel = this.panel;
- modalScope.dashboard = this.dashboard;
- modalScope.panelInfoHtml = this.getInfoContent({ mode: 'inspector' });
-
- modalScope.inspector = $.extend(true, {}, this.inspector);
- this.publishAppEvent('show-modal', {
- src: 'public/app/features/dashboard/partials/inspector.html',
- scope: modalScope,
- });
- }
}
diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts
index f503aa4386d..1fd0b129720 100644
--- a/public/app/features/panel/panel_directive.ts
+++ b/public/app/features/panel/panel_directive.ts
@@ -192,11 +192,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
scope.$watchGroup(['ctrl.error', 'ctrl.panel.description'], updatePanelCornerInfo);
scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo);
- cornerInfoElem.on('click', () => {
- infoDrop.close();
- scope.$apply(ctrl.openInspector.bind(ctrl));
- });
-
elem.on('mouseenter', mouseEnter);
elem.on('mouseleave', mouseLeave);
diff --git a/public/app/features/dashboard/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts
similarity index 100%
rename from public/app/features/dashboard/panellinks/link_srv.ts
rename to public/app/features/panel/panellinks/link_srv.ts
diff --git a/public/app/features/dashboard/panellinks/module.html b/public/app/features/panel/panellinks/module.html
similarity index 100%
rename from public/app/features/dashboard/panellinks/module.html
rename to public/app/features/panel/panellinks/module.html
diff --git a/public/app/features/dashboard/panellinks/module.ts b/public/app/features/panel/panellinks/module.ts
similarity index 100%
rename from public/app/features/dashboard/panellinks/module.ts
rename to public/app/features/panel/panellinks/module.ts
diff --git a/public/app/features/dashboard/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts
similarity index 100%
rename from public/app/features/dashboard/panellinks/specs/link_srv.test.ts
rename to public/app/features/panel/panellinks/specs/link_srv.test.ts
diff --git a/public/app/features/panel/partials/metrics_tab.html b/public/app/features/panel/partials/metrics_tab.html
deleted file mode 100644
index 5e9f23ba2ef..00000000000
--- a/public/app/features/panel/partials/metrics_tab.html
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/app/features/panel/partials/query_editor_row.html b/public/app/features/panel/partials/query_editor_row.html
index 34a86813d1d..fc2e3602630 100644
--- a/public/app/features/panel/partials/query_editor_row.html
+++ b/public/app/features/panel/partials/query_editor_row.html
@@ -1,44 +1,2 @@
-
diff --git a/public/app/features/panel/query_editor_row.ts b/public/app/features/panel/query_editor_row.ts
index a44c1e8be6d..fa25ce832be 100644
--- a/public/app/features/panel/query_editor_row.ts
+++ b/public/app/features/panel/query_editor_row.ts
@@ -3,89 +3,26 @@ import angular from 'angular';
const module = angular.module('grafana.directives');
export class QueryRowCtrl {
- collapsedText: string;
- canCollapse: boolean;
- getCollapsedText: any;
target: any;
queryCtrl: any;
panelCtrl: any;
panel: any;
- collapsed: any;
- hideEditorRowActions: boolean;
+ hasTextEditMode: boolean;
constructor() {
this.panelCtrl = this.queryCtrl.panelCtrl;
this.target = this.queryCtrl.target;
this.panel = this.panelCtrl.panel;
- this.hideEditorRowActions = this.panelCtrl.hideEditorRowActions;
- if (!this.target.refId) {
- this.target.refId = this.panel.getNextQueryLetter();
+ if (this.hasTextEditMode) {
+ // expose this function to react parent component
+ this.panelCtrl.toggleEditorMode = this.queryCtrl.toggleEditorMode.bind(this.queryCtrl);
}
- this.toggleCollapse(true);
- if (this.target.isNew) {
- delete this.target.isNew;
- this.toggleCollapse(false);
+ if (this.queryCtrl.getCollapsedText) {
+ // expose this function to react parent component
+ this.panelCtrl.getCollapsedText = this.queryCtrl.getCollapsedText.bind(this.queryCtrl);
}
-
- if (this.panel.targets.length < 4) {
- this.collapsed = false;
- }
- }
-
- toggleHideQuery() {
- this.target.hide = !this.target.hide;
- this.panelCtrl.refresh();
- }
-
- toggleCollapse(init) {
- if (!this.canCollapse) {
- return;
- }
-
- if (!this.panelCtrl.__collapsedQueryCache) {
- this.panelCtrl.__collapsedQueryCache = {};
- }
-
- if (init) {
- this.collapsed = this.panelCtrl.__collapsedQueryCache[this.target.refId] !== false;
- } else {
- this.collapsed = !this.collapsed;
- this.panelCtrl.__collapsedQueryCache[this.target.refId] = this.collapsed;
- }
-
- try {
- this.collapsedText = this.queryCtrl.getCollapsedText();
- } catch (e) {
- const err = e.message || e.toString();
- this.collapsedText = 'Error: ' + err;
- }
- }
-
- toggleEditorMode() {
- if (this.canCollapse && this.collapsed) {
- this.collapsed = false;
- }
-
- this.queryCtrl.toggleEditorMode();
- }
-
- removeQuery() {
- if (this.panelCtrl.__collapsedQueryCache) {
- delete this.panelCtrl.__collapsedQueryCache[this.target.refId];
- }
-
- this.panelCtrl.removeQuery(this.target);
- }
-
- duplicateQuery() {
- const clone = angular.copy(this.target);
- this.panelCtrl.addQuery(clone);
- }
-
- moveQuery(direction) {
- this.panelCtrl.moveQuery(this.target, direction);
}
}
diff --git a/public/app/features/dashboard/repeat_option/repeat_option.ts b/public/app/features/panel/repeat_option.ts
similarity index 100%
rename from public/app/features/dashboard/repeat_option/repeat_option.ts
rename to public/app/features/panel/repeat_option.ts
diff --git a/public/app/features/panel/specs/metrics_panel_ctrl.test.ts b/public/app/features/panel/specs/metrics_panel_ctrl.test.ts
index 913a2461fd0..8b9607d39ad 100644
--- a/public/app/features/panel/specs/metrics_panel_ctrl.test.ts
+++ b/public/app/features/panel/specs/metrics_panel_ctrl.test.ts
@@ -1,7 +1,6 @@
jest.mock('app/core/core', () => ({}));
jest.mock('app/core/config', () => {
return {
- exploreEnabled: true,
panels: {
test: {
id: 'test',
@@ -16,46 +15,45 @@ import { PanelModel } from 'app/features/dashboard/panel_model';
import { MetricsPanelCtrl } from '../metrics_panel_ctrl';
describe('MetricsPanelCtrl', () => {
- let ctrl;
-
- beforeEach(() => {
- ctrl = setupController();
- });
-
describe('when getting additional menu items', () => {
- let additionalItems;
-
- describe('and has no datasource set', () => {
- beforeEach(() => {
- additionalItems = ctrl.getAdditionalMenuItems();
- });
-
+ describe('and has no datasource set but user has access to explore', () => {
it('should not return any items', () => {
- expect(additionalItems.length).toBe(0);
+ const ctrl = setupController({ hasAccessToExplore: true });
+
+ expect(ctrl.getAdditionalMenuItems().length).toBe(0);
});
});
- describe('and has datasource set that supports explore and user has powers', () => {
- beforeEach(() => {
- ctrl.contextSrv = { isEditor: true };
- ctrl.datasource = { meta: { explore: true } };
- additionalItems = ctrl.getAdditionalMenuItems();
- });
-
+ describe('and has datasource set that supports explore and user does not have access to explore', () => {
it('should not return any items', () => {
- expect(additionalItems.length).toBe(1);
+ const ctrl = setupController({ hasAccessToExplore: false });
+ ctrl.datasource = { meta: { explore: true } };
+
+ expect(ctrl.getAdditionalMenuItems().length).toBe(0);
+ });
+ });
+
+ describe('and has datasource set that supports explore and user has access to explore', () => {
+ it('should return one item', () => {
+ const ctrl = setupController({ hasAccessToExplore: true });
+ ctrl.datasource = { meta: { explore: true } };
+
+ expect(ctrl.getAdditionalMenuItems().length).toBe(1);
});
});
});
});
-function setupController() {
+function setupController({ hasAccessToExplore } = { hasAccessToExplore: false }) {
const injectorStub = {
get: type => {
switch (type) {
case '$q': {
return q;
}
+ case 'contextSrv': {
+ return { hasAccessToExplore: () => hasAccessToExplore };
+ }
default: {
return jest.fn();
}
diff --git a/public/app/features/playlist/playlist_srv.ts b/public/app/features/playlist/playlist_srv.ts
index 9d3b635a1e5..0a80ce0cdf0 100644
--- a/public/app/features/playlist/playlist_srv.ts
+++ b/public/app/features/playlist/playlist_srv.ts
@@ -4,12 +4,13 @@ import appEvents from 'app/core/app_events';
import _ from 'lodash';
import { toUrlParams } from 'app/core/utils/url';
-class PlaylistSrv {
+export class PlaylistSrv {
private cancelPromise: any;
- private dashboards: any;
+ private dashboards: Array<{ uri: string }>;
private index: number;
- private interval: any;
+ private interval: number;
private startUrl: string;
+ private numberOfLoops = 0;
isPlaying: boolean;
/** @ngInject */
@@ -20,8 +21,15 @@ class PlaylistSrv {
const playedAllDashboards = this.index > this.dashboards.length - 1;
if (playedAllDashboards) {
- window.location.href = this.startUrl;
- return;
+ this.numberOfLoops++;
+
+ // This does full reload of the playlist to keep memory in check due to existing leaks but at the same time
+ // we do not want page to flicker after each full loop.
+ if (this.numberOfLoops >= 3) {
+ window.location.href = this.startUrl;
+ return;
+ }
+ this.index = 0;
}
const dash = this.dashboards[this.index];
@@ -46,8 +54,8 @@ class PlaylistSrv {
this.index = 0;
this.isPlaying = true;
- this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
- this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
+ return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
+ return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
this.dashboards = dashboards;
this.interval = kbn.interval_to_ms(playlist.interval);
this.next();
diff --git a/public/app/features/playlist/specs/playlist_srv.test.ts b/public/app/features/playlist/specs/playlist_srv.test.ts
new file mode 100644
index 00000000000..e6b7671c964
--- /dev/null
+++ b/public/app/features/playlist/specs/playlist_srv.test.ts
@@ -0,0 +1,103 @@
+import { PlaylistSrv } from '../playlist_srv';
+
+const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }];
+
+const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance
}] => {
+ const mockBackendSrv = {
+ get: jest.fn(url => {
+ switch (url) {
+ case '/api/playlists/1':
+ return Promise.resolve({ interval: '1s' });
+ case '/api/playlists/1/dashboards':
+ return Promise.resolve(dashboards);
+ default:
+ throw new Error(`Unexpected url=${url}`);
+ }
+ }),
+ };
+
+ const mockLocation = {
+ url: jest.fn(),
+ search: () => ({}),
+ };
+
+ const mockTimeout = jest.fn();
+ (mockTimeout as any).cancel = jest.fn();
+
+ return [new PlaylistSrv(mockLocation, mockTimeout, mockBackendSrv), mockLocation];
+};
+
+const mockWindowLocation = (): [jest.MockInstance, () => void] => {
+ const oldLocation = window.location;
+ const hrefMock = jest.fn();
+
+ // JSDom defines window in a way that you cannot tamper with location so this seems to be the only way to change it.
+ // https://github.com/facebook/jest/issues/5124#issuecomment-446659510
+ delete window.location;
+ window.location = {} as any;
+
+ // Only mocking href as that is all this test needs, but otherwise there is lots of things missing, so keep that
+ // in mind if this is reused.
+ Object.defineProperty(window.location, 'href', {
+ set: hrefMock,
+ get: hrefMock,
+ });
+ const unmock = () => {
+ window.location = oldLocation;
+ };
+ return [hrefMock, unmock];
+};
+
+describe('PlaylistSrv', () => {
+ let srv: PlaylistSrv;
+ let mockLocationService: { url: jest.MockInstance };
+ let hrefMock: jest.MockInstance;
+ let unmockLocation: () => void;
+ const initialUrl = 'http://localhost/playlist';
+
+ beforeEach(() => {
+ [srv, mockLocationService] = createPlaylistSrv();
+ [hrefMock, unmockLocation] = mockWindowLocation();
+
+ // This will be cached in the srv when start() is called
+ hrefMock.mockReturnValue(initialUrl);
+ });
+
+ afterEach(() => {
+ unmockLocation();
+ });
+
+ it('runs all dashboards in cycle and reloads page after 3 cycles', async () => {
+ await srv.start(1);
+
+ for (let i = 0; i < 6; i++) {
+ expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
+ srv.next();
+ }
+
+ expect(hrefMock).toHaveBeenCalledTimes(2);
+ expect(hrefMock).toHaveBeenLastCalledWith(initialUrl);
+ });
+
+ it('keeps the refresh counter value after restarting', async () => {
+ await srv.start(1);
+
+ // 1 complete loop
+ for (let i = 0; i < 3; i++) {
+ expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
+ srv.next();
+ }
+
+ srv.stop();
+ await srv.start(1);
+
+ // Another 2 loops
+ for (let i = 0; i < 4; i++) {
+ expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
+ srv.next();
+ }
+
+ expect(hrefMock).toHaveBeenCalledTimes(3);
+ expect(hrefMock).toHaveBeenLastCalledWith(initialUrl);
+ });
+});
diff --git a/public/app/features/plugins/PluginList.tsx b/public/app/features/plugins/PluginList.tsx
index fd490fcfe1e..bf970c37392 100644
--- a/public/app/features/plugins/PluginList.tsx
+++ b/public/app/features/plugins/PluginList.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import classNames from 'classnames';
import PluginListItem from './PluginListItem';
import { Plugin } from 'app/types';
@@ -9,7 +9,7 @@ interface Props {
layoutMode: LayoutMode;
}
-const PluginList: SFC = props => {
+const PluginList: FC = props => {
const { plugins, layoutMode } = props;
const listStyle = classNames({
diff --git a/public/app/features/plugins/PluginListItem.tsx b/public/app/features/plugins/PluginListItem.tsx
index 05eac614fd5..bbc1daa3355 100644
--- a/public/app/features/plugins/PluginListItem.tsx
+++ b/public/app/features/plugins/PluginListItem.tsx
@@ -1,11 +1,11 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { Plugin } from 'app/types';
interface Props {
plugin: Plugin;
}
-const PluginListItem: SFC = props => {
+const PluginListItem: FC = props => {
const { plugin } = props;
return (
diff --git a/public/app/features/plugins/PluginListPage.test.tsx b/public/app/features/plugins/PluginListPage.test.tsx
index 31b2f128436..31956f41cc1 100644
--- a/public/app/features/plugins/PluginListPage.test.tsx
+++ b/public/app/features/plugins/PluginListPage.test.tsx
@@ -6,7 +6,14 @@ import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector
const setup = (propOverrides?: object) => {
const props: Props = {
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Plugins'
+ }
+ } as NavModel,
plugins: [] as Plugin[],
searchQuery: '',
setPluginsSearchQuery: jest.fn(),
diff --git a/public/app/features/plugins/PluginListPage.tsx b/public/app/features/plugins/PluginListPage.tsx
index a2fcb90ce54..3bc0ee545b4 100644
--- a/public/app/features/plugins/PluginListPage.tsx
+++ b/public/app/features/plugins/PluginListPage.tsx
@@ -1,15 +1,14 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import Page from 'app/core/components/Page/Page';
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
import PluginList from './PluginList';
import { NavModel, Plugin } from 'app/types';
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
-import { getNavModel } from '../../core/selectors/navModel';
+import { getNavModel } from 'app/core/selectors/navModel';
import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors';
-import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
export interface Props {
navModel: NavModel;
@@ -48,23 +47,22 @@ export class PluginListPage extends PureComponent {
};
return (
-
-
-
-
setPluginsLayoutMode(mode)}
- setSearchQuery={query => setPluginsSearchQuery(query)}
- linkButton={linkButton}
- />
- {hasFetched ? (
- plugins &&
- ) : (
-
- )}
-
-
+
+
+ <>
+ setPluginsLayoutMode(mode)}
+ setSearchQuery={query => setPluginsSearchQuery(query)}
+ linkButton={linkButton}
+ />
+ {hasFetched && plugins && (
+ plugins &&
+ )}
+ >
+
+
);
}
}
diff --git a/public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap b/public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
index ad27dd5037c..eab70367152 100644
--- a/public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
+++ b/public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
@@ -1,12 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
-
+
+
`;
exports[`Render should render list 1`] = `
-
+
+
`;
diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts
index 0d68cbc71ba..9695cc621b7 100644
--- a/public/app/features/plugins/datasource_srv.ts
+++ b/public/app/features/plugins/datasource_srv.ts
@@ -1,14 +1,16 @@
+// Libraries
import _ from 'lodash';
import coreModule from 'app/core/core_module';
+// Services & Utils
import config from 'app/core/config';
import { importPluginModule } from './plugin_loader';
-import { DataSourceApi } from 'app/types/series';
-import { DataSource, DataSourceSelectItem } from 'app/types';
+// Types
+import { DataSourceApi, DataSourceSelectItem } from '@grafana/ui/src/types';
export class DatasourceSrv {
- datasources: { [name: string]: DataSource };
+ datasources: { [name: string]: DataSourceApi };
/** @ngInject */
constructor(private $q, private $injector, private $rootScope, private templateSrv) {
@@ -59,7 +61,7 @@ export class DatasourceSrv {
throw new Error('Plugin module is missing Datasource constructor');
}
- const instance: DataSource = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
+ const instance: DataSourceApi = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
instance.meta = pluginDef;
instance.name = name;
instance.pluginExports = plugin;
diff --git a/public/app/features/plugins/plugin_component.ts b/public/app/features/plugins/plugin_component.ts
index 7092608085d..0b305e05f5b 100644
--- a/public/app/features/plugins/plugin_component.ts
+++ b/public/app/features/plugins/plugin_component.ts
@@ -105,23 +105,17 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
switch (attrs.type) {
// QueryCtrl
case 'query-ctrl': {
- const datasource = scope.target.datasource || scope.ctrl.panel.datasource;
- return datasourceSrv.get(datasource).then(ds => {
- scope.datasource = ds;
-
- return importPluginModule(ds.meta.module).then(dsModule => {
- return {
- baseUrl: ds.meta.baseUrl,
- name: 'query-ctrl-' + ds.meta.id,
- bindings: { target: '=', panelCtrl: '=', datasource: '=' },
- attrs: {
- target: 'target',
- 'panel-ctrl': 'ctrl',
- datasource: 'datasource',
- },
- Component: dsModule.QueryCtrl,
- };
- });
+ const ds = scope.ctrl.datasource;
+ return $q.when({
+ baseUrl: ds.meta.baseUrl,
+ name: 'query-ctrl-' + ds.meta.id,
+ bindings: { target: '=', panelCtrl: '=', datasource: '=' },
+ attrs: {
+ target: 'ctrl.target',
+ 'panel-ctrl': 'ctrl',
+ datasource: 'ctrl.datasource',
+ },
+ Component: ds.pluginExports.QueryCtrl,
});
}
// Annotations
diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts
index 3c4fa29382d..206edf0bd9d 100644
--- a/public/app/features/plugins/plugin_loader.ts
+++ b/public/app/features/plugins/plugin_loader.ts
@@ -18,7 +18,7 @@ import config from 'app/core/config';
import TimeSeries from 'app/core/time_series2';
import TableModel from 'app/core/table_model';
import { coreModule, appEvents, contextSrv } from 'app/core/core';
-import { PluginExports } from 'app/types/plugins';
+import { PluginExports } from '@grafana/ui';
import * as datemath from 'app/core/utils/datemath';
import * as fileExport from 'app/core/utils/file_export';
import * as flatten from 'app/core/utils/flatten';
diff --git a/public/app/features/plugins/state/navModel.ts b/public/app/features/plugins/state/navModel.ts
index f12967ebb7a..1e7ed0cdf27 100644
--- a/public/app/features/plugins/state/navModel.ts
+++ b/public/app/features/plugins/state/navModel.ts
@@ -1,8 +1,14 @@
+// Libraries
import _ from 'lodash';
-import { DataSource, PluginMeta, NavModel } from 'app/types';
+
+// Utils & Services
import config from 'app/core/config';
-export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: string): NavModel {
+// Types
+import { NavModel } from 'app/types';
+import { PluginMeta, DataSourceSettings } from '@grafana/ui/src/types';
+
+export function buildNavModel(ds: DataSourceSettings, plugin: PluginMeta, currentPage: string): NavModel {
let title = 'New';
const subTitle = `Type: ${plugin.name}`;
diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx
index f6e1c11c9f9..212da2e3310 100644
--- a/public/app/features/teams/TeamList.test.tsx
+++ b/public/app/features/teams/TeamList.test.tsx
@@ -6,7 +6,14 @@ import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Team List'
+ }
+ } as NavModel,
teams: [] as Team[],
loadTeams: jest.fn(),
deleteTeam: jest.fn(),
diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx
index d1551d6baa6..efd279184d4 100644
--- a/public/app/features/teams/TeamList.tsx
+++ b/public/app/features/teams/TeamList.tsx
@@ -1,11 +1,10 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import Page from 'app/core/components/Page/Page';
import { DeleteButton } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
-import { NavModel, Team } from '../../types';
+import { NavModel, Team } from 'app/types';
import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
import { getNavModel } from 'app/core/selectors/navModel';
@@ -141,10 +140,11 @@ export class TeamList extends PureComponent {
const { hasFetched, navModel } = this.props;
return (
-
-
- {hasFetched ? this.renderList() :
}
-
+
+
+ {hasFetched && this.renderList()}
+
+
);
}
}
diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx
index 5e058289bf0..01a4a3347b2 100644
--- a/public/app/features/teams/TeamSettings.tsx
+++ b/public/app/features/teams/TeamSettings.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
+import { FormLabel } from '@grafana/ui';
-import { Label } from 'app/core/components/Label/Label';
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
import { updateTeam } from './state/actions';
import { getRouteParamsId } from 'app/core/selectors/location';
@@ -51,7 +51,7 @@ export class TeamSettings extends React.Component {
Team Settings
- Name
+ Name
{
+
`;
exports[`Render should render teams table 1`] = `
-
-
-
+
+
`;
diff --git a/public/app/features/templating/specs/template_srv.test.ts b/public/app/features/templating/specs/template_srv.test.ts
index 7805341d1a2..30faffea3be 100644
--- a/public/app/features/templating/specs/template_srv.test.ts
+++ b/public/app/features/templating/specs/template_srv.test.ts
@@ -275,6 +275,11 @@ describe('templateSrv', () => {
expect(result).toBe('test,test2');
});
+ it('multi value and percentencode format should render percent-encoded string', () => {
+ const result = _templateSrv.formatValue(['foo()bar BAZ', 'test2'], 'percentencode');
+ expect(result).toBe('%7Bfoo%28%29bar%20BAZ%2Ctest2%7D');
+ });
+
it('slash should be properly escaped in regex format', () => {
const result = _templateSrv.formatValue('Gi3/14', 'regex');
expect(result).toBe('Gi3\\/14');
@@ -464,6 +469,11 @@ describe('templateSrv', () => {
name: 'empty_on_init',
current: { value: '', text: '' },
},
+ {
+ type: 'custom',
+ name: 'foo',
+ current: { value: 'constructor', text: 'constructor' },
+ }
]);
_templateSrv.setGrafanaVariable('$__auto_interval_interval', '13m');
_templateSrv.updateTemplateData();
@@ -478,6 +488,12 @@ describe('templateSrv', () => {
const target = _templateSrv.replaceWithText('Hello $empty_on_init');
expect(target).toBe('Hello ');
});
+
+ it('should not return a string representation of a constructor property', () => {
+ const target = _templateSrv.replaceWithText('$foo');
+ expect(target).not.toBe('function Object() { [native code] }');
+ expect(target).toBe('constructor');
+ });
});
describe('built in interval variables', () => {
diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts
index 74da017bb93..2f8068137e5 100644
--- a/public/app/features/templating/template_srv.ts
+++ b/public/app/features/templating/template_srv.ts
@@ -77,6 +77,15 @@ export class TemplateSrv {
return '(' + quotedValues.join(' OR ') + ')';
}
+ // encode string according to RFC 3986; in contrast to encodeURIComponent()
+ // also the sub-delims "!", "'", "(", ")" and "*" are encoded;
+ // unicode handling uses UTF-8 as in ECMA-262.
+ encodeURIComponentStrict(str) {
+ return encodeURIComponent(str).replace(/[!'()*]/g, (c) => {
+ return '%' + c.charCodeAt(0).toString(16).toUpperCase();
+ });
+ }
+
formatValue(value, format, variable) {
// for some scopedVars there is no variable
variable = variable || {};
@@ -118,6 +127,13 @@ export class TemplateSrv {
}
return value;
}
+ case 'percentencode': {
+ // like glob, but url escaped
+ if (_.isArray(value)) {
+ return this.encodeURIComponentStrict('{' + value.join(',') + '}');
+ }
+ return this.encodeURIComponentStrict(value);
+ }
default: {
if (_.isArray(value)) {
return '{' + value.join(',') + '}';
@@ -238,7 +254,9 @@ export class TemplateSrv {
return match;
}
- return this.grafanaVariables[variable.current.value] || variable.current.text;
+ const value = this.grafanaVariables[variable.current.value];
+
+ return typeof(value) === 'string' ? value : variable.current.text;
});
}
diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts
index bc0362f0678..896987de706 100644
--- a/public/app/features/templating/variable_srv.ts
+++ b/public/app/features/templating/variable_srv.ts
@@ -132,7 +132,7 @@ export class VariableSrv {
return this.$q.all(promises).then(() => {
if (emitChangeEvents) {
- this.$rootScope.$emit('template-variable-value-updated');
+ this.$rootScope.appEvent('template-variable-value-updated');
this.dashboard.startRefresh();
}
});
diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx
index d2b1a19afe1..9256285c9aa 100644
--- a/public/app/features/users/UsersListPage.test.tsx
+++ b/public/app/features/users/UsersListPage.test.tsx
@@ -11,7 +11,14 @@ jest.mock('../../core/app_events', () => ({
const setup = (propOverrides?: object) => {
const props: Props = {
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Users'
+ }
+ } as NavModel,
users: [] as OrgUser[],
invitees: [] as Invitee[],
searchQuery: '',
diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx
index ff0ac8f1239..5396603bbde 100644
--- a/public/app/features/users/UsersListPage.tsx
+++ b/public/app/features/users/UsersListPage.tsx
@@ -2,15 +2,14 @@ import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import Remarkable from 'remarkable';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
+import Page from 'app/core/components/Page/Page';
import UsersActionBar from './UsersActionBar';
import UsersTable from './UsersTable';
import InviteesTable from './InviteesTable';
import { Invitee, NavModel, OrgUser } from 'app/types';
import appEvents from 'app/core/app_events';
import { loadUsers, loadInvitees, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
-import { getNavModel } from '../../core/selectors/navModel';
+import { getNavModel } from 'app/core/selectors/navModel';
import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
export interface Props {
@@ -105,16 +104,17 @@ export class UsersListPage extends PureComponent {
const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
return (
-
-
-
+
+
+ <>
{externalUserMngInfoHtml && (
)}
- {hasFetched ? this.renderTable() : }
-
-
+ {hasFetched && this.renderTable()}
+ >
+
+
);
}
}
diff --git a/public/app/features/users/UsersTable.tsx b/public/app/features/users/UsersTable.tsx
index a7bb44d1a62..0aacdfa9442 100644
--- a/public/app/features/users/UsersTable.tsx
+++ b/public/app/features/users/UsersTable.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { OrgUser } from 'app/types';
export interface Props {
@@ -7,7 +7,7 @@ export interface Props {
onRemoveUser: (user: OrgUser) => void;
}
-const UsersTable: SFC = props => {
+const UsersTable: FC = props => {
const { users, onRoleChange, onRemoveUser } = props;
return (
diff --git a/public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap b/public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
index 429322eac98..c8836666c59 100644
--- a/public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
+++ b/public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
@@ -1,12 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render List page 1`] = `
-
+
+
`;
exports[`Render should render component 1`] = `
-
+
+
`;
diff --git a/public/app/partials/login.html b/public/app/partials/login.html
index f4237e7b1ec..d629244e0ae 100644
--- a/public/app/partials/login.html
+++ b/public/app/partials/login.html
@@ -9,7 +9,7 @@
+ autofocus autofill-event-fix>