diff --git a/.circleci/config.yml b/.circleci/config.yml index c293ea26a9d..b173dbff481 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -206,6 +206,10 @@ jobs: - run: docker info - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}" + - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cd packaging/docker && ./build-enterprise.sh "master" + grafana-docker-pr: docker: @@ -230,6 +234,9 @@ jobs: - run: docker info - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}" + - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}" build-enterprise: docker: @@ -312,39 +319,52 @@ jobs: deploy-enterprise-master: docker: - - image: circleci/python:2.7-stretch + - image: grafana/grafana-ci-deploy:1.0.0 steps: - attach_workspace: at: . - run: - name: install awscli - command: 'sudo pip install awscli' + name: gcp credentials + command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json' + - run: + name: sign in to gcp + command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json' - run: name: deploy to s3 command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master' + - run: + name: deploy to gcp + command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/master' + - run: + name: Deploy to grafana.com + command: 'cd enterprise-dist && ../scripts/build/release_publisher/release_publisher -apikey ${GRAFANA_COM_API_KEY} -enterprise -from-local' + deploy-enterprise-release: docker: - - image: circleci/python:2.7-stretch + - image: grafana/grafana-ci-deploy:1.0.0 steps: - - attach_workspace: - at: . - - run: - name: install awscli - command: 'sudo pip install awscli' - - run: - name: deploy to s3 - command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release' + - attach_workspace: + at: . + - run: + name: gcp credentials + command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json' + - run: + name: sign in to gcp + command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json' + - run: + name: deploy to s3 + command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release' + - run: + name: deploy to gcp + command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/release' deploy-master: docker: - - image: circleci/python:2.7-stretch + - image: grafana/grafana-ci-deploy:1.0.0 steps: - attach_workspace: at: . - - run: - name: install awscli - command: 'sudo pip install awscli' - run: name: deploy to s3 command: | @@ -354,6 +374,15 @@ jobs: - run: name: Trigger Windows build command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} master' + - run: + name: gcp credentials + command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json' + - run: + name: sign in to gcp + command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json' + - run: + name: deploy to gcp + command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://$GCP_BUCKET_NAME/oss/master' - run: name: Publish to Grafana.com command: | @@ -362,16 +391,22 @@ jobs: deploy-release: docker: - - image: circleci/python:2.7-stretch + - image: grafana/grafana-ci-deploy:1.0.0 steps: - attach_workspace: at: . - - run: - name: install awscli - command: 'sudo pip install awscli' - run: name: deploy to s3 command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release' + - run: + name: gcp credentials + command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json' + - run: + name: sign in to gcp + command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json' + - run: + name: deploy to gcp + command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://$GCP_BUCKET_NAME/oss/release' - run: name: Deploy to Grafana.com command: './scripts/build/publish.sh' @@ -409,6 +444,7 @@ workflows: - grafana-docker-master: requires: - build-all + - build-all-enterprise - test-backend - test-frontend - codespell diff --git a/.gitignore b/.gitignore index 21083741e14..d599f762840 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ awsconfig /dist /public/build /public/views/index.html +/public/views/error.html /emails/dist /public_gen /public/vendor/npm @@ -75,3 +76,4 @@ debug.test /devenv/bulk_alerting_dashboards/*.json /scripts/build/release_publisher/release_publisher +*.patch diff --git a/CHANGELOG.md b/CHANGELOG.md index 778da9cd499..a98fca96fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,20 +4,34 @@ * **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat) * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset) -* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro) +* **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm) * **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro) * **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669) ### Minor * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy) +* **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm) * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +* **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +* **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876) +* **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945) +* **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme) ### Breaking changes * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited) +# 5.3.3 (unreleased) + +* **Alerting**: Delete alerts when parent folder was deleted [#13322](https://github.com/grafana/grafana/issues/13322) +* **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769) +* **Dashboard**: Fix datasource selection in panel by enter key [#13932](https://github.com/grafana/grafana/issues/13932) +* **Graph**: Fix table legend height when positioned below graph and using Internet Explorer 11 [#13903](https://github.com/grafana/grafana/issues/13903) + # 5.3.2 (2018-10-24) * **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm) diff --git a/Gruntfile.js b/Gruntfile.js index 2d5990b5f58..de3e68d4a92 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -9,12 +9,17 @@ module.exports = function (grunt) { destDir: 'dist', tempDir: 'tmp', platform: process.platform.replace('win32', 'windows'), + enterprise: false, }; if (grunt.option('platform')) { config.platform = grunt.option('platform'); } + if (grunt.option('enterprise')) { + config.enterprise = true; + } + if (grunt.option('arch')) { config.arch = grunt.option('arch'); } else { diff --git a/Makefile b/Makefile index c9e51d897f3..fcb740d2fac 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,7 @@ all: deps build deps-go: go run build.go setup -deps-js: - yarn install --pure-lockfile --no-progress +deps-js: node_modules deps: deps-js @@ -43,3 +42,10 @@ test: test-go test-js run: ./bin/grafana-server + +clean: + rm -rf node_modules + rm -rf public/build + +node_modules: package.json yarn.lock + yarn install --pure-lockfile --no-progress diff --git a/build.go b/build.go index 69fbf3bada8..a2a1fb825d9 100644 --- a/build.go +++ b/build.go @@ -41,8 +41,8 @@ var ( race bool phjsToRelease string workingDir string - includeBuildNumber bool = true - buildNumber int = 0 + includeBuildId bool = true + buildId string = "0" binaries []string = []string{"grafana-server", "grafana-cli"} isDev bool = false enterprise bool = false @@ -54,6 +54,8 @@ func main() { ensureGoPath() + var buildIdRaw string + flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") flag.StringVar(&gocc, "cc", "", "CC") @@ -61,12 +63,14 @@ func main() { flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary") flag.BoolVar(&race, "race", race, "Use race detector") - flag.BoolVar(&includeBuildNumber, "includeBuildNumber", includeBuildNumber, "IncludeBuildNumber in package name") + flag.BoolVar(&includeBuildId, "includeBuildId", includeBuildId, "IncludeBuildId in package name") flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana") - flag.IntVar(&buildNumber, "buildNumber", 0, "Build number from CI system") + flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system") flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps") flag.Parse() + buildId = shortenBuildId(buildIdRaw) + readVersionFromPackageJson() if pkgArch == "" { @@ -197,9 +201,9 @@ func readVersionFromPackageJson() { } // add timestamp to iteration - if includeBuildNumber { - if buildNumber != 0 { - linuxPackageIteration = fmt.Sprintf("%d%s", buildNumber, linuxPackageIteration) + if includeBuildId { + if buildId != "0" { + linuxPackageIteration = fmt.Sprintf("%s%s", buildId, linuxPackageIteration) } else { linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration) } @@ -392,7 +396,7 @@ func grunt(params ...string) { func gruntBuildArg(task string) []string { args := []string{task} - if includeBuildNumber { + if includeBuildId { args = append(args, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)) } else { args = append(args, fmt.Sprintf("--pkgVer=%v", version)) @@ -403,6 +407,10 @@ func gruntBuildArg(task string) []string { if phjsToRelease != "" { args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease)) } + if enterprise { + args = append(args, "--enterprise") + } + args = append(args, fmt.Sprintf("--platform=%v", goos)) return args @@ -467,6 +475,7 @@ func ldflags() string { b.WriteString(fmt.Sprintf(" -X main.version=%s", version)) b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha())) b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp())) + b.WriteString(fmt.Sprintf(" -X main.buildBranch=%s", getGitBranch())) return b.String() } @@ -514,6 +523,14 @@ func setBuildEnv() { } } +func getGitBranch() string { + v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "master" + } + return string(v) +} + func getGitSha() string { v, err := runError("git", "rev-parse", "--short", "HEAD") if err != nil { @@ -619,3 +636,11 @@ func shaFile(file string) error { return out.Close() } + +func shortenBuildId(buildId string) string { + buildId = strings.Replace(buildId, "-", "", -1) + if (len(buildId) < 9) { + return buildId + } + return buildId[0:8] +} diff --git a/conf/defaults.ini b/conf/defaults.ini index 750f06f2f6a..481bb002582 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -557,3 +557,7 @@ callback_url = [panels] enable_alpha = false + +[enterprise] +license_path = + diff --git a/conf/sample.ini b/conf/sample.ini index e6a03718d19..61eb1d695e8 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -475,3 +475,8 @@ log_queries = # Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer ;server_url = ;callback_url = + +[enterprise] +# Path to a valid Grafana Enterprise license.jwt file +;license_path = + diff --git a/devenv/dev-dashboards/panel_tests_graph.json b/devenv/dev-dashboards/panel_tests_graph.json index 8a1770f0fa6..ba677764a43 100644 --- a/devenv/dev-dashboards/panel_tests_graph.json +++ b/devenv/dev-dashboards/panel_tests_graph.json @@ -927,6 +927,123 @@ "title": "", "type": "text" }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 0, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 44 + }, + "id": 21, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "C-series", + "steppedLine": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "hide": false, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,null,40,null,90,null,null,100,null,null,100,null,null,80,null", + "target": "" + }, + { + "alias": "", + "hide": false, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "20,null40,null,null,50,null,70,null,100,null,10,null,30,null", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Null between points", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points", + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 44 + }, + "id": 22, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, { "aliasColors": {}, "bars": false, @@ -939,7 +1056,7 @@ "h": 7, "w": 24, "x": 0, - "y": 44 + "y": 51 }, "id": 20, "legend": { @@ -1024,7 +1141,7 @@ "h": 7, "w": 12, "x": 0, - "y": 51 + "y": 58 }, "id": 16, "legend": { @@ -1127,7 +1244,7 @@ "h": 7, "w": 12, "x": 12, - "y": 51 + "y": 58 }, "id": 17, "legend": { @@ -1266,7 +1383,7 @@ "h": 7, "w": 12, "x": 0, - "y": 58 + "y": 65 }, "id": 18, "legend": { @@ -1370,7 +1487,7 @@ "h": 7, "w": 12, "x": 12, - "y": 58 + "y": 65 }, "id": 19, "legend": { @@ -1554,5 +1671,5 @@ "timezone": "browser", "title": "Panel Tests - Graph", "uid": "5SdHCadmz", - "version": 3 + "version": 1 } diff --git a/devenv/dev-dashboards/panel_tests_table.json b/devenv/dev-dashboards/panel_tests_table.json index 8337e9cd746..ff0288c340a 100644 --- a/devenv/dev-dashboards/panel_tests_table.json +++ b/devenv/dev-dashboards/panel_tests_table.json @@ -404,6 +404,112 @@ "title": "Column style thresholds & units", "transform": "timeseries_to_columns", "type": "table" + }, + { + "columns": [], + "datasource": "gdev-testdata", + "fontSize": "100%", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 6, + "links": [], + "pageSize": 20, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": "cell", + "colors": [ + "rgba(245, 54, 54, 0.5)", + "rgba(237, 129, 40, 0.5)", + "rgba(50, 172, 45, 0.5)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "link": true, + "linkTargetBlank": true, + "linkTooltip": "", + "linkUrl": "http://www.grafana.com", + "mappingType": 1, + "pattern": "ColorCell", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "currencyUSD" + }, + { + "alias": "", + "colorMode": "value", + "colors": [ + "rgba(245, 54, 54, 0.5)", + "rgba(237, 129, 40, 0.5)", + "rgba(50, 172, 45, 0.5)" + ], + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "decimals": 2, + "link": true, + "linkUrl": "http://www.grafana.com", + "mappingType": 1, + "pattern": "ColorValue", + "thresholds": [ + "5", + "10" + ], + "type": "number", + "unit": "Bps" + }, + { + "alias": "", + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "ColorValue", + "expr": "", + "format": "table", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "null,1,20,90,30,5,0,20,10" + }, + { + "alias": "ColorCell", + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "null,5,1,2,3,4,5,10,20" + } + ], + "title": "Column style thresholds and links", + "transform": "timeseries_to_columns", + "type": "table" } ], "refresh": false, @@ -449,5 +555,5 @@ "timezone": "browser", "title": "Panel Tests - Table", "uid": "pttable", - "version": 1 -} + "version": 2 +} \ No newline at end of file diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 8916b2bf6e3..9149aa42130 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -158,7 +158,7 @@ Since not all datasources have the same configuration settings we only have the | timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source | | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) | | timeField | string | Elasticsearch | Which field that should be used as timestamp | -| interval | string | Elasticsearch | Index date time format | +| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | | assumeRoleArn | string | Cloudwatch | ARN of Assume Role | | defaultRegion | string | Cloudwatch | AWS region | diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 307af1ee15e..b232ee78f27 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -140,7 +140,7 @@ In DingTalk PC Client: 6. There will be a Webhook URL in the panel, looks like this: https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx. Copy this URL to the grafana Dingtalk setting page and then click "finish". -Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `text` message type is supported. +Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `link` message type is supported. ### Kafka diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md index e3a450f9fc7..56fc3b131a5 100644 --- a/docs/sources/auth/gitlab.md +++ b/docs/sources/auth/gitlab.md @@ -100,12 +100,12 @@ display name, especially if the display name contains spaces or special characters. Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. -Here's a complete example with `alloed_sign_up` enabled, and access limited to +Here's a complete example with `allow_sign_up` enabled, and access limited to the `example` and `foo/bar` groups: ```ini [auth.gitlab] -enabled = false +enabled = true allow_sign_up = true 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 be36d108475..e2bcb50bb1d 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -60,7 +60,8 @@ Here is a minimal policy example: "Effect": "Allow", "Action": [ "cloudwatch:ListMetrics", - "cloudwatch:GetMetricStatistics" + "cloudwatch:GetMetricStatistics", + "cloudwatch:GetMetricData" ], "Resource": "*" }, diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md index 988f632bff3..bc4e4df6cf9 100644 --- a/docs/sources/features/datasources/mysql.md +++ b/docs/sources/features/datasources/mysql.md @@ -73,6 +73,58 @@ Example: You can use wildcards (`*`) in place of database or table if you want to grant access to more databases and tables. +## Query Editor + +> Only available in Grafana v5.4+. + +{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v54/mysql_query.gif" >}} + +You find the MySQL query editor in the metrics tab in a panel's edit mode. You enter edit mode by clicking the +panel title, then edit. + +The query editor has a link named `Generated SQL` that shows up after a query has been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed. + +### Select table, time column and metric column (FROM) + +When you enter edit mode for the first time or add a new query Grafana will try to prefill the query builder with the first table that has a timestamp column and a numeric column. + +In the FROM field, Grafana will suggest tables that are in the configured database. To select a table or view in another database that your database user has access to you can manually enter a fully qualified name (database.table) like `otherDb.metrics`. + +The Time column field refers to the name of the column holding your time values. Selecting a value for the Metric column field is optional. If a value is selected, the Metric column field will be used as the series name. + +The metric column suggestions will only contain columns with a text datatype (text, tinytext, mediumtext, longtext, varchar, char). +If you want to use a column with a different datatype as metric column you may enter the column name with a cast: `CAST(numericColumn as CHAR)`. +You may also enter arbitrary SQL expressions in the metric column field that evaluate to a text datatype like +`CONCAT(column1, " ", CAST(numericColumn as CHAR))`. + +### Columns and Aggregation functions (SELECT) + +In the `SELECT` row you can specify what columns and functions you want to use. +In the column field you may write arbitrary expressions instead of a column name like `column1 * column2 / column3`. + +If you use aggregate functions you need to group your resultset. The editor will automatically add a `GROUP BY time` if you add an aggregate function. + +You may add further value columns by clicking the plus button and selecting `Column` from the menu. Multiple value columns will be plotted as separate series in the graph panel. + +### Filter data (WHERE) +To add a filter click the plus icon to the right of the `WHERE` condition. You can remove filters by clicking on +the filter and selecting `Remove`. A filter for the current selected timerange is automatically added to new queries. + +### Group By +To group by time or any other columns click the plus icon at the end of the GROUP BY row. The suggestion dropdown will only show text columns of your currently selected table but you may manually enter any column. +You can remove the group by clicking on the item and then selecting `Remove`. + +If you add any grouping, all selected columns need to have an aggregate function applied. The query builder will automatically add aggregate functions to all columns without aggregate functions when you add groupings. + +#### Gap Filling + +Grafana can fill in missing values when you group by time. The time function accepts two arguments. The first argument is the time window that you would like to group by, and the second argument is the value you want Grafana to fill missing items with. + +### Text Editor Mode (RAW) +You can switch to the raw query editor mode by clicking the hamburger icon and selecting `Switch editor mode` or by clicking `Edit SQL` below the query. + +> If you use the raw query editor, be sure your query at minimum has `ORDER BY time` and a filter on the returned time range. + ## Macros To simplify syntax and to allow for dynamic parts, like date range filters, the query can contain macros. diff --git a/docs/sources/guides/whats-new-in-v5-3.md b/docs/sources/guides/whats-new-in-v5-3.md index 5dcadc0813d..10592f51648 100644 --- a/docs/sources/guides/whats-new-in-v5-3.md +++ b/docs/sources/guides/whats-new-in-v5-3.md @@ -18,7 +18,7 @@ Grafana v5.3 brings new features, many enhancements and bug fixes. This article - [TV mode]({{< relref "#tv-and-kiosk-mode" >}}) is improved and more accessible - [Alerting]({{< relref "#notification-reminders" >}}) with notification reminders - [Postgres]({{< relref "#postgres-query-builder" >}}) gets a new query builder! -- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for Gitlab is improved +- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for GitLab is improved - [Annotations]({{< relref "#annotations" >}}) with template variable filtering - [Variables]({{< relref "#variables" >}}) with free text support @@ -69,9 +69,9 @@ Grafana 5.3 comes with a new graphical query builder for Postgres. This brings P {{< docs-imagebox img="/img/docs/v53/postgres_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v53/postgres_query.gif" >}} -## Improved OAuth Support for Gitlab +## Improved OAuth Support for GitLab -Grafana 5.3 comes with a new OAuth integration for Gitlab that enables configuration to only allow users that are a member of certain Gitlab groups to authenticate. This makes it possible to use Gitlab OAuth with Grafana in a shared environment without giving everyone access to Grafana. +Grafana 5.3 comes with a new OAuth integration for GitLab that enables configuration to only allow users that are a member of certain GitLab groups to authenticate. This makes it possible to use GitLab OAuth with Grafana in a shared environment without giving everyone access to Grafana. Learn how to enable and configure it in the [documentation](/auth/gitlab/). ## Annotations diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index 103de190793..2d70a6d2017 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -290,7 +290,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk "sendReminder": true, "frequency": "15m", "settings": { - "addresses: "carl@grafana.com;dev@grafana.com" + "addresses": "carl@grafana.com;dev@grafana.com" } } ``` diff --git a/package.json b/package.json index 9b00853355a..68b7647fb58 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "grunt-contrib-copy": "~1.0.0", "grunt-contrib-cssmin": "~1.0.2", "grunt-exec": "^1.0.1", + "grunt-newer": "^1.3.0", "grunt-notify": "^0.4.5", "grunt-postcss": "^0.8.0", "grunt-sass": "^2.0.0", @@ -102,7 +103,7 @@ "build": "grunt build", "test": "grunt test", "lint": "tslint -c tslint.json --project tsconfig.json", - "jest": "jest --config jest.config.json --notify --watch", + "jest": "jest --notify --watch", "api-tests": "jest --notify --watch --config=tests/api/jest.js", "precommit": "lint-staged && grunt precommit" }, diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh new file mode 100755 index 00000000000..2f59e436d95 --- /dev/null +++ b/packaging/docker/build-enterprise.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +_grafana_tag=$1 +_docker_repo=${2:-grafana/grafana-enterprise} + +docker build \ + --tag "${_docker_repo}:${_grafana_tag}"\ + --no-cache=true \ + . + +docker push "${_docker_repo}:${_grafana_tag}" diff --git a/packaging/release_process.md b/packaging/release_process.md deleted file mode 100644 index 6037a9c499c..00000000000 --- a/packaging/release_process.md +++ /dev/null @@ -1,29 +0,0 @@ -# New Grafana Release Processes - -## Building release packages - -1) Update package.json so that it has the right version. -2) Create a git tag for the release: `git tag -a v3.0.4 -m "3.0.4 release"` -3) Push branch & tag to github! -2) Packages from master a built automatically by circle CI for this repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer) - -### Non master branch - -When building from non master branch create a new branch in repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer) -and configure circle.yml to deploy that branch as well, https://github.com/grafana/grafana-packer/blob/master/circle.yml#L25, -you also need to update https://github.com/grafana/grafana-packer/blob/v3.1.x/deploy.sh#L7. - -### Windows build - -Sign into ci.appveyor.com and the Grafana project's build history page. Builds for windows take a long time (around 20min) -and fail quite often for random reasons so I usually continue with the release process without a windows build already built. - -1) Click on the green build that has the correct version and tag -2) Click on `DEPLOYMENTS` -3) Click on `NEW DEPLOYMENT` -4) Select GrafanaBuildS3 -4) Select the build you want to deploy. - -The deployment should be quick (just uploads the release zip file to S3) - - diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index a936d696207..c68cee50948 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -134,12 +134,16 @@ func AlertTest(c *m.ReqContext, dto dtos.AlertTestCommand) Response { OrgId: c.OrgId, Dashboard: dto.Dashboard, PanelId: dto.PanelId, + User: c.SignedInUser, } if err := bus.Dispatch(&backendCmd); err != nil { if validationErr, ok := err.(alerting.ValidationError); ok { return Error(422, validationErr.Error(), nil) } + if err == m.ErrDataSourceAccessDenied { + return Error(403, "Access denied to datasource", err) + } return Error(500, "Failed to test rule", err) } diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 3bb2f236129..5cde0efd0b4 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -1,62 +1,22 @@ package api import ( - "fmt" - "github.com/pkg/errors" - "time" - "github.com/grafana/grafana/pkg/api/pluginproxy" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/metrics" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ) -const HeaderNameNoBackendCache = "X-Grafana-NoCache" - -func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) { - userPermissionsQuery := m.GetDataSourcePermissionsForUserQuery{ - User: c.SignedInUser, - } - if err := bus.Dispatch(&userPermissionsQuery); err != nil { - if err != bus.ErrHandlerNotFound { - return nil, err - } - } else { - permissionType, exists := userPermissionsQuery.Result[id] - if exists && permissionType != m.DsPermissionQuery { - return nil, errors.New("User not allowed to access datasource") - } - } - - nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true" - cacheKey := fmt.Sprintf("ds-%d", id) - - if !nocache { - if cached, found := hs.cache.Get(cacheKey); found { - ds := cached.(*m.DataSource) - if ds.OrgId == c.OrgId { - return ds, nil - } - } - } - - query := m.GetDataSourceByIdQuery{Id: id, OrgId: c.OrgId} - if err := bus.Dispatch(&query); err != nil { - return nil, err - } - - hs.cache.Set(cacheKey, query.Result, time.Second*5) - return query.Result, nil -} - func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) { c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer) dsId := c.ParamsInt64(":id") - ds, err := hs.getDatasourceFromCache(dsId, c) - + ds, err := hs.DatasourceCache.GetDatasource(dsId, c.SignedInUser, c.SkipCache) if err != nil { + if err == m.ErrDataSourceAccessDenied { + c.JsonApiErr(403, "Access denied to datasource", err) + return + } c.JsonApiErr(500, "Unable to load datasource meta data", err) return } diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 77004899fc3..bd3ac76eec8 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -14,6 +14,7 @@ type IndexViewData struct { NewGrafanaVersionExists bool NewGrafanaVersion string AppName string + AppNameBodyClass string } type PluginCss struct { diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 858b3c5a8c5..ce28e4716ee 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -16,7 +16,6 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" - gocache "github.com/patrickmn/go-cache" macaron "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/api/live" @@ -28,6 +27,8 @@ 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/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/setting" @@ -46,19 +47,19 @@ type HTTPServer struct { macaron *macaron.Macaron context context.Context streamManager *live.StreamManager - cache *gocache.Cache httpSrv *http.Server - RouteRegister routing.RouteRegister `inject:""` - Bus bus.Bus `inject:""` - RenderService rendering.Service `inject:""` - Cfg *setting.Cfg `inject:""` - HooksService *hooks.HooksService `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:""` } func (hs *HTTPServer) Init() error { hs.log = log.New("http.server") - hs.cache = gocache.New(5*time.Minute, 10*time.Minute) hs.streamManager = live.NewStreamManager() hs.macaron = hs.newMacaron() @@ -231,6 +232,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m.Use(middleware.ValidateHostHeader(setting.Domain)) } + m.Use(middleware.HandleNoCacheHeader()) m.Use(middleware.AddDefaultResponseHeaders()) } diff --git a/pkg/api/index.go b/pkg/api/index.go index e61620f9586..fe7c9e79a17 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -83,6 +83,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er NewGrafanaVersion: plugins.GrafanaLatestVersion, NewGrafanaVersionExists: plugins.GrafanaHasUpdate, AppName: setting.ApplicationName, + AppNameBodyClass: getAppNameBodyClass(setting.ApplicationName), } if setting.DisableGravatar { @@ -377,3 +378,14 @@ func (hs *HTTPServer) NotFoundHandler(c *m.ReqContext) { c.HTML(404, "index", data) } + +func getAppNameBodyClass(name string) string { + switch name { + case setting.APP_NAME: + return "app-grafana" + case setting.APP_NAME_ENTERPRISE: + return "app-enterprise" + default: + return "" + } +} diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index cb80bd346b8..6e5ae0f8761 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -25,8 +25,11 @@ func (hs *HTTPServer) QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) R return Error(400, "Query missing datasourceId", nil) } - ds, err := hs.getDatasourceFromCache(datasourceId, c) + ds, err := hs.DatasourceCache.GetDatasource(datasourceId, c.SignedInUser, c.SkipCache) if err != nil { + if err == m.ErrDataSourceAccessDenied { + return Error(403, "Access denied to datasource", err) + } return Error(500, "Unable to load datasource meta data", err) } diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 06c07a2887c..c7c1ff3aff7 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -3,6 +3,8 @@ package main import ( "flag" "fmt" + "net/http" + _ "net/http/pprof" "os" "os/signal" "runtime" @@ -11,16 +13,12 @@ import ( "syscall" "time" - "net/http" - _ "net/http/pprof" - + extensions "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/setting" - - extensions "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/services/alerting/conditions" _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" + "github.com/grafana/grafana/pkg/setting" _ "github.com/grafana/grafana/pkg/tsdb/cloudwatch" _ "github.com/grafana/grafana/pkg/tsdb/elasticsearch" _ "github.com/grafana/grafana/pkg/tsdb/graphite" @@ -35,6 +33,7 @@ import ( var version = "5.0.0" var commit = "NA" +var buildBranch = "master" var buildstamp string var configFile = flag.String("config", "", "path to config file") @@ -47,7 +46,7 @@ func main() { profilePort := flag.Int("profile-port", 6060, "Define custom port for profiling") flag.Parse() if *v { - fmt.Printf("Version %s (commit: %s)\n", version, commit) + fmt.Printf("Version %s (commit: %s, branch: %s)\n", version, commit, buildBranch) os.Exit(0) } @@ -78,9 +77,10 @@ func main() { setting.BuildVersion = version setting.BuildCommit = commit setting.BuildStamp = buildstampInt64 + setting.BuildBranch = buildBranch setting.IsEnterprise = extensions.IsEnterprise - metrics.M_Grafana_Version.WithLabelValues(version).Set(1) + metrics.SetBuildInformation(version, commit, buildBranch) server := NewGrafanaServer() diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 8794d7d8338..2c67a06a843 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -12,20 +12,20 @@ import ( "time" "github.com/facebookgo/inject" + "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/social" "golang.org/x/sync/errgroup" - "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/login" + "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/social" - // self registering services _ "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/metrics" @@ -77,6 +77,7 @@ func (g *GrafanaServerImpl) Run() error { serviceGraph.Provide(&inject.Object{Value: bus.GetBus()}) serviceGraph.Provide(&inject.Object{Value: g.cfg}) serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) + serviceGraph.Provide(&inject.Object{Value: cache.New(5*time.Minute, 10*time.Minute)}) // self registered services services := registry.GetServices() @@ -143,7 +144,6 @@ func (g *GrafanaServerImpl) Run() error { } sendSystemdNotification("READY=1") - return g.childRoutines.Wait() } @@ -159,7 +159,7 @@ func (g *GrafanaServerImpl) loadConfiguration() { os.Exit(1) } - g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0)) + g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "branch", buildBranch, "compiled", time.Unix(setting.BuildStamp, 0)) g.cfg.LogConfigSources() } diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 991fa72fd54..b766d963328 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -2,6 +2,7 @@ package login import ( "errors" + "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" ) diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 4c71ab3cd5f..d4e81d2bd46 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -185,7 +185,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo if ldapUser.isMemberOf(group.GroupDN) { extUser.OrgRoles[group.OrgId] = group.OrgRole - if extUser.IsGrafanaAdmin == nil || *extUser.IsGrafanaAdmin == false { + if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin { extUser.IsGrafanaAdmin = group.IsGrafanaAdmin } } diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 9a514fdb6f3..5709e3e3213 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -58,7 +58,14 @@ var ( M_StatActive_Users prometheus.Gauge M_StatTotal_Orgs prometheus.Gauge M_StatTotal_Playlists prometheus.Gauge - M_Grafana_Version *prometheus.GaugeVec + + // M_Grafana_Version is a gauge that contains build info about this binary + // + // Deprecated: use M_Grafana_Build_Version instead. + M_Grafana_Version *prometheus.GaugeVec + + // grafanaBuildVersion is a gauge that contains build info about this binary + grafanaBuildVersion *prometheus.GaugeVec ) func newCounterVecStartingAtZero(opts prometheus.CounterOpts, labels []string, labelValues ...string) *prometheus.CounterVec { @@ -293,9 +300,25 @@ func init() { M_Grafana_Version = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "info", - Help: "Information about the Grafana", + Help: "Information about the Grafana. This metric is deprecated. please use `grafana_build_info`", Namespace: exporterName, }, []string{"version"}) + + grafanaBuildVersion = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "build_info", + Help: "A metric with a constant '1' value labeled by version, revision, branch, and goversion from which Grafana was built.", + Namespace: exporterName, + }, []string{"version", "revision", "branch", "goversion"}) +} + +// SetBuildInformation sets the build information for this binary +func SetBuildInformation(version, revision, branch string) { + // We export this info twice for backwards compability. + // Once this have been released for some time we should be able to remote `M_Grafana_Version` + // The reason we added a new one is that its common practice in the prometheus community + // to name this metric `*_build_info` so its easy to do aggregation on all programs. + M_Grafana_Version.WithLabelValues(version).Set(1) + grafanaBuildVersion.WithLabelValues(version, revision, branch, runtime.Version()).Set(1) } func initMetricVars() { @@ -334,7 +357,8 @@ func initMetricVars() { M_StatActive_Users, M_StatTotal_Orgs, M_StatTotal_Playlists, - M_Grafana_Version) + M_Grafana_Version, + grafanaBuildVersion) } diff --git a/pkg/middleware/headers.go b/pkg/middleware/headers.go new file mode 100644 index 00000000000..28c623d74b0 --- /dev/null +++ b/pkg/middleware/headers.go @@ -0,0 +1,14 @@ +package middleware + +import ( + m "github.com/grafana/grafana/pkg/models" + macaron "gopkg.in/macaron.v1" +) + +const HeaderNameNoBackendCache = "X-Grafana-NoCache" + +func HandleNoCacheHeader() macaron.Handler { + return func(ctx *m.ReqContext) { + ctx.SkipCache = ctx.Req.Header.Get(HeaderNameNoBackendCache) == "true" + } +} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 3e83a60f94b..ace72d998eb 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -29,6 +29,7 @@ func GetContextHandler() macaron.Handler { Session: session.GetSession(), IsSignedIn: false, AllowAnonymous: false, + SkipCache: false, Logger: log.New("context"), } @@ -43,12 +44,13 @@ func GetContextHandler() macaron.Handler { // then init session and look for userId in session // then look for api key in session (special case for render calls via api) // then test if anonymous access is enabled - if initContextWithRenderAuth(ctx) || - initContextWithApiKey(ctx) || - initContextWithBasicAuth(ctx, orgId) || - initContextWithAuthProxy(ctx, orgId) || - initContextWithUserSessionCookie(ctx, orgId) || - initContextWithAnonymousUser(ctx) { + switch { + case initContextWithRenderAuth(ctx): + case initContextWithApiKey(ctx): + case initContextWithBasicAuth(ctx, orgId): + case initContextWithAuthProxy(ctx, orgId): + case initContextWithUserSessionCookie(ctx, orgId): + case initContextWithAnonymousUser(ctx): } ctx.Logger = log.New("context", "userId", ctx.UserId, "orgId", ctx.OrgId, "uname", ctx.Login) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 1830b3eb161..e9a3c8059f8 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -18,6 +18,7 @@ import ( ) func TestMiddlewareContext(t *testing.T) { + setting.ERR_TEMPLATE_NAME = "error-template" Convey("Given the grafana middleware", t, func() { middlewareScenario("middleware should add context to injector", func(sc *scenarioContext) { diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index 456bc91354e..eef07c8c24a 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -138,7 +138,7 @@ func Recovery() macaron.Handler { c.JSON(500, resp) } else { - c.HTML(500, "error") + c.HTML(500, setting.ERR_TEMPLATE_NAME) } } }() diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 4bbedbc3b21..c92150f3b7d 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -8,11 +8,14 @@ import ( "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" ) func TestRecoveryMiddleware(t *testing.T) { + setting.ERR_TEMPLATE_NAME = "error-template" + Convey("Given an api route that panics", t, func() { apiURL := "/api/whatever" recoveryScenario("recovery middleware should return json", apiURL, func(sc *scenarioContext) { @@ -50,6 +53,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { sc := &scenarioContext{ url: url, } + viewsPath, _ := filepath.Abs("../../public/views") sc.m = macaron.New() diff --git a/pkg/models/alert.go b/pkg/models/alert.go index ba1fc0779ba..aaf9c50197a 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -215,13 +215,14 @@ type AlertStateInfoDTO struct { // "Internal" commands type UpdateDashboardAlertsCommand struct { - UserId int64 OrgId int64 Dashboard *Dashboard + User *SignedInUser } type ValidateDashboardAlertsCommand struct { UserId int64 OrgId int64 Dashboard *Dashboard + User *SignedInUser } diff --git a/pkg/models/context.go b/pkg/models/context.go index 262f6550954..7cb80a957c3 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -20,6 +20,7 @@ type ReqContext struct { IsSignedIn bool IsRenderCall bool AllowAnonymous bool + SkipCache bool Logger log.Logger } @@ -36,7 +37,7 @@ func (ctx *ReqContext) Handle(status int, title string, err error) { ctx.Data["AppSubUrl"] = setting.AppSubUrl ctx.Data["Theme"] = "dark" - ctx.HTML(status, "error") + ctx.HTML(status, setting.ERR_TEMPLATE_NAME) } func (ctx *ReqContext) JsonOK(message string) { diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index b71d17ec0d1..89439420d7a 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -207,11 +207,6 @@ func (p DsPermissionType) String() string { return names[int(p)] } -type GetDataSourcePermissionsForUserQuery struct { - User *SignedInUser - Result map[int64]DsPermissionType -} - type DatasourcesPermissionFilterQuery struct { User *SignedInUser Datasources []*DataSource diff --git a/pkg/models/user.go b/pkg/models/user.go index d5b912e0a9c..e3c7b556d35 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -165,6 +165,7 @@ type SignedInUser struct { IsAnonymous bool HelpFlags1 HelpFlags1 LastSeenAt time.Time + Teams []int64 } func (u *SignedInUser) ShouldUpdateLastSeenAt() bool { diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 67eb0f51d70..4f15441bb2f 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -121,7 +121,6 @@ func (pm *PluginManager) Run(ctx context.Context) error { pm.checkForUpdates() case <-ctx.Done(): run = false - break } } diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 87fca27f6c1..487a6db7927 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -29,11 +29,42 @@ func Register(descriptor *Descriptor) { } func GetServices() []*Descriptor { - sort.Slice(services, func(i, j int) bool { - return services[i].InitPriority > services[j].InitPriority + slice := getServicesWithOverrides() + + sort.Slice(slice, func(i, j int) bool { + return slice[i].InitPriority > slice[j].InitPriority }) - return services + return slice +} + +type OverrideServiceFunc func(descriptor Descriptor) (*Descriptor, bool) + +var overrides []OverrideServiceFunc + +func RegisterOverride(fn OverrideServiceFunc) { + overrides = append(overrides, fn) +} + +func getServicesWithOverrides() []*Descriptor { + slice := []*Descriptor{} + for _, s := range services { + var descriptor *Descriptor + for _, fn := range overrides { + if newDescriptor, override := fn(*s); override { + descriptor = newDescriptor + break + } + } + + if descriptor != nil { + slice = append(slice, descriptor) + } else { + slice = append(slice, s) + } + } + + return slice } // Service interface is the lowest common shape that services diff --git a/pkg/services/alerting/commands.go b/pkg/services/alerting/commands.go index 02186d697ee..dd2ff5658d6 100644 --- a/pkg/services/alerting/commands.go +++ b/pkg/services/alerting/commands.go @@ -11,7 +11,7 @@ func init() { } func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error { - extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId) + extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId, cmd.User) return extractor.ValidateAlerts() } @@ -19,11 +19,11 @@ func validateDashboardAlerts(cmd *m.ValidateDashboardAlertsCommand) error { func updateDashboardAlerts(cmd *m.UpdateDashboardAlertsCommand) error { saveAlerts := m.SaveAlertsCommand{ OrgId: cmd.OrgId, - UserId: cmd.UserId, + UserId: cmd.User.UserId, DashboardId: cmd.Dashboard.Id, } - extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId) + extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId, cmd.User) alerts, err := extractor.GetAlerts() if err != nil { diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go index 9d4e1462690..7f11fc498bd 100644 --- a/pkg/services/alerting/conditions/reducer_test.go +++ b/pkg/services/alerting/conditions/reducer_test.go @@ -52,6 +52,24 @@ func TestSimpleReducer(t *testing.T) { So(result, ShouldEqual, float64(1)) }) + Convey("median should ignore null values", func() { + reducer := NewSimpleReducer("median") + series := &tsdb.TimeSeries{ + Name: "test time serie", + } + + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 2)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 3)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(1)), 4)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(2)), 5)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(3)), 6)) + + result := reducer.Reduce(series) + So(result.Valid, ShouldEqual, true) + So(result.Float64, ShouldEqual, float64(2)) + }) + Convey("avg", func() { result := testReducer("avg", 1, 2, 3) So(result, ShouldEqual, float64(2)) diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index edfab2dedee..0abacc91313 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -13,14 +13,16 @@ import ( // DashAlertExtractor extracts alerts from the dashboard json type DashAlertExtractor struct { + User *m.SignedInUser Dash *m.Dashboard OrgID int64 log log.Logger } // NewDashAlertExtractor returns a new DashAlertExtractor -func NewDashAlertExtractor(dash *m.Dashboard, orgID int64) *DashAlertExtractor { +func NewDashAlertExtractor(dash *m.Dashboard, orgID int64, user *m.SignedInUser) *DashAlertExtractor { return &DashAlertExtractor{ + User: user, Dash: dash, OrgID: orgID, log: log.New("alerting.extractor"), @@ -149,6 +151,21 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, return nil, ValidationError{Reason: fmt.Sprintf("Data source used by alert rule not found, alertName=%v, datasource=%s", alert.Name, dsName)} } + dsFilterQuery := m.DatasourcesPermissionFilterQuery{ + User: e.User, + Datasources: []*m.DataSource{datasource}, + } + + if err := bus.Dispatch(&dsFilterQuery); err != nil { + if err != bus.ErrHandlerNotFound { + return nil, err + } + } else { + if len(dsFilterQuery.Result) == 0 { + return nil, m.ErrDataSourceAccessDenied + } + } + jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id) if interval, err := panel.Get("interval").String(); err == nil { diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index e2dc01a1181..0890b9e1bd1 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -69,7 +69,7 @@ func TestAlertRuleExtraction(t *testing.T) { So(getTarget(dashJson), ShouldEqual, "") }) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) _, _ = extractor.GetAlerts() Convey("Dashboard json should not be updated after extracting rules", func() { @@ -83,7 +83,7 @@ func TestAlertRuleExtraction(t *testing.T) { So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) alerts, err := extractor.GetAlerts() @@ -146,7 +146,7 @@ func TestAlertRuleExtraction(t *testing.T) { dashJson, err := simplejson.NewJson(panelWithoutId) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) _, err = extractor.GetAlerts() @@ -162,7 +162,7 @@ func TestAlertRuleExtraction(t *testing.T) { dashJson, err := simplejson.NewJson(panelWithIdZero) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) _, err = extractor.GetAlerts() @@ -178,7 +178,7 @@ func TestAlertRuleExtraction(t *testing.T) { dashJson, err := simplejson.NewJson(json) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) alerts, err := extractor.GetAlerts() @@ -198,7 +198,7 @@ func TestAlertRuleExtraction(t *testing.T) { dashJson, err := simplejson.NewJson(json) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) alerts, err := extractor.GetAlerts() @@ -228,7 +228,7 @@ func TestAlertRuleExtraction(t *testing.T) { So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) alerts, err := extractor.GetAlerts() @@ -248,7 +248,7 @@ func TestAlertRuleExtraction(t *testing.T) { dashJSON, err := simplejson.NewJson(json) So(err, ShouldBeNil) dash := m.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1) + extractor := NewDashAlertExtractor(dash, 1, nil) err = extractor.ValidateAlerts() diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go index 738e43af2d2..1ef085c82f1 100644 --- a/pkg/services/alerting/notifiers/dingding.go +++ b/pkg/services/alerting/notifiers/dingding.go @@ -57,6 +57,9 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error { message := evalContext.Rule.Message picUrl := evalContext.ImagePublicUrl title := evalContext.GetNotificationTitle() + if message == "" { + message = title + } bodyJSON, err := simplejson.NewJson([]byte(`{ "msgtype": "link", diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 6c47c92972c..4a4a989d873 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -14,7 +14,7 @@ import ( ) const ( - captionLengthLimit = 200 + captionLengthLimit = 1024 ) var ( diff --git a/pkg/services/alerting/notifiers/telegram_test.go b/pkg/services/alerting/notifiers/telegram_test.go index 911323ae9d1..9906a2ffd95 100644 --- a/pkg/services/alerting/notifiers/telegram_test.go +++ b/pkg/services/alerting/notifiers/telegram_test.go @@ -61,7 +61,7 @@ func TestTelegramNotifier(t *testing.T) { }) caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "") - So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(len(caption), ShouldBeLessThanOrEqualTo, 1024) So(caption, ShouldContainSubstring, "Some kind of message.") So(caption, ShouldContainSubstring, "[OK] This is an alarm") So(caption, ShouldContainSubstring, "http://grafa.url/abcdef") @@ -78,9 +78,9 @@ func TestTelegramNotifier(t *testing.T) { }) caption := generateImageCaption(evalContext, - "http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "foo bar") - So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(len(caption), ShouldBeLessThanOrEqualTo, 1024) So(caption, ShouldContainSubstring, "Some kind of message.") So(caption, ShouldContainSubstring, "[OK] This is an alarm") So(caption, ShouldContainSubstring, "foo bar") @@ -91,31 +91,31 @@ func TestTelegramNotifier(t *testing.T) { evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ Name: "This is an alarm", - Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.", + Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis scelerisque. Nulla ipsum ex, iaculis vitae vehicula sit amet, fermentum eu eros.", State: m.AlertStateOK, }) caption := generateImageCaption(evalContext, "http://grafa.url/foo", "") - So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(len(caption), ShouldBeLessThanOrEqualTo, 1024) So(caption, ShouldContainSubstring, "[OK] This is an alarm") So(caption, ShouldNotContainSubstring, "http") - So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise ") + So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri") }) Convey("Metrics should be skipped if they don't fit", func() { evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{ Name: "This is an alarm", - Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ", + Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri", State: m.AlertStateOK, }) caption := generateImageCaption(evalContext, "http://grafa.url/foo", "foo bar long song") - So(len(caption), ShouldBeLessThanOrEqualTo, 200) + So(len(caption), ShouldBeLessThanOrEqualTo, 1024) So(caption, ShouldContainSubstring, "[OK] This is an alarm") So(caption, ShouldNotContainSubstring, "http") So(caption, ShouldNotContainSubstring, "foo bar") diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go index 627159c286b..2cdbc57b41d 100644 --- a/pkg/services/alerting/reader.go +++ b/pkg/services/alerting/reader.go @@ -34,11 +34,8 @@ func NewRuleReader() *DefaultRuleReader { func (arr *DefaultRuleReader) initReader() { heartbeat := time.NewTicker(time.Second * 10) - for { - select { - case <-heartbeat.C: - arr.heartbeat() - } + for range heartbeat.C { + arr.heartbeat() } } diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go index 88418bff14e..360ee065de0 100644 --- a/pkg/services/alerting/test_rule.go +++ b/pkg/services/alerting/test_rule.go @@ -13,6 +13,7 @@ type AlertTestCommand struct { Dashboard *simplejson.Json PanelId int64 OrgId int64 + User *m.SignedInUser Result *EvalContext } @@ -25,7 +26,7 @@ func handleAlertTestCommand(cmd *AlertTestCommand) error { dash := m.NewDashboardFromJson(cmd.Dashboard) - extractor := NewDashAlertExtractor(dash, cmd.OrgId) + extractor := NewDashAlertExtractor(dash, cmd.OrgId, cmd.User) alerts, err := extractor.GetAlerts() if err != nil { return err diff --git a/pkg/services/cache/cache.go b/pkg/services/cache/cache.go new file mode 100644 index 00000000000..93b2cf76e26 --- /dev/null +++ b/pkg/services/cache/cache.go @@ -0,0 +1,17 @@ +package cache + +import ( + "time" + + gocache "github.com/patrickmn/go-cache" +) + +type CacheService struct { + *gocache.Cache +} + +func New(defaultExpiration, cleanupInterval time.Duration) *CacheService { + return &CacheService{ + Cache: gocache.New(defaultExpiration, cleanupInterval), + } +} diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index 8eb7f4a6e72..b52d1845a0b 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -90,6 +90,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlertsCmd := models.ValidateDashboardAlertsCommand{ OrgId: dto.OrgId, Dashboard: dash, + User: dto.User, } if err := bus.Dispatch(&validateAlertsCmd); err != nil { @@ -159,8 +160,8 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, dto *SaveDashboardDTO) error { alertCmd := models.UpdateDashboardAlertsCommand{ OrgId: dto.OrgId, - UserId: dto.User.UserId, Dashboard: cmd.Result, + User: dto.User, } if err := bus.Dispatch(&alertCmd); err != nil { diff --git a/pkg/services/datasources/cache.go b/pkg/services/datasources/cache.go new file mode 100644 index 00000000000..0cd2bae63b5 --- /dev/null +++ b/pkg/services/datasources/cache.go @@ -0,0 +1,53 @@ +package datasources + +import ( + "fmt" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/cache" +) + +type CacheService interface { + GetDatasource(datasourceID int64, user *m.SignedInUser, skipCache bool) (*m.DataSource, error) +} + +type CacheServiceImpl struct { + Bus bus.Bus `inject:""` + CacheService *cache.CacheService `inject:""` +} + +func init() { + registry.Register(®istry.Descriptor{ + Name: "DatasourceCacheService", + Instance: &CacheServiceImpl{}, + InitPriority: registry.Low, + }) +} + +func (dc *CacheServiceImpl) Init() error { + return nil +} + +func (dc *CacheServiceImpl) GetDatasource(datasourceID int64, user *m.SignedInUser, skipCache bool) (*m.DataSource, error) { + cacheKey := fmt.Sprintf("ds-%d", datasourceID) + + if !skipCache { + if cached, found := dc.CacheService.Get(cacheKey); found { + ds := cached.(*m.DataSource) + if ds.OrgId == user.OrgId { + return ds, nil + } + } + } + + query := m.GetDataSourceByIdQuery{Id: datasourceID, OrgId: user.OrgId} + if err := dc.Bus.Dispatch(&query); err != nil { + return nil, err + } + + dc.CacheService.Set(cacheKey, query.Result, time.Second*5) + return query.Result, nil +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 1b853d17b5f..bad46c10af4 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -327,6 +327,24 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { if dashboard.IsFolder { deletes = append(deletes, "DELETE FROM dashboard_provisioning WHERE dashboard_id in (select id from dashboard where folder_id = ?)") deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ?") + + dashIds := []struct { + Id int64 + }{} + err := sess.SQL("select id from dashboard where folder_id = ?", dashboard.Id).Find(&dashIds) + if err != nil { + return err + } + + for _, id := range dashIds { + if err := deleteAlertDefinition(id.Id, sess); err != nil { + return nil + } + } + } + + if err := deleteAlertDefinition(dashboard.Id, sess); err != nil { + return nil } for _, sql := range deletes { @@ -337,10 +355,6 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { } } - if err := deleteAlertDefinition(dashboard.Id, sess); err != nil { - return nil - } - return nil }) } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index f904b44c3c8..95b53be9d4a 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -16,6 +16,7 @@ import ( m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/cache" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" @@ -47,8 +48,9 @@ func init() { } type SqlStore struct { - Cfg *setting.Cfg `inject:""` - Bus bus.Bus `inject:""` + Cfg *setting.Cfg `inject:""` + Bus bus.Bus `inject:""` + CacheService *cache.CacheService `inject:""` dbCfg DatabaseConfig engine *xorm.Engine @@ -148,9 +150,11 @@ func (ss *SqlStore) Init() error { // Init repo instances annotations.SetRepository(&SqlAnnotationRepo{}) - ss.Bus.SetTransactionManager(ss) + // Register handlers + ss.addUserQueryAndCommandHandlers() + // ensure admin user if ss.skipEnsureAdmin { return nil @@ -322,6 +326,7 @@ func InitTestDB(t *testing.T) *SqlStore { sqlstore := &SqlStore{} sqlstore.skipEnsureAdmin = true sqlstore.Bus = bus.New() + sqlstore.CacheService = cache.New(5*time.Minute, 10*time.Minute) dbType := migrator.SQLITE diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 72d5654a777..99a77ecabc3 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -15,8 +15,9 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func init() { - //bus.AddHandler("sql", CreateUser) +func (ss *SqlStore) addUserQueryAndCommandHandlers() { + ss.Bus.AddHandler(ss.GetSignedInUserWithCache) + bus.AddHandler("sql", GetUserById) bus.AddHandler("sql", UpdateUser) bus.AddHandler("sql", ChangeUserPassword) @@ -25,7 +26,6 @@ func init() { bus.AddHandler("sql", SetUsingOrg) bus.AddHandler("sql", UpdateUserLastSeenAt) bus.AddHandler("sql", GetUserProfile) - bus.AddHandler("sql", GetSignedInUser) bus.AddHandler("sql", SearchUsers) bus.AddHandler("sql", GetUserOrgList) bus.AddHandler("sql", DeleteUser) @@ -345,6 +345,22 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error { return err } +func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) error { + cacheKey := fmt.Sprintf("signed-in-user-%d-%d", query.UserId, query.OrgId) + if cached, found := ss.CacheService.Get(cacheKey); found { + query.Result = cached.(*m.SignedInUser) + return nil + } + + err := GetSignedInUser(query) + if err != nil { + return err + } + + ss.CacheService.Set(cacheKey, query.Result, time.Second*5) + return nil +} + func GetSignedInUser(query *m.GetSignedInUserQuery) error { orgId := "u.org_id" if query.OrgId > 0 { @@ -389,6 +405,17 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { user.OrgName = "Org missing" } + getTeamsByUserQuery := &m.GetTeamsByUserQuery{OrgId: user.OrgId, UserId: user.UserId} + err = GetTeamsByUser(getTeamsByUserQuery) + if err != nil { + return err + } + + user.Teams = make([]int64, len(getTeamsByUserQuery.Result)) + for i, t := range getTeamsByUserQuery.Result { + user.Teams[i] = t.Id + } + query.Result = &user return err } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 16158ded002..afae642f5b3 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -13,15 +13,12 @@ import ( "regexp" "runtime" "strings" - - "gopkg.in/ini.v1" - - "github.com/go-macaron/session" - "time" + "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/util" + "gopkg.in/ini.v1" ) type Scheme string @@ -34,9 +31,15 @@ const ( ) const ( - DEV string = "development" - PROD string = "production" - TEST string = "test" + DEV = "development" + PROD = "production" + TEST = "test" + APP_NAME = "Grafana" + APP_NAME_ENTERPRISE = "Grafana Enterprise" +) + +var ( + ERR_TEMPLATE_NAME = "error" ) var ( @@ -49,6 +52,7 @@ var ( // build BuildVersion string BuildCommit string + BuildBranch string BuildStamp int64 IsEnterprise bool ApplicationName string @@ -209,12 +213,10 @@ type Cfg struct { RendererLimitAlerting int DisableBruteForceLoginProtection bool - - TempDataLifetime time.Duration - - MetricsEndpointEnabled bool - - EnableAlphaPanels bool + TempDataLifetime time.Duration + MetricsEndpointEnabled bool + EnableAlphaPanels bool + EnterpriseLicensePath string } type CommandLineArgs struct { @@ -533,9 +535,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { // Temporary keep global, to make refactor in steps Raw = cfg.Raw - ApplicationName = "Grafana" + ApplicationName = APP_NAME if IsEnterprise { - ApplicationName += " Enterprise" + ApplicationName = APP_NAME_ENTERPRISE } Env = iniFile.Section("").Key("app_mode").MustString("development") @@ -715,6 +717,10 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { imageUploadingSection := iniFile.Section("external_image_storage") ImageUploadProvider = imageUploadingSection.Key("provider").MustString("") + + enterprise := iniFile.Section("enterprise") + cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt")) + return nil } diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index b74af76f09a..1a860519f2b 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -35,6 +35,7 @@ type CustomMetricsCache struct { var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache +var regionCache sync.Map func init() { metricsMap = map[string][]string{ @@ -45,6 +46,7 @@ func init() { "AWS/Billing": {"EstimatedCharges"}, "AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"}, "AWS/CloudSearch": {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"}, + "AWS/Connect": {"CallsBreachingConcurrencyQuota", "CallBackNotDialableNumber", "CallRecordingUploadError", "CallsPerInterval", "ConcurrentCalls", "ConcurrentCallsPercentage", "ContactFlowErrors", "ContactFlowFatalErrors", "LongestQueueWaitTime", "MissedCalls", "MisconfiguredPhoneNumbers", "PublicSigningKeyUsage", "QueueCapacityExceededError", "QueueSize", "ThrottledCalls", "ToInstancePacketLossRate"}, "AWS/DMS": {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"}, "AWS/DX": {"ConnectionState", "ConnectionBpsEgress", "ConnectionBpsIngress", "ConnectionPpsEgress", "ConnectionPpsIngress", "ConnectionCRCErrorCount", "ConnectionLightLevelTx", "ConnectionLightLevelRx"}, "AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"}, @@ -119,6 +121,7 @@ func init() { "AWS/Billing": {"ServiceName", "LinkedAccount", "Currency"}, "AWS/CloudFront": {"DistributionId", "Region"}, "AWS/CloudSearch": {}, + "AWS/Connect": {"InstanceId", "MetricGroup", "Participant", "QueueName", "Stream Type", "Type of Connection"}, "AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"}, "AWS/DX": {"ConnectionId"}, "AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"}, @@ -233,13 +236,20 @@ func parseMultiSelectValue(input string) []string { // Whenever this list is updated, frontend list should also be updated. // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + dsInfo := e.getDsInfo("default") + profile := dsInfo.Profile + if cache, ok := regionCache.Load(profile); ok { + if cache2, ok2 := cache.([]suggestData); ok2 { + return cache2, nil + } + } + regions := []string{ "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1", "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2", "cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1", } - - err := e.ensureClientSession("us-east-1") + err := e.ensureClientSession("default") if err != nil { return nil, err } @@ -269,6 +279,7 @@ func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *s for _, region := range regions { result = append(result, suggestData{Text: region, Value: region}) } + regionCache.Store(profile, result) return result, nil } diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index e3903e8027e..34c3379b4df 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -9,20 +9,26 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" "github.com/bmizerany/assert" + "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" ) type mockedEc2 struct { ec2iface.EC2API - Resp ec2.DescribeInstancesOutput + Resp ec2.DescribeInstancesOutput + RespRegions ec2.DescribeRegionsOutput } func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error { fn(&m.Resp, true) return nil } +func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeRegionsOutput, error) { + return &m.RespRegions, nil +} func TestCloudWatchMetrics(t *testing.T) { @@ -82,6 +88,31 @@ func TestCloudWatchMetrics(t *testing.T) { }) }) + Convey("When calling handleGetRegions", t, func() { + executor := &CloudWatchExecutor{ + ec2Svc: mockedEc2{RespRegions: ec2.DescribeRegionsOutput{ + Regions: []*ec2.Region{ + { + RegionName: aws.String("ap-northeast-2"), + }, + }, + }}, + } + jsonData := simplejson.New() + jsonData.Set("defaultRegion", "default") + executor.DataSource = &models.DataSource{ + JsonData: jsonData, + SecureJsonData: securejsondata.SecureJsonData{}, + } + + result, _ := executor.handleGetRegions(context.Background(), simplejson.New(), &tsdb.TsdbQuery{}) + + Convey("Should return regions", func() { + So(result[0].Text, ShouldEqual, "ap-northeast-1") + So(result[1].Text, ShouldEqual, "ap-northeast-2") + }) + }) + Convey("When calling handleGetEc2InstanceAttribute", t, func() { executor := &CloudWatchExecutor{ ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{ diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 2960ba0edc4..ff0ed8d0620 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -164,14 +164,12 @@ func formatTimeRange(input string) string { func fixIntervalFormat(target string) string { rMinute := regexp.MustCompile(`'(\d+)m'`) - rMin := regexp.MustCompile("m") target = rMinute.ReplaceAllStringFunc(target, func(m string) string { - return rMin.ReplaceAllString(m, "min") + return strings.Replace(m, "m", "min", -1) }) rMonth := regexp.MustCompile(`'(\d+)M'`) - rMon := regexp.MustCompile("M") target = rMonth.ReplaceAllStringFunc(target, func(M string) string { - return rMon.ReplaceAllString(M, "mon") + return strings.Replace(M, "M", "mon", -1) }) return target } diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index a037aa9277a..839f805568e 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -60,7 +60,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil + return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], m.timeRange.GetFromAsSecondsEpoch(), m.timeRange.GetToAsSecondsEpoch()), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index 3c9a5a26c94..24bf18873d5 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -60,7 +60,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { @@ -92,7 +92,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { @@ -112,7 +112,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { diff --git a/pkg/tsdb/stackdriver/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go index b33d33fb41c..2ad47cc4b83 100644 --- a/pkg/tsdb/stackdriver/stackdriver.go +++ b/pkg/tsdb/stackdriver/stackdriver.go @@ -186,8 +186,7 @@ func reverse(s string) string { } func interpolateFilterWildcards(value string) string { - re := regexp.MustCompile("[*]") - matches := len(re.FindAllStringIndex(value, -1)) + matches := strings.Count(value, "*") if matches == 2 && strings.HasSuffix(value, "*") && strings.HasPrefix(value, "*") { value = strings.Replace(value, "*", "", -1) value = fmt.Sprintf(`has_substring("%s")`, value) diff --git a/public/app/core/actions/appNotification.ts b/public/app/core/actions/appNotification.ts new file mode 100644 index 00000000000..b79b642eef1 --- /dev/null +++ b/public/app/core/actions/appNotification.ts @@ -0,0 +1,28 @@ +import { AppNotification } from 'app/types/'; + +export enum ActionTypes { + AddAppNotification = 'ADD_APP_NOTIFICATION', + ClearAppNotification = 'CLEAR_APP_NOTIFICATION', +} + +interface AddAppNotificationAction { + type: ActionTypes.AddAppNotification; + payload: AppNotification; +} + +interface ClearAppNotificationAction { + type: ActionTypes.ClearAppNotification; + payload: number; +} + +export type Action = AddAppNotificationAction | ClearAppNotificationAction; + +export const clearAppNotification = (appNotificationId: number) => ({ + type: ActionTypes.ClearAppNotification, + payload: appNotificationId, +}); + +export const notifyApp = (appNotification: AppNotification) => ({ + type: ActionTypes.AddAppNotification, + payload: appNotification, +}); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index 451a13dae99..f7ce2dda945 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -1,4 +1,5 @@ import { updateLocation } from './location'; import { updateNavIndex, UpdateNavIndexAction } from './navModel'; +import { notifyApp, clearAppNotification } from './appNotification'; -export { updateLocation, updateNavIndex, UpdateNavIndexAction }; +export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification }; diff --git a/public/app/core/actions/user.ts b/public/app/core/actions/user.ts new file mode 100644 index 00000000000..dba0588c058 --- /dev/null +++ b/public/app/core/actions/user.ts @@ -0,0 +1,28 @@ +import { ThunkAction } from 'redux-thunk'; +import { getBackendSrv } from '../services/backend_srv'; +import { DashboardAcl, DashboardSearchHit, StoreState } from '../../types'; + +type ThunkResult = ThunkAction; + +export type Action = LoadStarredDashboardsAction; + +export enum ActionTypes { + LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS', +} + +interface LoadStarredDashboardsAction { + type: ActionTypes.LoadStarredDashboards; + payload: DashboardSearchHit[]; +} + +const starredDashboardsLoaded = (dashboards: DashboardAcl[]) => ({ + type: ActionTypes.LoadStarredDashboards, + payload: dashboards, +}); + +export function loadStarredDashboards(): ThunkResult { + return async dispatch => { + const starredDashboards = await getBackendSrv().search({ starred: true }); + dispatch(starredDashboardsLoaded(starredDashboards)); + }; +} diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 6974d40aac8..7be28272f11 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -5,10 +5,12 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA'; import { SearchResult } from './components/search/SearchResult'; import { TagFilter } from './components/TagFilter/TagFilter'; import { SideMenu } from './components/sidemenu/SideMenu'; +import AppNotificationList from './components/AppNotifications/AppNotificationList'; export function registerAngularDirectives() { react2AngularDirective('passwordStrength', PasswordStrength, ['password']); react2AngularDirective('sidemenu', SideMenu, []); + react2AngularDirective('appNotificationsList', AppNotificationList, []); react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); react2AngularDirective('emptyListCta', EmptyListCTA, ['model']); react2AngularDirective('searchResult', SearchResult, []); diff --git a/public/app/core/components/Animations/SlideDown.tsx b/public/app/core/components/Animations/SlideDown.tsx index 4d515f98f16..70dacd73849 100644 --- a/public/app/core/components/Animations/SlideDown.tsx +++ b/public/app/core/components/Animations/SlideDown.tsx @@ -1,15 +1,22 @@ -import React from 'react'; +import React from 'react'; import Transition from 'react-transition-group/Transition'; -const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value. +interface Style { + transition?: string; + overflow?: string; +} + +// When animating using max-height we need to use a static value. // If this is not enough, pass in + + +
+
{appNotification.title}
+
{appNotification.text}
+
+ + + ); + } +} diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx new file mode 100644 index 00000000000..c91f8372384 --- /dev/null +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -0,0 +1,60 @@ +import React, { PureComponent } from 'react'; +import appEvents from 'app/core/app_events'; +import AppNotificationItem from './AppNotificationItem'; +import { notifyApp, clearAppNotification } from 'app/core/actions'; +import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; +import { AppNotification, StoreState } from 'app/types'; +import { + createErrorNotification, + createSuccessNotification, + createWarningNotification, +} from '../../copy/appNotification'; + +export interface Props { + appNotifications: AppNotification[]; + notifyApp: typeof notifyApp; + clearAppNotification: typeof clearAppNotification; +} + +export class AppNotificationList extends PureComponent { + componentDidMount() { + const { notifyApp } = this.props; + + appEvents.on('alert-warning', options => notifyApp(createWarningNotification(options[0], options[1]))); + appEvents.on('alert-success', options => notifyApp(createSuccessNotification(options[0], options[1]))); + appEvents.on('alert-error', options => notifyApp(createErrorNotification(options[0], options[1]))); + } + + onClearAppNotification = id => { + this.props.clearAppNotification(id); + }; + + render() { + const { appNotifications } = this.props; + + return ( +
+ {appNotifications.map((appNotification, index) => { + return ( + this.onClearAppNotification(id)} + /> + ); + })} +
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + appNotifications: state.appNotifications.appNotifications, +}); + +const mapDispatchToProps = { + notifyApp, + clearAppNotification, +}; + +export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps); diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx index 4af60f3c839..21700bb4d03 100644 --- a/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx +++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx @@ -7,6 +7,7 @@ const model = { buttonIcon: 'ga css class', buttonLink: 'http://url/to/destination', buttonTitle: 'Click me', + onClick: jest.fn(), proTip: 'This is a tip', proTipLink: 'http://url/to/tip/destination', proTipLinkTitle: 'Learn more', diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx index 5ece360e36a..ae0e39cc26d 100644 --- a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx +++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx @@ -11,6 +11,7 @@ class EmptyListCTA extends Component { buttonIcon, buttonLink, buttonTitle, + onClick, proTip, proTipLink, proTipLinkTitle, @@ -19,7 +20,7 @@ class EmptyListCTA extends Component { return (
{title}
- + {buttonTitle} diff --git a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap index 6d47c984d5e..b85660bcc6f 100644 --- a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap +++ b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap @@ -12,6 +12,7 @@ exports[`EmptyListCTA renders correctly 1`] = ` = props => { - return
; -}; - -export default InfoPopover; diff --git a/public/app/core/components/Forms/Forms.tsx b/public/app/core/components/Label/Label.tsx similarity index 83% rename from public/app/core/components/Forms/Forms.tsx rename to public/app/core/components/Label/Label.tsx index 543e1a1d6df..9b8fb6c6e19 100644 --- a/public/app/core/components/Forms/Forms.tsx +++ b/public/app/core/components/Label/Label.tsx @@ -5,11 +5,12 @@ interface Props { tooltip?: string; for?: string; children: ReactNode; + width?: number; } export const Label: SFC = props => { return ( - + {props.children} {props.tooltip && ( diff --git a/public/app/core/components/Picker/SimplePicker.tsx b/public/app/core/components/Picker/SimplePicker.tsx new file mode 100644 index 00000000000..6c9e8aca199 --- /dev/null +++ b/public/app/core/components/Picker/SimplePicker.tsx @@ -0,0 +1,46 @@ +import React, { SFC } from 'react'; +import Select from 'react-select'; +import DescriptionOption from './DescriptionOption'; +import ResetStyles from './ResetStyles'; + +interface Props { + className?: string; + defaultValue: any; + getOptionLabel: (item: any) => string; + getOptionValue: (item: any) => string; + onSelected: (item: any) => {} | void; + options: any[]; + placeholder?: string; + width: number; +} + +const SimplePicker: SFC = ({ + className, + defaultValue, + getOptionLabel, + getOptionValue, + onSelected, + options, + placeholder, + width, +}) => { + return ( + this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)} + /> +
+
+ Role + + + +
+
+ +
+
+ + +
+ ); + } + + renderApiKeyList() { + const { isAdding } = this.state; + const { apiKeys, searchQuery } = this.props; + + return ( +
+
+
+ +
+ +
+ +
+ + {this.renderAddApiKeyForm()} + +

Existing Keys

+ + + + + + + + {apiKeys.length > 0 ? ( + + {apiKeys.map(key => { + return ( + + + + + + ); + })} + + ) : null} +
NameRole +
{key.name}{key.role} + this.onDeleteApiKey(key)} /> +
+
+ ); } render() { - const { newApiKey, isAdding } = this.state; - const { hasFetched, navModel, searchQuery } = this.props; + const { hasFetched, navModel, apiKeysCount } = this.props; return (
-
-
-
- -
- -
- -
- - -
- -
Add API Key
-
-
-
- Key name - this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)} - /> -
-
- Role - - - -
-
- -
-
-
-
-
- {hasFetched ? this.renderTable() : } -
+ {hasFetched ? ( + apiKeysCount > 0 ? ( + this.renderApiKeyList() + ) : ( + this.renderEmptyList() + ) + ) : ( + + )}
); } @@ -220,6 +261,7 @@ function mapStateToProps(state) { navModel: getNavModel(state.navIndex, 'apikeys'), apiKeys: getApiKeys(state.apiKeys), searchQuery: state.apiKeys.searchQuery, + apiKeysCount: getApiKeysCount(state.apiKeys), hasFetched: state.apiKeys.hasFetched, }; } 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 b1cac8469be..7ede9618250 100644 --- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -1,276 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Render should render API keys table 1`] = ` +exports[`Render should render API keys table if there are any keys 1`] = `
-
-
-
- -
-
- -
- -
- -
- Add API Key -
-
-
-
- - Key name - - -
-
- - Role - - - - -
-
- -
-
-
-
-
-

- Existing Keys -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Name - - Role - -
- test-1 - - Viewer - - - - -
- test-2 - - Viewer - - - - -
- test-3 - - Viewer - - - - -
- test-4 - - Viewer - - - - -
- test-5 - - Viewer - - - - -
-
+
`; -exports[`Render should render component 1`] = ` +exports[`Render should render CTA if there are no API keys 1`] = `
-
-
- -
-
- -
+ @@ -406,9 +127,6 @@ exports[`Render should render component 1`] = `
-
`; diff --git a/public/app/features/api-keys/state/selectors.ts b/public/app/features/api-keys/state/selectors.ts index 8065c252e85..789237ae9a3 100644 --- a/public/app/features/api-keys/state/selectors.ts +++ b/public/app/features/api-keys/state/selectors.ts @@ -1,5 +1,7 @@ import { ApiKeysState } from 'app/types'; +export const getApiKeysCount = (state: ApiKeysState) => state.keys.length; + export const getApiKeys = (state: ApiKeysState) => { const regex = RegExp(state.searchQuery, 'i'); diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index c34b9ddaff2..5871a579f3c 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -1,6 +1,12 @@ +// Utils import config from 'app/core/config'; - +import appEvents from 'app/core/app_events'; import coreModule from 'app/core/core_module'; + +// Services +import { AnnotationsSrv } from '../annotations/annotations_srv'; + +// Types import { DashboardModel } from './dashboard_model'; import { PanelModel } from './panel_model'; @@ -21,6 +27,7 @@ export class DashboardCtrl { private dashboardSrv, private unsavedChangesSrv, private dashboardViewStateSrv, + private annotationsSrv: AnnotationsSrv, public playlistSrv ) { // temp hack due to way dashboards are loaded @@ -49,6 +56,7 @@ 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 // the rest of the dashboard can load @@ -72,7 +80,7 @@ export class DashboardCtrl { this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); this.setWindowTitleAndTheme(); - this.$scope.appEvent('dashboard-initialized', dashboard); + appEvents.emit('dashboard-initialized', dashboard); }) .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); } diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 1f5fa4cbe12..fe55e64634f 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -21,15 +21,14 @@ function GridWrapper({ className, isResizable, isDraggable, + isFullscreen, }) { - if (size.width === 0) { - console.log('size is zero!'); - } - const width = size.width > 0 ? size.width : lastGridWidth; if (width !== lastGridWidth) { - onWidthChange(); - lastGridWidth = width; + if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) { + onWidthChange(); + lastGridWidth = width; + } } return ( @@ -197,6 +196,7 @@ export class DashboardGrid extends React.Component { onDragStop={this.onDragStop} onResize={this.onResize} onResizeStop={this.onResizeStop} + isFullscreen={this.props.dashboard.meta.fullscreen} > {this.renderPanels()} diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 82b366d8126..953dfd62368 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -21,6 +21,7 @@ export interface Props { export interface State { refreshCounter: number; + renderCounter: number; timeRange?: TimeRange; } @@ -30,11 +31,13 @@ export class PanelChrome extends PureComponent { this.state = { refreshCounter: 0, + renderCounter: 0, }; } componentDidMount() { this.props.panel.events.on('refresh', this.onRefresh); + this.props.panel.events.on('render', this.onRender); this.props.dashboard.panelInitialized(this.props.panel); } @@ -52,6 +55,13 @@ export class PanelChrome extends PureComponent { }); }; + onRender = () => { + console.log('onRender'); + this.setState({ + renderCounter: this.state.renderCounter + 1, + }); + }; + get isVisible() { return !this.props.dashboard.otherPanelInFullscreen(this.props.panel); } @@ -59,9 +69,11 @@ export class PanelChrome extends PureComponent { render() { const { panel, dashboard } = this.props; const { datasource, targets } = panel; - const { refreshCounter, timeRange } = this.state; + const { timeRange, renderCounter, refreshCounter } = this.state; const PanelComponent = this.props.component; + console.log('Panel chrome render'); + return (
@@ -74,7 +86,16 @@ export class PanelChrome extends PureComponent { refreshCounter={refreshCounter} > {({ loading, timeSeries }) => { - return ; + console.log('panelcrome inner render'); + return ( + + ); }}
diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/dashgrid/PanelEditor.tsx index 26ac8b7d2c1..8f988ba2b86 100644 --- a/public/app/features/dashboard/dashgrid/PanelEditor.tsx +++ b/public/app/features/dashboard/dashgrid/PanelEditor.tsx @@ -1,12 +1,15 @@ -import React from 'react'; +import React, { PureComponent } from 'react'; import classNames from 'classnames'; + +import { QueriesTab } from './QueriesTab'; +import { VizTypePicker } from './VizTypePicker'; + +import { store } from 'app/store/store'; +import { updateLocation } from 'app/core/actions'; + import { PanelModel } from '../panel_model'; import { DashboardModel } from '../dashboard_model'; -import { store } from 'app/store/configureStore'; -import { QueriesTab } from './QueriesTab'; import { PanelPlugin, PluginExports } from 'app/types/plugins'; -import { VizTypePicker } from './VizTypePicker'; -import { updateLocation } from 'app/core/actions'; interface PanelEditorProps { panel: PanelModel; @@ -22,7 +25,7 @@ interface PanelEditorTab { icon: string; } -export class PanelEditor extends React.Component { +export class PanelEditor extends PureComponent { tabs: PanelEditorTab[]; constructor(props) { @@ -39,16 +42,21 @@ export class PanelEditor extends React.Component { } renderPanelOptions() { - const { pluginExports } = this.props; + const { pluginExports, panel } = this.props; - if (pluginExports.PanelOptions) { - const PanelOptions = pluginExports.PanelOptions; - return ; + if (pluginExports.PanelOptionsComponent) { + const OptionsComponent = pluginExports.PanelOptionsComponent; + return ; } else { return

Visualization has no options

; } } + onPanelOptionsChanged = (options: any) => { + this.props.panel.updateOptions(options); + this.forceUpdate(); + }; + renderVizTab() { return (
@@ -70,6 +78,7 @@ export class PanelEditor extends React.Component { partial: true, }) ); + this.forceUpdate(); }; render() { diff --git a/public/app/features/dashboard/dashgrid/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader.tsx index 12d5cd37253..276f12182fe 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import { PanelModel } from '../panel_model'; import { DashboardModel } from '../dashboard_model'; -import { store } from 'app/store/configureStore'; +import { store } from 'app/store/store'; import { updateLocation } from 'app/core/actions'; interface PanelHeaderProps { diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts index ebf8a6bb224..ed032a118fe 100644 --- a/public/app/features/dashboard/panel_model.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -60,6 +60,21 @@ export class PanelModel { _.defaultsDeep(this, _.cloneDeep(defaults)); } + getOptions() { + return this[this.getOptionsKey()] || {}; + } + + updateOptions(options: object) { + const update: any = {}; + update[this.getOptionsKey()] = options; + Object.assign(this, update); + this.render(); + } + + private getOptionsKey() { + return this.type + 'Options'; + } + getSaveModel() { const model: any = {}; for (const property in this) { @@ -121,10 +136,6 @@ export class PanelModel { this.events.emit('panel-initialized'); } - initEditMode() { - this.events.emit('panel-init-edit-mode'); - } - changeType(pluginId: string) { this.type = pluginId; @@ -133,6 +144,7 @@ export class PanelModel { } destroy() { + this.events.emit('panel-teardown'); this.events.removeAllListeners(); } } diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/permissions/DashboardPermissions.tsx index 5651242a485..c07bef42930 100644 --- a/public/app/features/dashboard/permissions/DashboardPermissions.tsx +++ b/public/app/features/dashboard/permissions/DashboardPermissions.tsx @@ -1,5 +1,4 @@ import React, { PureComponent } from 'react'; -import { connect } from 'react-redux'; import Tooltip from 'app/core/components/Tooltip/Tooltip'; import SlideDown from 'app/core/components/Animations/SlideDown'; import { StoreState, FolderInfo } from 'app/types'; @@ -13,7 +12,7 @@ import { 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 { store } from 'app/store/configureStore'; +import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; export interface Props { dashboardId: number; @@ -95,13 +94,6 @@ export class DashboardPermissions extends PureComponent { } } -function connectWithStore(WrappedComponent, ...args) { - const ConnectedWrappedComponent = connect(...args)(WrappedComponent); - return props => { - return ; - }; -} - const mapStateToProps = (state: StoreState) => ({ permissions: state.dashboard.permissions, }); diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/settings/settings.ts index b6a70ee4b98..1e8d96a54cb 100755 --- a/public/app/features/dashboard/settings/settings.ts +++ b/public/app/features/dashboard/settings/settings.ts @@ -32,9 +32,9 @@ export class SettingsCtrl { this.$scope.$on('$destroy', () => { this.dashboard.updateSubmenuVisibility(); - this.dashboard.startRefresh(); setTimeout(() => { this.$rootScope.appEvent('dash-scroll', { restore: true }); + this.dashboard.startRefresh(); }); }); diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 749edef58fb..bc35ff31ff0 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -13,6 +13,7 @@ import { export enum ActionTypes { LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS', + LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS', } export interface LoadDashboardPermissionsAction { @@ -20,7 +21,12 @@ export interface LoadDashboardPermissionsAction { payload: DashboardAcl[]; } -export type Action = LoadDashboardPermissionsAction; +export interface LoadStarredDashboardsAction { + type: ActionTypes.LoadStarredDashboards; + payload: DashboardAcl[]; +} + +export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction; type ThunkResult = ThunkAction; diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts index c5b67f58ac9..ced8866aad8 100644 --- a/public/app/features/dashboard/state/reducers.test.ts +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -1,6 +1,6 @@ import { Action, ActionTypes } from './actions'; import { OrgRole, PermissionLevel, DashboardState } from 'app/types'; -import { inititalState, dashboardReducer } from './reducers'; +import { initialState, dashboardReducer } from './reducers'; describe('dashboard reducer', () => { describe('loadDashboardPermissions', () => { @@ -14,7 +14,7 @@ describe('dashboard reducer', () => { { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, ], }; - state = dashboardReducer(inititalState, action); + state = dashboardReducer(initialState, action); }); it('should add permissions to state', async () => { diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 5100529d973..8a79a6c9f77 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -2,11 +2,11 @@ import { DashboardState } from 'app/types'; import { Action, ActionTypes } from './actions'; import { processAclItems } from 'app/core/utils/acl'; -export const inititalState: DashboardState = { +export const initialState: DashboardState = { permissions: [], }; -export const dashboardReducer = (state = inititalState, action: Action): DashboardState => { +export const dashboardReducer = (state = initialState, action: Action): DashboardState => { switch (action.type) { case ActionTypes.LoadDashboardPermissions: return { diff --git a/public/app/features/dashboard/upload.ts b/public/app/features/dashboard/upload.ts index 42871327eb6..ec4ad9a03cb 100644 --- a/public/app/features/dashboard/upload.ts +++ b/public/app/features/dashboard/upload.ts @@ -11,7 +11,7 @@ const template = ` `; /** @ngInject */ -function uploadDashboardDirective(timer, alertSrv, $location) { +function uploadDashboardDirective(timer, $location) { return { restrict: 'E', template: template, @@ -59,7 +59,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) { // Something elem[0].addEventListener('change', file_selected, false); } else { - alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error'); + appEvents.emit('alert-error', ['Oops', 'The HTML5 File APIs are not fully supported in this browser']); } }, }; diff --git a/public/app/features/datasources/AddDataSourcePermissions.tsx b/public/app/features/datasources/AddDataSourcePermissions.tsx deleted file mode 100644 index 2f9b38ef48e..00000000000 --- a/public/app/features/datasources/AddDataSourcePermissions.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { PureComponent } from 'react'; -import { UserPicker } from 'app/core/components/Picker/UserPicker'; -import { Team, TeamPicker } from 'app/core/components/Picker/TeamPicker'; -import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker'; -import { dataSourceAclLevels, AclTarget, DataSourcePermissionLevel } from 'app/types/acl'; -import { User } from 'app/types'; - -export interface Props { - onAddPermission: (state) => void; - onCancel: () => void; -} - -interface State { - userId: number; - teamId: number; - type: AclTarget; - permission: DataSourcePermissionLevel; -} - -export class AddDataSourcePermissions extends PureComponent { - cleanState = () => ({ - userId: 0, - teamId: 0, - type: AclTarget.Team, - permission: DataSourcePermissionLevel.Query, - }); - - state = this.cleanState(); - - isValid() { - switch (this.state.type) { - case AclTarget.Team: - return this.state.teamId > 0; - case AclTarget.User: - return this.state.userId > 0; - } - return true; - } - - onTeamSelected = (team: Team) => { - this.setState({ teamId: team ? team.id : 0 }); - }; - - onUserSelected = (user: User) => { - this.setState({ userId: user ? user.id : 0 }); - }; - - onPermissionChanged = (permission: OptionWithDescription) => { - this.setState({ permission: permission.value }); - }; - - onTypeChanged = event => { - const type = event.target.value as AclTarget; - - this.setState({ type: type, userId: 0, teamId: 0 }); - }; - - onSubmit = async event => { - event.preventDefault(); - - await this.props.onAddPermission(this.state); - this.setState(this.cleanState()); - }; - - render() { - const { onCancel } = this.props; - const { type, permission } = this.state; - - const pickerClassName = 'width-20'; - const aclTargets = [{ value: AclTarget.Team, text: 'Team' }, { value: AclTarget.User, text: 'User' }]; - - return ( -
- -
-
Add Permission For
-
-
- -
- {type === AclTarget.User && ( -
- -
- )} - - {type === AclTarget.Team && ( -
- -
- )} -
- -
-
- -
-
-
-
- ); - } -} - -export default AddDataSourcePermissions; diff --git a/public/app/features/datasources/settings/BasicSettings.test.tsx b/public/app/features/datasources/settings/BasicSettings.test.tsx new file mode 100644 index 00000000000..6729830bb75 --- /dev/null +++ b/public/app/features/datasources/settings/BasicSettings.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import BasicSettings, { Props } from './BasicSettings'; + +const setup = () => { + const props: Props = { + dataSourceName: 'Graphite', + onChange: jest.fn(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/BasicSettings.tsx b/public/app/features/datasources/settings/BasicSettings.tsx index f4c26f1e27f..adaec898dbc 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 '../../../core/components/Forms/Forms'; +import { Label } from 'app/core/components/Label/Label'; export interface Props { dataSourceName: string; diff --git a/public/app/features/datasources/settings/ButtonRow.test.tsx b/public/app/features/datasources/settings/ButtonRow.test.tsx new file mode 100644 index 00000000000..0acab8941ff --- /dev/null +++ b/public/app/features/datasources/settings/ButtonRow.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ButtonRow, { Props } from './ButtonRow'; + +const setup = (propOverrides?: object) => { + const props: Props = { + isReadOnly: true, + onSubmit: jest.fn(), + onDelete: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render with buttons enabled', () => { + const wrapper = setup({ + isReadOnly: false, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/DataSourceSettings.test.tsx b/public/app/features/datasources/settings/DataSourceSettings.test.tsx new file mode 100644 index 00000000000..222587d9249 --- /dev/null +++ b/public/app/features/datasources/settings/DataSourceSettings.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourceSettings, Props } from './DataSourceSettings'; +import { DataSource, DataSourceTest, NavModel } from '../../../types'; +import { getMockDataSource } from '../__mocks__/dataSourcesMocks'; +import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + dataSource: getMockDataSource(), + dataSourceMeta: getMockPlugin(), + pageId: 1, + deleteDataSource: jest.fn(), + loadDataSource: jest.fn(), + setDataSourceName: jest.fn(), + updateDataSource: jest.fn(), + testing: {} as DataSourceTest, + clearTesting: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render loader', () => { + const wrapper = setup({ + dataSource: {} as DataSource, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render beta info text', () => { + const wrapper = setup({ + dataSourceMeta: { ...getMockPlugin(), state: 'beta' }, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render alpha info text', () => { + const wrapper = setup({ + dataSourceMeta: { ...getMockPlugin(), state: 'alpha' }, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render is ready only message', () => { + const wrapper = setup({ + dataSource: { ...getMockDataSource(), readOnly: true }, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/settings/DataSourceSettings.tsx b/public/app/features/datasources/settings/DataSourceSettings.tsx index f7b870064c5..e9a1b49c02a 100644 --- a/public/app/features/datasources/settings/DataSourceSettings.tsx +++ b/public/app/features/datasources/settings/DataSourceSettings.tsx @@ -1,31 +1,33 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { DataSource, NavModel, Plugin } from 'app/types/'; +import { DataSource, DataSourceTest, NavModel, Plugin } from 'app/types/'; import PageHeader from '../../../core/components/PageHeader/PageHeader'; import PageLoader from '../../../core/components/PageLoader/PageLoader'; import PluginSettings from './PluginSettings'; import BasicSettings from './BasicSettings'; import ButtonRow from './ButtonRow'; -import { deleteDataSource, loadDataSource, setDataSourceName, updateDataSource } from '../state/actions'; +import appEvents from '../../../core/app_events'; +import { clearTesting, deleteDataSource, loadDataSource, setDataSourceName, updateDataSource } from '../state/actions'; import { getNavModel } from '../../../core/selectors/navModel'; import { getRouteParamsId } from '../../../core/selectors/location'; import { getDataSource, getDataSourceMeta } from '../state/selectors'; -import appEvents from '../../../core/app_events'; export interface Props { navModel: NavModel; dataSource: DataSource; dataSourceMeta: Plugin; pageId: number; + testing: DataSourceTest; deleteDataSource: typeof deleteDataSource; loadDataSource: typeof loadDataSource; setDataSourceName: typeof setDataSourceName; updateDataSource: typeof updateDataSource; + clearTesting: typeof clearTesting; } interface State { - name: string; - showNamePopover: boolean; + dataSource: DataSource; + hasClosedTest: boolean; } enum DataSourceStates { @@ -34,16 +36,38 @@ enum DataSourceStates { } export class DataSourceSettings extends PureComponent { + state = { + dataSource: {} as DataSource, + hasClosedTest: false, + }; + async componentDidMount() { const { loadDataSource, pageId } = this.props; await loadDataSource(pageId); } + componentDidUpdate(prevProps) { + const { clearTesting } = this.props; + + if (!this.state.hasClosedTest && prevProps.testing.status === 'success') { + this.setState({ hasClosedTest: true }); + + setTimeout(() => { + clearTesting(); + this.setState({ hasClosedTest: false }); + }, 3000); + } + } + + componentWillUnmount() { + this.props.clearTesting(); + } + onSubmit = event => { event.preventDefault(); - this.props.updateDataSource(); + this.props.updateDataSource({ ...this.state.dataSource, name: this.props.dataSource.name }); }; onDelete = () => { @@ -62,6 +86,12 @@ export class DataSourceSettings extends PureComponent { this.props.deleteDataSource(); }; + onModelChange = dataSource => { + this.setState({ + dataSource: dataSource, + }); + }; + isReadOnly() { return this.props.dataSource.readOnly === true; } @@ -102,7 +132,7 @@ export class DataSourceSettings extends PureComponent { } render() { - const { dataSource, dataSourceMeta, navModel } = this.props; + const { dataSource, dataSourceMeta, navModel, testing } = this.props; return (
@@ -120,9 +150,37 @@ export class DataSourceSettings extends PureComponent { {this.shouldRenderInfoBox() &&
{this.getInfoText()}
} - {this.isReadOnly() - ? this.renderIsReadOnlyMessage() - : dataSourceMeta.module && } + {this.isReadOnly() && this.renderIsReadOnlyMessage()} + {dataSourceMeta.module && ( + + )} + +
+ {testing.inProgress && ( +
+ Testing.... +
+ )} + {!testing.inProgress && + testing.status && ( +
+
+ {testing.status === 'error' ? ( + + ) : ( + + )} +
+
+
{testing.message}
+
+
+ )} +
this.onSubmit(event)} @@ -147,6 +205,7 @@ function mapStateToProps(state) { dataSource: getDataSource(state.dataSources, pageId), dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type), pageId: pageId, + testing: state.dataSources.testing, }; } @@ -155,6 +214,7 @@ const mapDispatchToProps = { loadDataSource, setDataSourceName, updateDataSource, + clearTesting, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceSettings)); diff --git a/public/app/features/datasources/settings/PluginSettings.tsx b/public/app/features/datasources/settings/PluginSettings.tsx index ae94fbc0685..e0b742985cc 100644 --- a/public/app/features/datasources/settings/PluginSettings.tsx +++ b/public/app/features/datasources/settings/PluginSettings.tsx @@ -1,34 +1,31 @@ -import _ from 'lodash'; import React, { PureComponent } from 'react'; +import _ from 'lodash'; import { DataSource, Plugin } from 'app/types/'; import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; -interface Props { +export interface Props { dataSource: DataSource; dataSourceMeta: Plugin; + onModelChange: (dataSource: DataSource) => void; } export class PluginSettings extends PureComponent { element: any; component: AngularComponent; - scopeProps: any; + scopeProps: { + ctrl: { datasourceMeta: Plugin; current: DataSource }; + onModelChanged: (dataSource: DataSource) => void; + }; constructor(props) { super(props); this.scopeProps = { - ctrl: { - datasourceMeta: this.props.dataSourceMeta, - current: this.props.dataSource, - }, + ctrl: { datasourceMeta: props.dataSourceMeta, current: _.cloneDeep(props.dataSource) }, onModelChanged: this.onModelChanged, }; } - componentDidUpdate() { - this.scopeProps.ctrl.current = this.props.dataSource; - } - componentDidMount() { if (!this.element) { return; @@ -40,15 +37,23 @@ export class PluginSettings extends PureComponent { this.component = loader.load(this.element, this.scopeProps, template); } + componentDidUpdate(prevProps) { + if (this.props.dataSource !== prevProps.dataSource) { + this.scopeProps.ctrl.current = _.cloneDeep(this.props.dataSource); + + this.component.digest(); + } + } + componentWillUnmount() { if (this.component) { this.component.destroy(); } } - onModelChanged(dataSource: DataSource) { - console.log(dataSource); - } + onModelChanged = (dataSource: DataSource) => { + this.props.onModelChange(dataSource); + }; render() { return
(this.element = element)} />; diff --git a/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap new file mode 100644 index 00000000000..0c3e8c86dd4 --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+
+ + Name + + +
+
+`; diff --git a/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap new file mode 100644 index 00000000000..bd190f60b03 --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+`; + +exports[`Render should render with buttons enabled 1`] = ` + +`; diff --git a/public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap new file mode 100644 index 00000000000..ebb856ca354 --- /dev/null +++ b/public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap @@ -0,0 +1,379 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render alpha info text 1`] = ` +
+ +
+
+
+ +
+ This plugin is marked as being in alpha state, which means it is in early development phase and updates will include breaking changes. +
+ +
+ + +
+
+
+`; + +exports[`Render should render beta info text 1`] = ` +
+ +
+
+
+ +
+ This plugin is marked as being in a beta development state. This means it is in currently in active development and could be missing important features. +
+ +
+ + +
+
+
+`; + +exports[`Render should render component 1`] = ` +
+ +
+
+
+ + +
+ + +
+
+
+`; + +exports[`Render should render is ready only message 1`] = ` +
+ +
+
+
+ +
+ This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource. +
+ +
+ + +
+
+
+`; + +exports[`Render should render loader 1`] = ` +
+ + +
+`; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index cb0030eb685..8fda109c906 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -1,11 +1,14 @@ import { ThunkAction } from 'redux-thunk'; import { DataSource, Plugin, StoreState } from 'app/types'; import { getBackendSrv } from '../../../core/services/backend_srv'; +import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions'; import { UpdateLocationAction } from '../../../core/actions/location'; import { buildNavModel } from './navModel'; +import config from '../../../core/config'; + export enum ActionTypes { LoadDataSources = 'LOAD_DATA_SOURCES', LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES', @@ -15,6 +18,10 @@ export enum ActionTypes { SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE', SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY', SetDataSourceName = 'SET_DATA_SOURCE_NAME', + SetDataSourceTestingProgess = 'SET_TESTING_PROGRESS', + SetDataSourceTestingSuccess = 'SET_DATA_SOURCE_TESTING_SUCCESS', + SetDataSourceTestingFail = 'SET_DATA_SOURCE_TESTING_FAIL', + ClearTesting = 'CLEAR_TEST', } interface LoadDataSourcesAction { @@ -57,6 +64,25 @@ interface SetDataSourceNameAction { payload: string; } +interface SetDataSourceTestingProgessAction { + type: ActionTypes.SetDataSourceTestingProgess; + payload: boolean; +} + +interface SetDataSourceTestingSuccessAction { + type: ActionTypes.SetDataSourceTestingSuccess; + payload: { status: string; message: string }; +} + +interface SetDataSourceTestingFailAction { + type: ActionTypes.SetDataSourceTestingFail; + payload: string; +} + +interface ClearTestingAction { + type: ActionTypes.ClearTesting; +} + const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({ type: ActionTypes.LoadDataSources, payload: dataSources, @@ -97,6 +123,28 @@ export const setDataSourceName = (name: string) => ({ payload: name, }); +export const clearTesting = (): ClearTestingAction => ({ + type: ActionTypes.ClearTesting, +}); + +const setDataSourceTestingProgress = (state: boolean): SetDataSourceTestingProgessAction => ({ + type: ActionTypes.SetDataSourceTestingProgess, + payload: state, +}); + +const setDataSourceTestingSuccess = (status: string, message: string): SetDataSourceTestingSuccessAction => ({ + type: ActionTypes.SetDataSourceTestingSuccess, + payload: { + status: status, + message: message, + }, +}); + +const setDataSourceTestingFail = (message: string): SetDataSourceTestingFailAction => ({ + type: ActionTypes.SetDataSourceTestingFail, + payload: message, +}); + export type Action = | LoadDataSourcesAction | SetDataSourcesSearchQueryAction @@ -107,7 +155,11 @@ export type Action = | LoadDataSourceAction | UpdateNavIndexAction | LoadDataSourceMetaAction - | SetDataSourceNameAction; + | SetDataSourceNameAction + | SetDataSourceTestingProgessAction + | SetDataSourceTestingSuccessAction + | SetDataSourceTestingFailAction + | ClearTestingAction; type ThunkResult = ThunkAction; @@ -157,11 +209,16 @@ export function loadDataSourceTypes(): ThunkResult { }; } -export function updateDataSource(): ThunkResult { - return async (dispatch, getStore) => { - const dataSource = getStore().dataSources.dataSource; +export function updateDataSource(dataSource: DataSource): ThunkResult { + return async dispatch => { + await getBackendSrv() + .put(`/api/datasources/${dataSource.id}`, dataSource) + .then(response => { + updateFrontendSettings().then(() => { + testDataSource(dispatch, response.name); + }); + }); - await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource); dispatch(loadDataSource(dataSource.id)); }; } @@ -203,6 +260,50 @@ export function findNewName(dataSources, name) { return name; } +function updateFrontendSettings() { + return getBackendSrv() + .get('/api/frontend/settings') + .then(settings => { + config.datasources = settings.datasources; + config.defaultDatasource = settings.defaultDatasource; + getDatasourceSrv().init(); + }); +} + +function testDataSource(dispatch, name) { + dispatch(setDataSourceTestingProgress(true)); + getDatasourceSrv() + .get(name) + .then(dataSource => { + if (!dataSource.testDatasource) { + return; + } + + // make test call in no backend cache context + getBackendSrv() + .withNoBackendCache(() => { + return dataSource + .testDatasource() + .then(result => { + dispatch(setDataSourceTestingSuccess(result.status, result.message)); + }) + .catch(err => { + let message = ''; + + if (err.statusText) { + message = 'HTTP Error ' + err.statusText; + } else { + message = err.message; + } + dispatch(setDataSourceTestingFail(message)); + }); + }) + .finally(() => { + dispatch(setDataSourceTestingProgress(false)); + }); + }); +} + function nameHasSuffix(name) { return name.endsWith('-', name.length - 1); } diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 33feae6770a..2fc6500e522 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -12,6 +12,7 @@ const initialState: DataSourcesState = { dataSourceTypeSearchQuery: '', hasFetched: false, dataSourceMeta: {} as Plugin, + testing: { inProgress: false, status: '', message: '' }, }; export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => { @@ -39,6 +40,28 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo case ActionTypes.SetDataSourceName: return { ...state, dataSource: { ...state.dataSource, name: action.payload } }; + + case ActionTypes.SetDataSourceTestingProgess: + return { ...state, testing: { ...state.testing, inProgress: action.payload } }; + + case ActionTypes.SetDataSourceTestingSuccess: + return { + ...state, + testing: { + status: action.payload.status, + message: action.payload.message, + inProgress: false, + }, + }; + + case ActionTypes.SetDataSourceTestingFail: + return { + ...state, + testing: { status: 'error', message: action.payload, inProgress: false }, + }; + + case ActionTypes.ClearTesting: + return { ...state, testing: { inProgress: false, status: '', message: '' } }; } return state; diff --git a/public/app/features/explore/ErrorBoundary.tsx b/public/app/features/explore/ErrorBoundary.tsx new file mode 100644 index 00000000000..959d15eea80 --- /dev/null +++ b/public/app/features/explore/ErrorBoundary.tsx @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; + +export default class ErrorBoundary extends Component<{}, any> { + constructor(props) { + super(props); + this.state = { error: null, errorInfo: null }; + } + + componentDidCatch(error, errorInfo) { + // Catch errors in any components below and re-render with error message + this.setState({ + error: error, + errorInfo: errorInfo, + }); + } + + render() { + if (this.state.errorInfo) { + // Error path + return ( +
+

An unexpected error happened.

+
+ {this.state.error && this.state.error.toString()} +
+ {this.state.errorInfo.componentStack} +
+
+ ); + } + // Normally, just render children + return this.props.children; + } +} diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index bac063116f1..af771bad5dd 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -3,15 +3,8 @@ import { hot } from 'react-hot-loader'; import Select from 'react-select'; import _ from 'lodash'; -import { - ExploreState, - ExploreUrlState, - HistoryItem, - Query, - QueryTransaction, - Range, - ResultType, -} from 'app/types/explore'; +import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore'; +import { RawTimeRange } from 'app/types/series'; import kbn from 'app/core/utils/kbn'; import colors from 'app/core/utils/colors'; import store from 'app/core/store'; @@ -28,8 +21,11 @@ import QueryRows from './QueryRows'; import Graph from './Graph'; import Logs from './Logs'; import Table from './Table'; +import ErrorBoundary from './ErrorBoundary'; import TimePicker from './TimePicker'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; +import { DataSource } from 'app/types/datasources'; +import { mergeStreams } from 'app/core/logs_model'; const MAX_HISTORY_ITEMS = 100; @@ -154,12 +150,7 @@ export class Explore extends React.PureComponent { } } - componentDidCatch(error) { - this.setState({ datasourceError: error }); - console.error(error); - } - - async setDatasource(datasource) { + async setDatasource(datasource: DataSource) { const supportsGraph = datasource.meta.metrics; const supportsLogs = datasource.meta.logs; const supportsTable = datasource.meta.metrics; @@ -170,7 +161,7 @@ export class Explore extends React.PureComponent { const testResult = await datasource.testDatasource(); datasourceError = testResult.status === 'success' ? null : testResult.message; } catch (error) { - datasourceError = (error && error.statusText) || error; + datasourceError = (error && error.statusText) || 'Network error'; } const historyKey = `grafana.explore.history.${datasourceId}`; @@ -187,8 +178,12 @@ export class Explore extends React.PureComponent { query: this.queryExpressions[i], })); + // Custom components + const StartPage = datasource.pluginExports.ExploreStartPage; + this.setState( { + StartPage, datasource, datasourceError, history, @@ -278,10 +273,9 @@ export class Explore extends React.PureComponent { } }; - onChangeTime = nextRange => { - const range = { - from: nextRange.from, - to: nextRange.to, + onChangeTime = (nextRange: RawTimeRange) => { + const range: RawTimeRange = { + ...nextRange, }; this.setState({ range }, () => this.onSubmit()); }; @@ -342,6 +336,13 @@ export class Explore extends React.PureComponent { ); }; + // Use this in help pages to set page to a single query + onClickQuery = query => { + const nextQueries = [{ query, key: generateQueryKey() }]; + this.queryExpressions = nextQueries.map(q => q.query); + this.setState({ queries: nextQueries }, this.onSubmit); + }; + onClickSplit = () => { const { onChangeSplit } = this.props; if (onChangeSplit) { @@ -373,9 +374,10 @@ export class Explore extends React.PureComponent { this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue }); }; - onModifyQueries = (action: object, index?: number) => { + onModifyQueries = (action, index?: number) => { const { datasource } = this.state; if (datasource && datasource.modifyQuery) { + const preventSubmit = action.preventSubmit; this.setState( state => { const { queries, queryTransactions } = state; @@ -391,16 +393,26 @@ export class Explore extends React.PureComponent { nextQueryTransactions = []; } else { // Modify query only at index - nextQueries = [ - ...queries.slice(0, index), - { - key: generateQueryKey(index), - query: datasource.modifyQuery(this.queryExpressions[index], action), - }, - ...queries.slice(index + 1), - ]; - // Discard transactions related to row query - nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + nextQueries = queries.map((q, i) => { + // Synchronise all queries with local query cache to ensure consistency + q.query = this.queryExpressions[i]; + return i === index + ? { + key: generateQueryKey(index), + query: datasource.modifyQuery(q.query, action), + } + : q; + }); + 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.queryExpressions = nextQueries.map(q => q.query); return { @@ -408,7 +420,8 @@ export class Explore extends React.PureComponent { queryTransactions: nextQueryTransactions, }; }, - () => this.onSubmit() + // Accepting certain fixes do not result in a well-formed query which should not be submitted + !preventSubmit ? () => this.onSubmit() : null ); } }; @@ -459,7 +472,7 @@ export class Explore extends React.PureComponent { ) { const { datasource, range } = this.state; const resolution = this.el.offsetWidth; - const absoluteRange = { + const absoluteRange: RawTimeRange = { from: parseDate(range.from, false), to: parseDate(range.to, true), }; @@ -474,7 +487,7 @@ export class Explore extends React.PureComponent { ]; // Clone range for query request - const queryRange: Range = { ...range }; + const queryRange: RawTimeRange = { ...range }; return { interval, @@ -572,13 +585,28 @@ export class Explore extends React.PureComponent { }); } - failQueryTransaction(transactionId: string, error: string, datasourceId: string) { + failQueryTransaction(transactionId: string, response: any, datasourceId: string) { const { datasource } = this.state; if (datasource.meta.id !== datasourceId) { // Navigated away, queries did not matter return; } + console.error(response); + + let error: string | JSX.Element = response; + if (response.data) { + error = response.data.error; + if (response.data.response) { + error = ( + <> + {response.data.error} +
{response.data.response}
+ + ); + } + } + this.setState(state => { // Transaction might have been discarded if (!state.queryTransactions.find(qt => qt.id === transactionId)) { @@ -625,9 +653,7 @@ export class Explore extends React.PureComponent { this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); this.setState({ graphRange: transaction.options.range }); } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.failQueryTransaction(transaction.id, queryError, datasourceId); + this.failQueryTransaction(transaction.id, response, datasourceId); } } else { this.discardTransactions(rowIndex); @@ -657,9 +683,7 @@ export class Explore extends React.PureComponent { const results = res.data[0]; this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.failQueryTransaction(transaction.id, queryError, datasourceId); + this.failQueryTransaction(transaction.id, response, datasourceId); } } else { this.discardTransactions(rowIndex); @@ -685,9 +709,7 @@ export class Explore extends React.PureComponent { const results = res.data; this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.failQueryTransaction(transaction.id, queryError, datasourceId); + this.failQueryTransaction(transaction.id, response, datasourceId); } } else { this.discardTransactions(rowIndex); @@ -695,11 +717,6 @@ export class Explore extends React.PureComponent { }); } - request = url => { - const { datasource } = this.state; - return datasource.metadataRequest(url); - }; - cloneState(): ExploreState { // Copy state, but copy queries including modifications return { @@ -717,6 +734,7 @@ export class Explore extends React.PureComponent { render() { const { position, split } = this.props; const { + StartPage, datasource, datasourceError, datasourceLoading, @@ -744,17 +762,20 @@ export class Explore extends React.PureComponent { 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); + // TODO don't recreate those on each re-render const graphResult = _.flatten( queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result) ); const tableResult = mergeTablesIntoModel( new TableModel(), - ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done).map(qt => qt.result) + ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result) ); - const logsResult = _.flatten( - queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done).map(qt => qt.result) + const logsResult = mergeStreams( + queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result) ); const loading = queryTransactions.some(qt => !qt.done); + const showStartPages = StartPage && queryTransactions.length === 0; + const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length; return (
@@ -831,53 +852,61 @@ export class Explore extends React.PureComponent { {datasource && !datasourceError ? (
-
- {supportsGraph ? ( - - ) : null} - {supportsTable ? ( - - ) : null} - {supportsLogs ? ( - - ) : null} -
-
- {supportsGraph && - showingGraph && ( - + + {showStartPages && } + {!showStartPages && ( + <> + {viewModeCount > 1 && ( +
+ {supportsGraph ? ( + + ) : null} + {supportsTable ? ( + + ) : null} + {supportsLogs ? ( + + ) : null} +
+ )} + + {supportsGraph && + showingGraph && ( + + )} + {supportsTable && showingTable ? ( +
+ + + ) : null} + {supportsLogs && showingLogs ? : null} + )} - {supportsTable && showingTable ? ( -
-
- - ) : null} - {supportsLogs && showingLogs ? : null} + ) : null} diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index d57f8d49a43..bbb055067a2 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -6,7 +6,7 @@ import { withSize } from 'react-sizeme'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.time'; -import { Range } from 'app/types/explore'; +import { RawTimeRange } from 'app/types/series'; import * as dateMath from 'app/core/utils/datemath'; import TimeSeries from 'app/core/time_series2'; @@ -76,7 +76,7 @@ interface GraphProps { height?: string; // e.g., '200px' id?: string; loading?: boolean; - range: Range; + range: RawTimeRange; split?: boolean; size?: { width: number; height: number }; } @@ -169,7 +169,7 @@ export class Graph extends PureComponent { return (
- {loading &&
} + {loading &&
} {this.props.data && this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && !this.state.showAllTimeSeries && ( diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index cc8f9be48fd..278c5ee016d 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -10,37 +10,33 @@ interface LogsProps { loading: boolean; } -const EXAMPLE_QUERY = '{job="default/prometheus"}'; - export default class Logs extends PureComponent { render() { - const { className = '', data } = this.props; + const { className = '', data, loading = false } = this.props; const hasData = data && data.rows && data.rows.length > 0; return (
- {hasData ? ( -
- {data.rows.map(row => ( - -
-
{row.timeLocal}
-
- -
- - ))} +
+ {loading &&
} +
+ {hasData && + data.rows.map(row => ( + +
+
{row.timeLocal}
+
+ +
+ + ))}
- ) : null} - {!hasData ? ( -
- Enter a query like {EXAMPLE_QUERY} -
- ) : null} + {!loading && !hasData && 'No data was returned.'} +
); } diff --git a/public/app/features/explore/PlaceholdersBuffer.test.ts b/public/app/features/explore/PlaceholdersBuffer.test.ts new file mode 100644 index 00000000000..2ce31e79b05 --- /dev/null +++ b/public/app/features/explore/PlaceholdersBuffer.test.ts @@ -0,0 +1,72 @@ +import PlaceholdersBuffer from './PlaceholdersBuffer'; + +describe('PlaceholdersBuffer', () => { + it('does nothing if no placeholders are defined', () => { + const text = 'metric'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe(text); + expect(buffer.getNextMoveOffset()).toBe(0); + }); + + it('respects the traversal order of placeholders', () => { + const text = 'sum($2 offset $1) by ($3)'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum( offset ) by ()'); + expect(buffer.getNextMoveOffset()).toBe(12); + + buffer.setNextPlaceholderValue('1h'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum( offset 1h) by ()'); + expect(buffer.getNextMoveOffset()).toBe(-10); + + buffer.setNextPlaceholderValue('metric'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum(metric offset 1h) by ()'); + expect(buffer.getNextMoveOffset()).toBe(16); + + buffer.setNextPlaceholderValue('label'); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)'); + expect(buffer.getNextMoveOffset()).toBe(0); + }); + + it('respects the traversal order of adjacent placeholders', () => { + const text = '$1$3$2$4'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe(''); + expect(buffer.getNextMoveOffset()).toBe(0); + + buffer.setNextPlaceholderValue('1'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('1'); + expect(buffer.getNextMoveOffset()).toBe(0); + + buffer.setNextPlaceholderValue('2'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('12'); + expect(buffer.getNextMoveOffset()).toBe(-1); + + buffer.setNextPlaceholderValue('3'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('132'); + expect(buffer.getNextMoveOffset()).toBe(1); + + buffer.setNextPlaceholderValue('4'); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe('1324'); + expect(buffer.getNextMoveOffset()).toBe(0); + }); +}); diff --git a/public/app/features/explore/PlaceholdersBuffer.ts b/public/app/features/explore/PlaceholdersBuffer.ts new file mode 100644 index 00000000000..9a0db18ef04 --- /dev/null +++ b/public/app/features/explore/PlaceholdersBuffer.ts @@ -0,0 +1,112 @@ +/** + * Provides a stateful means of managing placeholders in text. + * + * Placeholders are numbers prefixed with the `$` character (e.g. `$1`). + * Each number value represents the order in which a placeholder should + * receive focus if multiple placeholders exist. + * + * Example scenario given `sum($3 offset $1) by($2)`: + * 1. `sum( offset |) by()` + * 2. `sum( offset 1h) by(|)` + * 3. `sum(| offset 1h) by (label)` + */ +export default class PlaceholdersBuffer { + private nextMoveOffset: number; + private orders: number[]; + private parts: string[]; + + constructor(text: string) { + const result = this.parse(text); + const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0; + this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex); + this.orders = result.orders; + this.parts = result.parts; + } + + clearPlaceholders() { + this.nextMoveOffset = 0; + this.orders = []; + } + + getNextMoveOffset(): number { + return this.nextMoveOffset; + } + + hasPlaceholders(): boolean { + return this.orders.length > 0; + } + + setNextPlaceholderValue(value: string) { + if (this.orders.length === 0) { + return; + } + const currentPlaceholderIndex = this.orders[0]; + this.parts[currentPlaceholderIndex] = value; + this.orders = this.orders.slice(1); + if (this.orders.length === 0) { + this.nextMoveOffset = 0; + return; + } + const nextPlaceholderIndex = this.orders[0]; + // Case should never happen but handle it gracefully in case + if (currentPlaceholderIndex === nextPlaceholderIndex) { + this.nextMoveOffset = 0; + return; + } + const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex; + const indices = backwardMove + ? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 } + : { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex }; + this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end); + } + + toString(): string { + return this.parts.join(''); + } + + private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) { + return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0); + } + + private parse(text: string): ParseResult { + const placeholderRegExp = /\$(\d+)/g; + const parts = []; + const orders = []; + let textOffset = 0; + while (true) { + const match = placeholderRegExp.exec(text); + if (!match) { + break; + } + const part = text.slice(textOffset, match.index); + parts.push(part); + // Accounts for placeholders at text boundaries + if (part !== '') { + parts.push(''); + } + const order = parseInt(match[1], 10); + orders.push({ index: parts.length - 1, order }); + textOffset += part.length + match.length; + } + // Ensures string serialisation still works if no placeholders were parsed + // and also accounts for the remainder of text with placeholders + parts.push(text.slice(textOffset)); + return { + // Placeholder values do not necessarily appear sequentially so sort the + // indices to traverse in priority order + orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index), + parts, + }; + } +} + +type ParseResult = { + /** + * Indices to placeholder items in `parts` in traversal order. + */ + orders: number[]; + /** + * Parts comprising the original text with placeholders occupying distinct items. + */ + parts: string[]; +}; diff --git a/public/app/features/explore/PromQueryField.test.tsx b/public/app/features/explore/PromQueryField.test.tsx deleted file mode 100644 index 802bb695f2c..00000000000 --- a/public/app/features/explore/PromQueryField.test.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import React from 'react'; -import Enzyme, { shallow } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Plain from 'slate-plain-serializer'; - -import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField'; - -Enzyme.configure({ adapter: new Adapter() }); - -describe('PromQueryField typeahead handling', () => { - const defaultProps = { - request: () => ({ data: { data: [] } }), - }; - - it('returns default suggestions on emtpty context', () => { - const instance = shallow().instance() as PromQueryField; - const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - - describe('range suggestions', () => { - it('returns range suggestions in range context', () => { - const instance = shallow().instance() as PromQueryField; - const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] }); - expect(result.context).toBe('context-range'); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions).toEqual([ - { - items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }], - label: 'Range vector', - }, - ]); - }); - }); - - describe('metric suggestions', () => { - it('returns metrics suggestions by default', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - - it('returns default suggestions after a binary operator', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - }); - - describe('label suggestions', () => { - it('returns default label suggestions on label context and no metric', () => { - const instance = shallow().instance() as PromQueryField; - const value = Plain.deserialize('{}'); - const range = value.selection.merge({ - anchorOffset: 1, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); - }); - - it('returns label suggestions on label context and metric', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label suggestions on label context but leaves out labels that already exist', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}'); - const range = value.selection.merge({ - anchorOffset: 36, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label value suggestions inside a label value context after a negated matching operator', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('{label!=}'); - const range = value.selection.merge({ anchorOffset: 8 }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '!=', - prefix: '', - wrapperClasses: ['context-labels'], - labelKey: 'label', - value: valueWithSelection, - }); - expect(result.context).toBe('context-label-values'); - expect(result.suggestions).toEqual([ - { - items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }], - label: 'Label values for "label"', - }, - ]); - }); - - it('returns a refresher on label context and unavailable metric', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeInstanceOf(Promise); - expect(result.suggestions).toEqual([]); - }); - - it('returns label values on label context when given a metric and a label key', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{bar=ba}'); - const range = value.selection.merge({ - anchorOffset: 13, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '=ba', - prefix: 'ba', - wrapperClasses: ['context-labels'], - labelKey: 'bar', - value: valueWithSelection, - }); - expect(result.context).toBe('context-label-values'); - expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); - }); - - it('returns label suggestions on aggregation context and metric w/ selector', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('sum(metric{foo="xx"}) by ()'); - const range = value.selection.merge({ - anchorOffset: 26, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-aggregation'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label suggestions on aggregation context and metric w/o selector', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('sum(metric) by ()'); - const range = value.selection.merge({ - anchorOffset: 16, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-aggregation'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - }); -}); - -describe('groupMetricsByPrefix()', () => { - it('returns an empty group for no metrics', () => { - expect(groupMetricsByPrefix([])).toEqual([]); - }); - - it('returns options grouped by prefix', () => { - expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([ - { - value: 'foo', - children: [ - { - value: 'foo_metric', - }, - ], - }, - ]); - }); - - it('returns options without prefix as toplevel option', () => { - expect(groupMetricsByPrefix(['metric'])).toMatchObject([ - { - value: 'metric', - }, - ]); - }); - - it('returns recording rules grouped separately', () => { - expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([ - { - value: RECORDING_RULES_GROUP, - children: [ - { - value: ':foo_metric:', - }, - ], - }, - ]); - }); -}); diff --git a/public/app/features/explore/PromQueryField.tsx b/public/app/features/explore/PromQueryField.tsx deleted file mode 100644 index 442e51af987..00000000000 --- a/public/app/features/explore/PromQueryField.tsx +++ /dev/null @@ -1,610 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; -import React from 'react'; -import { Value } from 'slate'; -import Cascader from 'rc-cascader'; -import PluginPrism from 'slate-prism'; -import Prism from 'prismjs'; - -// dom also includes Element polyfills -import { getNextCharacter, getPreviousCousin } from './utils/dom'; -import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql'; -import BracesPlugin from './slate-plugins/braces'; -import RunnerPlugin from './slate-plugins/runner'; -import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus'; - -import TypeaheadField, { - Suggestion, - SuggestionGroup, - TypeaheadInput, - TypeaheadFieldState, - TypeaheadOutput, -} from './QueryField'; - -const DEFAULT_KEYS = ['job', 'instance']; -const EMPTY_SELECTOR = '{}'; -const HISTOGRAM_GROUP = '__histograms__'; -const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms -const HISTORY_ITEM_COUNT = 5; -const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h -const METRIC_MARK = 'metric'; -const PRISM_SYNTAX = 'promql'; -export const RECORDING_RULES_GROUP = '__recording_rules__'; - -export const wrapLabel = (label: string) => ({ label }); -export const setFunctionMove = (suggestion: Suggestion): Suggestion => { - suggestion.move = -1; - return suggestion; -}; - -// Syntax highlighting -Prism.languages[PRISM_SYNTAX] = PrismPromql; -function setPrismTokens(language, field, values, alias = 'variable') { - Prism.languages[language][field] = { - alias, - pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`), - }; -} - -export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion { - const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; - const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label); - const count = historyForItem.length; - const recent = historyForItem[0]; - let hint = `Queried ${count} times in the last 24h.`; - if (recent) { - const lastQueried = moment(recent.ts).fromNow(); - hint = `${hint} Last queried ${lastQueried}.`; - } - return { - ...item, - documentation: hint, - }; -} - -export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] { - // Filter out recording rules and insert as first option - const ruleRegex = /:\w+:/; - const ruleNames = metrics.filter(metric => ruleRegex.test(metric)); - const rulesOption = { - label: 'Recording rules', - value: RECORDING_RULES_GROUP, - children: ruleNames - .slice() - .sort() - .map(name => ({ label: name, value: name })), - }; - - const options = ruleNames.length > 0 ? [rulesOption] : []; - - const metricsOptions = _.chain(metrics) - .filter(metric => !ruleRegex.test(metric)) - .groupBy(metric => metric.split(delimiter)[0]) - .map((metricsForPrefix: string[], prefix: string): CascaderOption => { - const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix; - const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m })); - return { - children, - label: prefix, - value: prefix, - }; - }) - .sortBy('label') - .value(); - - return [...options, ...metricsOptions]; -} - -export function willApplySuggestion( - suggestion: string, - { typeaheadContext, typeaheadText }: TypeaheadFieldState -): string { - // Modify suggestion based on context - switch (typeaheadContext) { - case 'context-labels': { - const nextChar = getNextCharacter(); - if (!nextChar || nextChar === '}' || nextChar === ',') { - suggestion += '='; - } - break; - } - - case 'context-label-values': { - // Always add quotes and remove existing ones instead - if (!typeaheadText.match(/^(!?=~?"|")/)) { - suggestion = `"${suggestion}`; - } - if (getNextCharacter() !== '"') { - suggestion = `${suggestion}"`; - } - break; - } - - default: - } - return suggestion; -} - -interface CascaderOption { - label: string; - value: string; - children?: CascaderOption[]; - disabled?: boolean; -} - -interface PromQueryFieldProps { - error?: string; - hint?: any; - histogramMetrics?: string[]; - history?: any[]; - initialQuery?: string | null; - labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] - labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] - metrics?: string[]; - metricsByPrefix?: CascaderOption[]; - onClickHintFix?: (action: any) => void; - onPressEnter?: () => void; - onQueryChange?: (value: string, override?: boolean) => void; - portalOrigin?: string; - request?: (url: string) => any; - supportsLogs?: boolean; // To be removed after Logging gets its own query field -} - -interface PromQueryFieldState { - histogramMetrics: string[]; - labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...] - labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] - logLabelOptions: any[]; - metrics: string[]; - metricsOptions: any[]; - metricsByPrefix: CascaderOption[]; - syntaxLoaded: boolean; -} - -interface PromTypeaheadInput { - text: string; - prefix: string; - wrapperClasses: string[]; - labelKey?: string; - value?: Value; -} - -class PromQueryField extends React.PureComponent { - plugins: any[]; - - constructor(props: PromQueryFieldProps, context) { - super(props, context); - - this.plugins = [ - BracesPlugin(), - RunnerPlugin({ handler: props.onPressEnter }), - PluginPrism({ - onlyIn: node => node.type === 'code_block', - getSyntax: node => 'promql', - }), - ]; - - this.state = { - histogramMetrics: props.histogramMetrics || [], - labelKeys: props.labelKeys || {}, - labelValues: props.labelValues || {}, - logLabelOptions: [], - metrics: props.metrics || [], - metricsByPrefix: props.metricsByPrefix || [], - metricsOptions: [], - syntaxLoaded: false, - }; - } - - componentDidMount() { - // Temporarily reused by logging - const { supportsLogs } = this.props; - if (supportsLogs) { - this.fetchLogLabels(); - } else { - // Usual actions - this.fetchMetricNames(); - this.fetchHistogramMetrics(); - } - } - - onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => { - let query; - if (selectedOptions.length === 1) { - if (selectedOptions[0].children.length === 0) { - query = selectedOptions[0].value; - } else { - // Ignore click on group - return; - } - } else { - const key = selectedOptions[0].value; - const value = selectedOptions[1].value; - query = `{${key}="${value}"}`; - } - this.onChangeQuery(query, true); - }; - - onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => { - let query; - if (selectedOptions.length === 1) { - if (selectedOptions[0].children.length === 0) { - query = selectedOptions[0].value; - } else { - // Ignore click on group - return; - } - } else { - const prefix = selectedOptions[0].value; - const metric = selectedOptions[1].value; - if (prefix === HISTOGRAM_GROUP) { - query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`; - } else { - query = metric; - } - } - this.onChangeQuery(query, true); - }; - - onChangeQuery = (value: string, override?: boolean) => { - // Send text change to parent - const { onQueryChange } = this.props; - if (onQueryChange) { - onQueryChange(value, override); - } - }; - - onClickHintFix = () => { - const { hint, onClickHintFix } = this.props; - if (onClickHintFix && hint && hint.fix) { - onClickHintFix(hint.fix.action); - } - }; - - onReceiveMetrics = () => { - const { histogramMetrics, metrics, metricsByPrefix } = this.state; - if (!metrics) { - return; - } - - // Update global prism config - setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics); - - // Build metrics tree - const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm })); - const metricsOptions = [ - { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions }, - ...metricsByPrefix, - ]; - - this.setState({ metricsOptions, syntaxLoaded: true }); - }; - - onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => { - const { prefix, text, value, wrapperNode } = typeahead; - - // Get DOM-dependent context - const wrapperClasses = Array.from(wrapperNode.classList); - const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name'); - const labelKey = labelKeyNode && labelKeyNode.textContent; - const nextChar = getNextCharacter(); - - const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey }); - - console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context); - - return result; - }; - - // Keep this DOM-free for testing - getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput { - // Syntax spans have 3 classes by default. More indicate a recognized token - const tokenRecognized = wrapperClasses.length > 3; - // Determine candidates by CSS context - if (_.includes(wrapperClasses, 'context-range')) { - // Suggestions for metric[|] - return this.getRangeTypeahead(); - } else if (_.includes(wrapperClasses, 'context-labels')) { - // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} - return this.getLabelTypeahead.apply(this, arguments); - } else if (_.includes(wrapperClasses, 'context-aggregation')) { - return this.getAggregationTypeahead.apply(this, arguments); - } else if ( - // Show default suggestions in a couple of scenarios - (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token - (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace - text.match(/[+\-*/^%]/) // Anything after binary operator - ) { - return this.getEmptyTypeahead(); - } - - return { - suggestions: [], - }; - } - - getEmptyTypeahead(): TypeaheadOutput { - const { history } = this.props; - const { metrics } = this.state; - const suggestions: SuggestionGroup[] = []; - - if (history && history.length > 0) { - const historyItems = _.chain(history) - .uniqBy('query') - .take(HISTORY_ITEM_COUNT) - .map(h => h.query) - .map(wrapLabel) - .map(item => addHistoryMetadata(item, history)) - .value(); - - suggestions.push({ - prefixMatch: true, - skipSort: true, - label: 'History', - items: historyItems, - }); - } - - suggestions.push({ - prefixMatch: true, - label: 'Functions', - items: FUNCTIONS.map(setFunctionMove), - }); - - if (metrics) { - suggestions.push({ - label: 'Metrics', - items: metrics.map(wrapLabel), - }); - } - return { suggestions }; - } - - getRangeTypeahead(): TypeaheadOutput { - return { - context: 'context-range', - suggestions: [ - { - label: 'Range vector', - items: [...RATE_RANGES].map(wrapLabel), - }, - ], - }; - } - - getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput { - let refresher: Promise = null; - const suggestions: SuggestionGroup[] = []; - - // sum(foo{bar="1"}) by (|) - const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; - // sum(foo{bar="1"}) by ( - const leftSide = line.slice(0, cursorOffset); - const openParensAggregationIndex = leftSide.lastIndexOf('('); - const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('('); - const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex; - // foo{bar="1"} - const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); - const selector = parseSelector(selectorString, selectorString.length - 2).selector; - - const labelKeys = this.state.labelKeys[selector]; - if (labelKeys) { - suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) }); - } else { - refresher = this.fetchSeriesLabels(selector); - } - - return { - refresher, - suggestions, - context: 'context-aggregation', - }; - } - - getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput { - let context: string; - let refresher: Promise = null; - const suggestions: SuggestionGroup[] = []; - const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; - - // Get normalized selector - let selector; - let parsedSelector; - try { - parsedSelector = parseSelector(line, cursorOffset); - selector = parsedSelector.selector; - } catch { - selector = EMPTY_SELECTOR; - } - const containsMetric = selector.indexOf('__name__=') > -1; - const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; - - if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { - // Label values - if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) { - const labelValues = this.state.labelValues[selector][labelKey]; - context = 'context-label-values'; - suggestions.push({ - label: `Label values for "${labelKey}"`, - items: labelValues.map(wrapLabel), - }); - } - } else { - // Label keys - const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS); - if (labelKeys) { - const possibleKeys = _.difference(labelKeys, existingKeys); - if (possibleKeys.length > 0) { - context = 'context-labels'; - suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) }); - } - } - } - - // Query labels for selector - // Temporarily add skip for logging - if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) { - if (selector === EMPTY_SELECTOR) { - // Query label values for default labels - refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); - } else { - refresher = this.fetchSeriesLabels(selector, !containsMetric); - } - } - - return { context, refresher, suggestions }; - } - - request = url => { - if (this.props.request) { - return this.props.request(url); - } - return fetch(url); - }; - - fetchHistogramMetrics() { - this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => { - const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR]; - if (histogramSeries && histogramSeries['__name__']) { - const histogramMetrics = histogramSeries['__name__'].slice().sort(); - this.setState({ histogramMetrics }, this.onReceiveMetrics); - } - }); - } - - // Temporarily here while reusing this field for logging - async fetchLogLabels() { - const url = '/api/prom/label'; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const labelKeys = body.data.slice().sort(); - const labelKeysBySelector = { - ...this.state.labelKeys, - [EMPTY_SELECTOR]: labelKeys, - }; - const labelValuesByKey = {}; - const logLabelOptions = []; - for (const key of labelKeys) { - const valuesUrl = `/api/prom/label/${key}/values`; - const res = await this.request(valuesUrl); - const body = await (res.data || res.json()); - const values = body.data.slice().sort(); - labelValuesByKey[key] = values; - logLabelOptions.push({ - label: key, - value: key, - children: values.map(value => ({ label: value, value })), - }); - } - const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey }; - this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions }); - } catch (e) { - console.error(e); - } - } - - async fetchLabelValues(key: string) { - const url = `/api/v1/label/${key}/values`; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const exisingValues = this.state.labelValues[EMPTY_SELECTOR]; - const values = { - ...exisingValues, - [key]: body.data, - }; - const labelValues = { - ...this.state.labelValues, - [EMPTY_SELECTOR]: values, - }; - this.setState({ labelValues }); - } catch (e) { - console.error(e); - } - } - - async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) { - const url = `/api/v1/series?match[]=${name}`; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const { keys, values } = processLabels(body.data, withName); - const labelKeys = { - ...this.state.labelKeys, - [name]: keys, - }; - const labelValues = { - ...this.state.labelValues, - [name]: values, - }; - this.setState({ labelKeys, labelValues }, callback); - } catch (e) { - console.error(e); - } - } - - async fetchMetricNames() { - const url = '/api/v1/label/__name__/values'; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const metrics = body.data; - const metricsByPrefix = groupMetricsByPrefix(metrics); - this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics); - } catch (error) { - console.error(error); - } - } - - render() { - const { error, hint, initialQuery, supportsLogs } = this.props; - const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state; - - return ( -
-
- {supportsLogs ? ( - - - - ) : ( - - - - )} -
-
-
- -
- {error ?
{error}
: null} - {hint ? ( -
- {hint.label}{' '} - {hint.fix ? ( - - {hint.fix.label} - - ) : null} -
- ) : null} -
-
- ); - } -} - -export default PromQueryField; diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index ce0bcd71ed0..73d743fe8e6 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -5,96 +5,29 @@ import { Change, Value } from 'slate'; import { Editor } from 'slate-react'; import Plain from 'slate-plain-serializer'; +import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; + import ClearPlugin from './slate-plugins/clear'; import NewlinePlugin from './slate-plugins/newline'; import Typeahead from './Typeahead'; import { makeFragment, makeValue } from './Value'; +import PlaceholdersBuffer from './PlaceholdersBuffer'; export const TYPEAHEAD_DEBOUNCE = 100; -function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion { +function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem { // Flatten suggestion groups const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []); const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length; return flattenedSuggestions[correctedIndex]; } -function hasSuggestions(suggestions: SuggestionGroup[]): boolean { +function hasSuggestions(suggestions: CompletionItemGroup[]): boolean { return suggestions && suggestions.length > 0; } -export interface Suggestion { - /** - * The label of this completion item. By default - * this is also the text that is inserted when selecting - * this completion. - */ - label: string; - /** - * The kind of this completion item. Based on the kind - * an icon is chosen by the editor. - */ - kind?: string; - /** - * A human-readable string with additional information - * about this item, like type or symbol information. - */ - detail?: string; - /** - * A human-readable string, can be Markdown, that represents a doc-comment. - */ - documentation?: string; - /** - * A string that should be used when comparing this item - * with other items. When `falsy` the `label` is used. - */ - sortText?: string; - /** - * A string that should be used when filtering a set of - * completion items. When `falsy` the `label` is used. - */ - filterText?: string; - /** - * A string or snippet that should be inserted in a document when selecting - * this completion. When `falsy` the `label` is used. - */ - insertText?: string; - /** - * Delete number of characters before the caret position, - * by default the letters from the beginning of the word. - */ - deleteBackwards?: number; - /** - * Number of steps to move after the insertion, can be negative. - */ - move?: number; -} - -export interface SuggestionGroup { - /** - * Label that will be displayed for all entries of this group. - */ - label: string; - /** - * List of suggestions of this group. - */ - items: Suggestion[]; - /** - * If true, match only by prefix (and not mid-word). - */ - prefixMatch?: boolean; - /** - * If true, do not filter items in this group based on the search. - */ - skipFilter?: boolean; - /** - * If true, do not sort items. - */ - skipSort?: boolean; -} - -interface TypeaheadFieldProps { +interface QueryFieldProps { additionalPlugins?: any[]; cleanText?: (text: string) => string; initialValue: string | null; @@ -102,15 +35,15 @@ interface TypeaheadFieldProps { onFocus?: () => void; onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput; onValueChanged?: (value: Value) => void; - onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string; + onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string; placeholder?: string; portalOrigin?: string; syntax?: string; syntaxLoaded?: boolean; } -export interface TypeaheadFieldState { - suggestions: SuggestionGroup[]; +export interface QueryFieldState { + suggestions: CompletionItemGroup[]; typeaheadContext: string | null; typeaheadIndex: number; typeaheadPrefix: string; @@ -127,22 +60,19 @@ export interface TypeaheadInput { wrapperNode: Element; } -export interface TypeaheadOutput { - context?: string; - refresher?: Promise<{}>; - suggestions: SuggestionGroup[]; -} - -class QueryField extends React.PureComponent { +export class QueryField extends React.PureComponent { menuEl: HTMLElement | null; + placeholdersBuffer: PlaceholdersBuffer; plugins: any[]; resetTimer: any; constructor(props, context) { super(props, context); + this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || ''); + // Base plugins - this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins]; + this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p); this.state = { suggestions: [], @@ -150,7 +80,7 @@ class QueryField extends React.PureComponent operation.type === 'insert_text'); + if (insertTextOperation) { + const suggestionText = insertTextOperation.text; + this.placeholdersBuffer.setNextPlaceholderValue(suggestionText); + if (this.placeholdersBuffer.hasPlaceholders()) { + nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus(); + } + } + return true; } break; @@ -410,6 +352,8 @@ class QueryField extends React.PureComponent { + onClickMenu = (item: CompletionItem) => { // Manually triggering change const change = this.applyTypeahead(this.state.value.change(), item); this.onChange(change); @@ -490,19 +434,21 @@ class QueryField extends React.PureComponent - {this.renderMenu()} - +
+
+ {this.renderMenu()} + +
); } diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 0b0d7085d2d..4aacdb22599 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -1,12 +1,12 @@ import React, { PureComponent } from 'react'; -import { QueryTransaction } from 'app/types/explore'; +import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore'; -// TODO make this datasource-plugin-dependent -import QueryField from './PromQueryField'; -import QueryTransactions from './QueryTransactions'; +import DefaultQueryField from './QueryField'; +import QueryTransactionStatus from './QueryTransactionStatus'; +import { DataSource } from 'app/types'; -function getFirstHintFromTransactions(transactions: QueryTransaction[]) { +function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); if (transaction) { return transaction.hints[0]; @@ -14,7 +14,28 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]) { return undefined; } -class QueryRow extends PureComponent { +interface QueryRowEventHandlers { + onAddQueryRow: (index: number) => void; + onChangeQuery: (value: string, index: number, override?: boolean) => void; + onClickHintFix: (action: object, index?: number) => void; + onExecuteQuery: () => void; + onRemoveQueryRow: (index: number) => void; +} + +interface QueryRowCommonProps { + className?: string; + datasource: DataSource; + history: HistoryItem[]; + transactions: QueryTransaction[]; +} + +type QueryRowProps = QueryRowCommonProps & + QueryRowEventHandlers & { + index: number; + query: string; + }; + +class QueryRow extends PureComponent { onChangeQuery = (value, override?: boolean) => { const { index, onChangeQuery } = this.props; if (onChangeQuery) { @@ -55,17 +76,19 @@ class QueryRow extends PureComponent { }; render() { - const { history, query, request, supportsLogs, transactions } = this.props; - const transactionWithError = transactions.find(t => t.error); + const { datasource, history, query, transactions } = 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 || DefaultQueryField; return (
- +
{ onClickHintFix={this.onClickHintFix} onPressEnter={this.onPressEnter} onQueryChange={this.onChangeQuery} - request={request} - supportsLogs={supportsLogs} />
@@ -93,9 +114,14 @@ class QueryRow extends PureComponent { } } -export default class QueryRows extends PureComponent { +type QueryRowsProps = QueryRowCommonProps & + QueryRowEventHandlers & { + queries: Query[]; + }; + +export default class QueryRows extends PureComponent { render() { - const { className = '', queries, queryHints, transactions, ...handlers } = this.props; + const { className = '', queries, transactions, ...handlers } = this.props; return (
{queries.map((q, index) => ( diff --git a/public/app/features/explore/QueryTransactions.tsx b/public/app/features/explore/QueryTransactionStatus.tsx similarity index 60% rename from public/app/features/explore/QueryTransactions.tsx rename to public/app/features/explore/QueryTransactionStatus.tsx index 0ce721f14e7..77a50b7d2ca 100644 --- a/public/app/features/explore/QueryTransactions.tsx +++ b/public/app/features/explore/QueryTransactionStatus.tsx @@ -1,17 +1,17 @@ import React, { PureComponent } from 'react'; -import { QueryTransaction as QueryTransactionModel } from 'app/types/explore'; +import { QueryTransaction } from 'app/types/explore'; import ElapsedTime from './ElapsedTime'; function formatLatency(value) { return `${(value / 1000).toFixed(1)}s`; } -interface QueryTransactionProps { - transaction: QueryTransactionModel; +interface QueryTransactionStatusItemProps { + transaction: QueryTransaction; } -class QueryTransaction extends PureComponent { +class QueryTransactionStatusItem extends PureComponent { render() { const { transaction } = this.props; const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading'; @@ -26,16 +26,16 @@ class QueryTransaction extends PureComponent { } } -interface QueryTransactionsProps { - transactions: QueryTransactionModel[]; +interface QueryTransactionStatusProps { + transactions: QueryTransaction[]; } -export default class QueryTransactions extends PureComponent { +export default class QueryTransactionStatus extends PureComponent { render() { const { transactions } = this.props; return (
- {transactions.map((t, i) => )} + {transactions.map((t, i) => )}
); } diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx index 0264bd3b4cc..4946a6a505d 100644 --- a/public/app/features/explore/Table.tsx +++ b/public/app/features/explore/Table.tsx @@ -21,10 +21,16 @@ function prepareRows(rows, columnNames) { export default class Table extends PureComponent { getCellProps = (state, rowInfo, column) => { return { - onClick: () => { - const columnKey = column.Header; - const rowValue = rowInfo.row[columnKey]; - this.props.onClickCell(columnKey, rowValue); + onClick: (e: React.SyntheticEvent) => { + // Only handle click on link, not the cell + if (e.target) { + const link = e.target as HTMLElement; + if (link.className === 'link') { + const columnKey = column.Header; + const rowValue = rowInfo.row[columnKey]; + this.props.onClickCell(columnKey, rowValue); + } + } }, }; }; diff --git a/public/app/features/explore/TimePicker.test.tsx b/public/app/features/explore/TimePicker.test.tsx index afe6b092901..cadcfbb668c 100644 --- a/public/app/features/explore/TimePicker.test.tsx +++ b/public/app/features/explore/TimePicker.test.tsx @@ -33,8 +33,8 @@ describe('', () => { to: '1000', }; const rangeString = rangeUtil.describeTimeRange({ - from: parseTime(range.from), - to: parseTime(range.to), + from: parseTime(range.from, true), + to: parseTime(range.to, true), }); const wrapper = shallow(); expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00'); @@ -50,8 +50,8 @@ describe('', () => { to: '4000', }; const rangeString = rangeUtil.describeTimeRange({ - from: parseTime(range.from), - to: parseTime(range.to), + from: parseTime(range.from, true), + to: parseTime(range.to, true), }); const onChangeTime = sinon.spy(); diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx index f9c740073d0..8955fb4aa9b 100644 --- a/public/app/features/explore/TimePicker.tsx +++ b/public/app/features/explore/TimePicker.tsx @@ -3,6 +3,7 @@ import moment from 'moment'; import * as dateMath from 'app/core/utils/datemath'; import * as rangeUtil from 'app/core/utils/rangeutil'; +import { RawTimeRange } from 'app/types/series'; const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; export const DEFAULT_RANGE = { @@ -10,77 +11,104 @@ export const DEFAULT_RANGE = { to: 'now', }; -export function parseTime(value, isUtc = false, asString = false) { +/** + * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT). + * @param value Epoch or relative time + */ +export function parseTime(value: string, isUtc = false): string { if (value.indexOf('now') !== -1) { return value; } - if (!isNaN(value)) { - const epoch = parseInt(value, 10); - const m = isUtc ? moment.utc(epoch) : moment(epoch); - return asString ? m.format(DATE_FORMAT) : m; + let time: any = value; + // Possible epoch + if (!isNaN(time)) { + time = parseInt(time, 10); } - return undefined; + time = isUtc ? moment.utc(time) : moment(time); + return time.format(DATE_FORMAT); } -export default class TimePicker extends PureComponent { +interface TimePickerProps { + isOpen?: boolean; + isUtc?: boolean; + range?: RawTimeRange; + onChangeTime?: (Range) => void; +} + +interface TimePickerState { + isOpen: boolean; + isUtc: boolean; + rangeString: string; + refreshInterval: string; + + // Input-controlled text, keep these in a shape that is human-editable + fromRaw: string; + toRaw: string; +} + +export default class TimePicker extends PureComponent { dropdownEl: any; + constructor(props) { super(props); - const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from; - const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to; + const from = props.range ? props.range.from : DEFAULT_RANGE.from; + const to = props.range ? props.range.to : DEFAULT_RANGE.to; + + // Ensure internal format + const fromRaw = parseTime(from, props.isUtc); + const toRaw = parseTime(to, props.isUtc); const range = { - from: parseTime(fromRaw), - to: parseTime(toRaw), + from: fromRaw, + to: toRaw, }; + this.state = { - fromRaw: parseTime(fromRaw, props.isUtc, true), + fromRaw, + toRaw, isOpen: props.isOpen, isUtc: props.isUtc, rangeString: rangeUtil.describeTimeRange(range), refreshInterval: '', - toRaw: parseTime(toRaw, props.isUtc, true), }; } - move(direction) { + move(direction: number) { const { onChangeTime } = this.props; const { fromRaw, toRaw } = this.state; - const range = { - from: dateMath.parse(fromRaw, false), - to: dateMath.parse(toRaw, true), - }; + const from = dateMath.parse(fromRaw, false); + const to = dateMath.parse(toRaw, true); + const timespan = (to.valueOf() - from.valueOf()) / 2; - const timespan = (range.to.valueOf() - range.from.valueOf()) / 2; - let to, from; + let nextTo, nextFrom; if (direction === -1) { - to = range.to.valueOf() - timespan; - from = range.from.valueOf() - timespan; + nextTo = to.valueOf() - timespan; + nextFrom = from.valueOf() - timespan; } else if (direction === 1) { - to = range.to.valueOf() + timespan; - from = range.from.valueOf() + timespan; - if (to > Date.now() && range.to < Date.now()) { - to = Date.now(); - from = range.from.valueOf(); + nextTo = to.valueOf() + timespan; + nextFrom = from.valueOf() + timespan; + if (nextTo > Date.now() && to < Date.now()) { + nextTo = Date.now(); + nextFrom = from.valueOf(); } } else { - to = range.to.valueOf(); - from = range.from.valueOf(); + nextTo = to.valueOf(); + nextFrom = from.valueOf(); } - const rangeString = rangeUtil.describeTimeRange(range); - // No need to convert to UTC again - to = moment(to); - from = moment(from); + const nextRange = { + from: moment(nextFrom), + to: moment(nextTo), + }; this.setState( { - rangeString, - fromRaw: from.format(DATE_FORMAT), - toRaw: to.format(DATE_FORMAT), + rangeString: rangeUtil.describeTimeRange(nextRange), + fromRaw: nextRange.from.format(DATE_FORMAT), + toRaw: nextRange.to.format(DATE_FORMAT), }, () => { - onChangeTime({ to, from }); + onChangeTime(nextRange); } ); } @@ -99,16 +127,19 @@ export default class TimePicker extends PureComponent { handleClickApply = () => { const { onChangeTime } = this.props; - const { toRaw, fromRaw } = this.state; - const range = { - from: dateMath.parse(fromRaw, false), - to: dateMath.parse(toRaw, true), - }; - const rangeString = rangeUtil.describeTimeRange(range); + let range; this.setState( - { - isOpen: false, - rangeString, + state => { + const { toRaw, fromRaw } = this.state; + range = { + from: dateMath.parse(fromRaw, false), + to: dateMath.parse(toRaw, true), + }; + const rangeString = rangeUtil.describeTimeRange(range); + return { + isOpen: false, + rangeString, + }; }, () => { if (onChangeTime) { diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index 0c01cbe01ba..13882e030f6 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/public/app/features/explore/Typeahead.tsx @@ -1,7 +1,7 @@ import React from 'react'; import Highlighter from 'react-highlight-words'; -import { Suggestion, SuggestionGroup } from './QueryField'; +import { CompletionItem, CompletionItemGroup } from 'app/types/explore'; function scrollIntoView(el: HTMLElement) { if (!el || !el.offsetParent) { @@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) { interface TypeaheadItemProps { isSelected: boolean; - item: Suggestion; + item: CompletionItem; onClickItem: (Suggestion) => void; prefix?: string; } -class TypeaheadItem extends React.PureComponent { +class TypeaheadItem extends React.PureComponent { el: HTMLElement; componentDidUpdate(prevProps) { @@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent { } interface TypeaheadGroupProps { - items: Suggestion[]; + items: CompletionItem[]; label: string; - onClickItem: (Suggestion) => void; - selected: Suggestion; + onClickItem: (CompletionItem) => void; + selected: CompletionItem; prefix?: string; } -class TypeaheadGroup extends React.PureComponent { +class TypeaheadGroup extends React.PureComponent { render() { const { items, label, selected, onClickItem, prefix } = this.props; return ( @@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent { } interface TypeaheadProps { - groupedItems: SuggestionGroup[]; + groupedItems: CompletionItemGroup[]; menuRef: any; - selectedItem: Suggestion | null; + selectedItem: CompletionItem | null; onClickItem: (Suggestion) => void; prefix?: string; } -class Typeahead extends React.PureComponent { +class Typeahead extends React.PureComponent { render() { const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props; return ( diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index 7e07aafbf6d..de1eee4c662 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -7,6 +7,7 @@ import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore' import { StoreState } from 'app/types'; import { ExploreState } from 'app/types/explore'; +import ErrorBoundary from './ErrorBoundary'; import Explore from './Explore'; interface WrapperProps { @@ -61,28 +62,33 @@ export class Wrapper extends Component { const { split, splitState } = this.state; const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]); const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]); + return (
- - {split && ( + + + {split && ( + + + )}
); diff --git a/public/app/features/org/new_org_ctrl.ts b/public/app/features/org/NewOrgCtrl.ts similarity index 100% rename from public/app/features/org/new_org_ctrl.ts rename to public/app/features/org/NewOrgCtrl.ts diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx new file mode 100644 index 00000000000..2eb45fa368a --- /dev/null +++ b/public/app/features/org/OrgDetailsPage.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { OrgDetailsPage, Props } from './OrgDetailsPage'; +import { NavModel, Organization, OrganizationPreferences } from '../../types'; + +const setup = (propOverrides?: object) => { + const props: Props = { + preferences: {} as OrganizationPreferences, + organization: {} as Organization, + navModel: {} as NavModel, + loadOrganization: jest.fn(), + loadOrganizationPreferences: jest.fn(), + loadStarredDashboards: jest.fn(), + setOrganizationName: jest.fn(), + updateOrganization: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render organization and preferences', () => { + const wrapper = setup({ + organization: { + name: 'Cool org', + id: 1, + }, + preferences: { + homeDashboardId: 1, + theme: 'Default', + timezone: 'Default', + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx new file mode 100644 index 00000000000..86a3fab3268 --- /dev/null +++ b/public/app/features/org/OrgDetailsPage.tsx @@ -0,0 +1,85 @@ +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 OrgProfile from './OrgProfile'; +import OrgPreferences from './OrgPreferences'; +import { + loadOrganization, + loadOrganizationPreferences, + setOrganizationName, + updateOrganization, +} from './state/actions'; +import { loadStarredDashboards } from '../../core/actions/user'; +import { NavModel, Organization, OrganizationPreferences, StoreState } from 'app/types'; +import { getNavModel } from '../../core/selectors/navModel'; + +export interface Props { + navModel: NavModel; + organization: Organization; + preferences: OrganizationPreferences; + loadOrganization: typeof loadOrganization; + loadOrganizationPreferences: typeof loadOrganizationPreferences; + loadStarredDashboards: typeof loadStarredDashboards; + setOrganizationName: typeof setOrganizationName; + updateOrganization: typeof updateOrganization; +} + +export class OrgDetailsPage extends PureComponent { + async componentDidMount() { + await this.props.loadStarredDashboards(); + await this.props.loadOrganization(); + await this.props.loadOrganizationPreferences(); + } + + onOrgNameChange = name => { + this.props.setOrganizationName(name); + }; + + onUpdateOrganization = () => { + this.props.updateOrganization(); + }; + + render() { + const { navModel, organization, preferences } = this.props; + + return ( +
+ +
+ {Object.keys(organization).length === 0 || Object.keys(preferences).length === 0 ? ( + + ) : ( +
+ this.onOrgNameChange(name)} + onSubmit={this.onUpdateOrganization} + orgName={organization.name} + /> + +
+ )} +
+
+ ); + } +} + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(state.navIndex, 'org-settings'), + organization: state.organization.organization, + preferences: state.organization.preferences, + }; +} + +const mapDispatchToProps = { + loadOrganization, + loadOrganizationPreferences, + loadStarredDashboards, + setOrganizationName, + updateOrganization, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(OrgDetailsPage)); diff --git a/public/app/features/org/OrgPreferences.test.tsx b/public/app/features/org/OrgPreferences.test.tsx new file mode 100644 index 00000000000..e79e43d04f2 --- /dev/null +++ b/public/app/features/org/OrgPreferences.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { OrgPreferences, Props } from './OrgPreferences'; + +const setup = () => { + const props: Props = { + preferences: { + homeDashboardId: 1, + timezone: 'UTC', + theme: 'Default', + }, + starredDashboards: [{ id: 1, title: 'Standard dashboard', url: '', uri: '', uid: '', type: '', tags: [] }], + setOrganizationTimezone: jest.fn(), + setOrganizationTheme: jest.fn(), + setOrganizationHomeDashboard: jest.fn(), + updateOrganizationPreferences: jest.fn(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/org/OrgPreferences.tsx b/public/app/features/org/OrgPreferences.tsx new file mode 100644 index 00000000000..a8133ba4a1c --- /dev/null +++ b/public/app/features/org/OrgPreferences.tsx @@ -0,0 +1,113 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { Label } from '../../core/components/Label/Label'; +import SimplePicker from '../../core/components/Picker/SimplePicker'; +import { DashboardSearchHit, OrganizationPreferences } from 'app/types'; +import { + setOrganizationHomeDashboard, + setOrganizationTheme, + setOrganizationTimezone, + updateOrganizationPreferences, +} from './state/actions'; + +export interface Props { + preferences: OrganizationPreferences; + starredDashboards: DashboardSearchHit[]; + setOrganizationHomeDashboard: typeof setOrganizationHomeDashboard; + setOrganizationTheme: typeof setOrganizationTheme; + setOrganizationTimezone: typeof setOrganizationTimezone; + updateOrganizationPreferences: typeof updateOrganizationPreferences; +} + +const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }]; + +const timezones = [ + { value: '', text: 'Default' }, + { value: 'browser', text: 'Local browser time' }, + { value: 'utc', text: 'UTC' }, +]; + +export class OrgPreferences extends PureComponent { + onSubmitForm = event => { + event.preventDefault(); + this.props.updateOrganizationPreferences(); + }; + + render() { + const { + preferences, + starredDashboards, + setOrganizationHomeDashboard, + setOrganizationTimezone, + setOrganizationTheme, + } = this.props; + + starredDashboards.unshift({ id: 0, title: 'Default', tags: [], type: '', uid: '', uri: '', url: '' }); + + return ( +
+

Preferences

+
+ UI Theme + theme.value === preferences.theme)} + options={themes} + getOptionValue={i => i.value} + getOptionLabel={i => i.text} + onSelected={theme => setOrganizationTheme(theme.value)} + width={20} + /> +
+
+ + dashboard.id === preferences.homeDashboardId)} + getOptionValue={i => i.id} + getOptionLabel={i => i.title} + onSelected={(dashboard: DashboardSearchHit) => setOrganizationHomeDashboard(dashboard.id)} + options={starredDashboards} + placeholder="Chose default dashboard" + width={20} + /> +
+
+ + timezone.value === preferences.timezone)} + getOptionValue={i => i.value} + getOptionLabel={i => i.text} + onSelected={timezone => setOrganizationTimezone(timezone.value)} + options={timezones} + width={20} + /> +
+
+ +
+ + ); + } +} + +function mapStateToProps(state) { + return { + preferences: state.organization.preferences, + starredDashboards: state.user.starredDashboards, + }; +} + +const mapDispatchToProps = { + setOrganizationHomeDashboard, + setOrganizationTimezone, + setOrganizationTheme, + updateOrganizationPreferences, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(OrgPreferences); diff --git a/public/app/features/org/OrgProfile.test.tsx b/public/app/features/org/OrgProfile.test.tsx new file mode 100644 index 00000000000..d101eded13e --- /dev/null +++ b/public/app/features/org/OrgProfile.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import OrgProfile, { Props } from './OrgProfile'; + +const setup = () => { + const props: Props = { + orgName: 'Main org', + onSubmit: jest.fn(), + onOrgNameChange: jest.fn(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/org/OrgProfile.tsx b/public/app/features/org/OrgProfile.tsx new file mode 100644 index 00000000000..22dfa7bb1ce --- /dev/null +++ b/public/app/features/org/OrgProfile.tsx @@ -0,0 +1,44 @@ +import React, { SFC } from 'react'; + +export interface Props { + orgName: string; + onSubmit: () => void; + onOrgNameChange: (orgName: string) => void; +} + +const OrgProfile: SFC = ({ onSubmit, onOrgNameChange, orgName }) => { + return ( +
+

Organization profile

+
{ + event.preventDefault(); + onSubmit(); + }} + > +
+
+ Organization name + { + onOrgNameChange(event.target.value); + }} + value={orgName} + /> +
+
+
+ +
+ +
+ ); +}; + +export default OrgProfile; diff --git a/public/app/features/org/select_org_ctrl.ts b/public/app/features/org/SelectOrgCtrl.ts similarity index 100% rename from public/app/features/org/select_org_ctrl.ts rename to public/app/features/org/SelectOrgCtrl.ts diff --git a/public/app/features/org/user_invite_ctrl.ts b/public/app/features/org/UserInviteCtrl.ts similarity index 100% rename from public/app/features/org/user_invite_ctrl.ts rename to public/app/features/org/UserInviteCtrl.ts diff --git a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap new file mode 100644 index 00000000000..28806d2bf1d --- /dev/null +++ b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap @@ -0,0 +1,36 @@ +// 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/org/__snapshots__/OrgPreferences.test.tsx.snap b/public/app/features/org/__snapshots__/OrgPreferences.test.tsx.snap new file mode 100644 index 00000000000..06bf464a4a0 --- /dev/null +++ b/public/app/features/org/__snapshots__/OrgPreferences.test.tsx.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+

+ Preferences +

+
+ + UI Theme + + +
+
+ + Home Dashboard + + +
+
+ + +
+
+ +
+ +`; diff --git a/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap b/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap new file mode 100644 index 00000000000..b49b63e4532 --- /dev/null +++ b/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+

+ Organization profile +

+
+
+
+ + Organization name + + +
+
+
+ +
+ +
+`; diff --git a/public/app/features/org/all.ts b/public/app/features/org/all.ts index 9cbcec8de0d..c905853cf49 100644 --- a/public/app/features/org/all.ts +++ b/public/app/features/org/all.ts @@ -1,8 +1,3 @@ -import './profile_ctrl'; -import './select_org_ctrl'; -import './change_password_ctrl'; -import './new_org_ctrl'; -import './user_invite_ctrl'; -import './create_team_ctrl'; -import './org_details_ctrl'; -import './prefs_control'; +import './SelectOrgCtrl'; +import './NewOrgCtrl'; +import './UserInviteCtrl'; diff --git a/public/app/features/org/org_details_ctrl.ts b/public/app/features/org/org_details_ctrl.ts deleted file mode 100644 index 1d4a92c6e8b..00000000000 --- a/public/app/features/org/org_details_ctrl.ts +++ /dev/null @@ -1,38 +0,0 @@ -import angular from 'angular'; - -export class OrgDetailsCtrl { - /** @ngInject */ - constructor($scope, $http, backendSrv, contextSrv, navModelSrv) { - $scope.init = () => { - $scope.getOrgInfo(); - $scope.navModel = navModelSrv.getNav('cfg', 'org-settings', 0); - }; - - $scope.getOrgInfo = () => { - backendSrv.get('/api/org').then(org => { - $scope.org = org; - $scope.address = org.address; - contextSrv.user.orgName = org.name; - }); - }; - - $scope.update = () => { - if (!$scope.orgForm.$valid) { - return; - } - const data = { name: $scope.org.name }; - backendSrv.put('/api/org', data).then($scope.getOrgInfo); - }; - - $scope.updateAddress = () => { - if (!$scope.addressForm.$valid) { - return; - } - backendSrv.put('/api/org/address', $scope.address).then($scope.getOrgInfo); - }; - - $scope.init(); - } -} - -angular.module('grafana.controllers').controller('OrgDetailsCtrl', OrgDetailsCtrl); diff --git a/public/app/features/org/partials/orgDetails.html b/public/app/features/org/partials/orgDetails.html deleted file mode 100644 index f5a49e3d3ee..00000000000 --- a/public/app/features/org/partials/orgDetails.html +++ /dev/null @@ -1,21 +0,0 @@ - - -
-

Organization profile

- -
-
-
- Organization name - -
-
- -
- -
- - -
- - diff --git a/public/app/features/org/prefs_control.ts b/public/app/features/org/prefs_control.ts deleted file mode 100644 index 74dde250eec..00000000000 --- a/public/app/features/org/prefs_control.ts +++ /dev/null @@ -1,92 +0,0 @@ -import config from 'app/core/config'; -import coreModule from 'app/core/core_module'; - -export class PrefsControlCtrl { - prefs: any; - oldTheme: any; - prefsForm: any; - mode: string; - - timezones: any = [ - { value: '', text: 'Default' }, - { value: 'browser', text: 'Local browser time' }, - { value: 'utc', text: 'UTC' }, - ]; - themes: any = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }]; - - /** @ngInject */ - constructor(private backendSrv, private $location) {} - - $onInit() { - return this.backendSrv.get(`/api/${this.mode}/preferences`).then(prefs => { - this.prefs = prefs; - this.oldTheme = prefs.theme; - }); - } - - updatePrefs() { - if (!this.prefsForm.$valid) { - return; - } - - const cmd = { - theme: this.prefs.theme, - timezone: this.prefs.timezone, - homeDashboardId: this.prefs.homeDashboardId, - }; - - this.backendSrv.put(`/api/${this.mode}/preferences`, cmd).then(() => { - window.location.href = config.appSubUrl + this.$location.path(); - }); - } -} - -const template = ` -
-

Preferences

- -
- UI Theme -
- -
-
- -
- - Home Dashboard - - Not finding dashboard you want? Star it first, then it should appear in this select box. - - - - -
- -
- -
- -
-
- -
- -
- -`; - -export function prefsControlDirective() { - return { - restrict: 'E', - controller: PrefsControlCtrl, - bindToController: true, - controllerAs: 'ctrl', - template: template, - scope: { - mode: '@', - }, - }; -} - -coreModule.directive('prefsControl', prefsControlDirective); diff --git a/public/app/features/org/state/actions.ts b/public/app/features/org/state/actions.ts new file mode 100644 index 00000000000..4df9083c323 --- /dev/null +++ b/public/app/features/org/state/actions.ts @@ -0,0 +1,118 @@ +import { ThunkAction } from 'redux-thunk'; +import { Organization, OrganizationPreferences, StoreState } from 'app/types'; +import { getBackendSrv } from '../../../core/services/backend_srv'; + +type ThunkResult = ThunkAction; + +export enum ActionTypes { + LoadOrganization = 'LOAD_ORGANISATION', + LoadPreferences = 'LOAD_PREFERENCES', + SetOrganizationName = 'SET_ORGANIZATION_NAME', + SetOrganizationTheme = 'SET_ORGANIZATION_THEME', + SetOrganizationHomeDashboard = 'SET_ORGANIZATION_HOME_DASHBOARD', + SetOrganizationTimezone = 'SET_ORGANIZATION_TIMEZONE', +} + +interface LoadOrganizationAction { + type: ActionTypes.LoadOrganization; + payload: Organization; +} + +interface LoadPreferencesAction { + type: ActionTypes.LoadPreferences; + payload: OrganizationPreferences; +} + +interface SetOrganizationNameAction { + type: ActionTypes.SetOrganizationName; + payload: string; +} + +interface SetOrganizationThemeAction { + type: ActionTypes.SetOrganizationTheme; + payload: string; +} + +interface SetOrganizationHomeDashboardAction { + type: ActionTypes.SetOrganizationHomeDashboard; + payload: number; +} + +interface SetOrganizationTimezoneAction { + type: ActionTypes.SetOrganizationTimezone; + payload: string; +} + +const organisationLoaded = (organisation: Organization) => ({ + type: ActionTypes.LoadOrganization, + payload: organisation, +}); + +const preferencesLoaded = (preferences: OrganizationPreferences) => ({ + type: ActionTypes.LoadPreferences, + payload: preferences, +}); + +export const setOrganizationName = (orgName: string) => ({ + type: ActionTypes.SetOrganizationName, + payload: orgName, +}); + +export const setOrganizationTheme = (theme: string) => ({ + type: ActionTypes.SetOrganizationTheme, + payload: theme, +}); + +export const setOrganizationHomeDashboard = (id: number) => ({ + type: ActionTypes.SetOrganizationHomeDashboard, + payload: id, +}); + +export const setOrganizationTimezone = (timezone: string) => ({ + type: ActionTypes.SetOrganizationTimezone, + payload: timezone, +}); + +export type Action = + | LoadOrganizationAction + | LoadPreferencesAction + | SetOrganizationNameAction + | SetOrganizationThemeAction + | SetOrganizationHomeDashboardAction + | SetOrganizationTimezoneAction; + +export function loadOrganization(): ThunkResult { + return async dispatch => { + const organisationResponse = await getBackendSrv().get('/api/org'); + dispatch(organisationLoaded(organisationResponse)); + + return organisationResponse; + }; +} + +export function loadOrganizationPreferences(): ThunkResult { + return async dispatch => { + const preferencesResponse = await getBackendSrv().get('/api/org/preferences'); + dispatch(preferencesLoaded(preferencesResponse)); + }; +} + +export function updateOrganization() { + return async (dispatch, getStore) => { + const organization = getStore().organization.organization; + + await getBackendSrv().put('/api/org', { name: organization.name }); + + dispatch(loadOrganization()); + }; +} + +export function updateOrganizationPreferences() { + return async (dispatch, getStore) => { + const preferences = getStore().organization.preferences; + + await getBackendSrv().put('/api/org/preferences', preferences); + + window.location.reload(); + }; +} diff --git a/public/app/features/org/state/reducers.ts b/public/app/features/org/state/reducers.ts new file mode 100644 index 00000000000..b79f915a731 --- /dev/null +++ b/public/app/features/org/state/reducers.ts @@ -0,0 +1,35 @@ +import { Organization, OrganizationPreferences, OrganizationState } from 'app/types'; +import { Action, ActionTypes } from './actions'; + +const initialState: OrganizationState = { + organization: {} as Organization, + preferences: {} as OrganizationPreferences, +}; + +const organizationReducer = (state = initialState, action: Action): OrganizationState => { + switch (action.type) { + case ActionTypes.LoadOrganization: + return { ...state, organization: action.payload }; + + case ActionTypes.LoadPreferences: + return { ...state, preferences: action.payload }; + + case ActionTypes.SetOrganizationName: + return { ...state, organization: { ...state.organization, name: action.payload } }; + + case ActionTypes.SetOrganizationTheme: + return { ...state, preferences: { ...state.preferences, theme: action.payload } }; + + case ActionTypes.SetOrganizationHomeDashboard: + return { ...state, preferences: { ...state.preferences, homeDashboardId: action.payload } }; + + case ActionTypes.SetOrganizationTimezone: + return { ...state, preferences: { ...state.preferences, timezone: action.payload } }; + } + + return state; +}; + +export default { + organization: organizationReducer, +}; diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 5e216f6b34d..08605132e82 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -48,11 +48,6 @@ export class PanelCtrl { } $scope.$on('component-did-mount', () => this.panelDidMount()); - - $scope.$on('$destroy', () => { - this.events.emit('panel-teardown'); - this.events.removeAllListeners(); - }); } panelDidMount() { diff --git a/public/app/features/panel/solo_panel_ctrl.ts b/public/app/features/panel/solo_panel_ctrl.ts index 15d35188d6d..a8bf5371913 100644 --- a/public/app/features/panel/solo_panel_ctrl.ts +++ b/public/app/features/panel/solo_panel_ctrl.ts @@ -14,7 +14,7 @@ export class SoloPanelCtrl { const params = $location.search(); panelId = parseInt(params.panelId, 10); - $scope.onAppEvent('dashboard-initialized', $scope.initPanelScope); + appEvents.on('dashboard-initialized', $scope.initPanelScope); // if no uid, redirect to new route based on slug if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) { diff --git a/public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap b/public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap index 176304b7b11..5eb82fbefb6 100644 --- a/public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap +++ b/public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap @@ -33,6 +33,7 @@ exports[`Render should render component 1`] = ` "version": "1", }, "latestVersion": "1.0", + "module": Object {}, "name": "pretty cool plugin-0", "pinned": false, "state": "", @@ -66,6 +67,7 @@ exports[`Render should render component 1`] = ` "version": "1", }, "latestVersion": "1.1", + "module": Object {}, "name": "pretty cool plugin-1", "pinned": false, "state": "", @@ -99,6 +101,7 @@ exports[`Render should render component 1`] = ` "version": "1", }, "latestVersion": "1.2", + "module": Object {}, "name": "pretty cool plugin-2", "pinned": false, "state": "", @@ -132,6 +135,7 @@ exports[`Render should render component 1`] = ` "version": "1", }, "latestVersion": "1.3", + "module": Object {}, "name": "pretty cool plugin-3", "pinned": false, "state": "", @@ -165,6 +169,7 @@ exports[`Render should render component 1`] = ` "version": "1", }, "latestVersion": "1.4", + "module": Object {}, "name": "pretty cool plugin-4", "pinned": false, "state": "", @@ -198,6 +203,7 @@ exports[`Render should render component 1`] = ` "version": "1", }, "latestVersion": "1.5", + "module": Object {}, "name": "pretty cool plugin-5", "pinned": false, "state": "", diff --git a/public/app/features/plugins/all.ts b/public/app/features/plugins/all.ts index c9fb250266c..ce9bcbff3bc 100644 --- a/public/app/features/plugins/all.ts +++ b/public/app/features/plugins/all.ts @@ -1,4 +1,3 @@ -import './plugin_edit_ctrl'; import './plugin_page_ctrl'; import './import_list/import_list'; import './ds_edit_ctrl'; diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 71a417a882f..fed455472c9 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -8,9 +8,10 @@ import { importPluginModule } from './plugin_loader'; // Types import { DataSourceApi } from 'app/types/series'; +import { DataSource } from 'app/types'; export class DatasourceSrv { - datasources: any; + datasources: { [name: string]: DataSource }; /** @ngInject */ constructor(private $q, private $injector, private $rootScope, private templateSrv) { @@ -61,9 +62,10 @@ export class DatasourceSrv { throw new Error('Plugin module is missing Datasource constructor'); } - const instance = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig }); + const instance: DataSource = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig }); instance.meta = pluginDef; instance.name = name; + instance.pluginExports = plugin; this.datasources[name] = instance; deferred.resolve(instance); }) diff --git a/public/app/features/plugins/ds_dashboards_ctrl.ts b/public/app/features/plugins/ds_dashboards_ctrl.ts index a0324215453..71639702e41 100644 --- a/public/app/features/plugins/ds_dashboards_ctrl.ts +++ b/public/app/features/plugins/ds_dashboards_ctrl.ts @@ -1,5 +1,5 @@ import { coreModule } from 'app/core/core'; -import { store } from 'app/store/configureStore'; +import { store } from 'app/store/store'; import { getNavModel } from 'app/core/selectors/navModel'; import { buildNavModel } from './state/navModel'; diff --git a/public/app/features/plugins/ds_edit_ctrl.ts b/public/app/features/plugins/ds_edit_ctrl.ts index c223f444ef3..5e203a96e8b 100644 --- a/public/app/features/plugins/ds_edit_ctrl.ts +++ b/public/app/features/plugins/ds_edit_ctrl.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import config from 'app/core/config'; import { coreModule, appEvents } from 'app/core/core'; -import { store } from 'app/store/configureStore'; +import { store } from 'app/store/store'; import { getNavModel } from 'app/core/selectors/navModel'; import { buildNavModel } from './state/navModel'; diff --git a/public/app/features/plugins/partials/ds_edit.html b/public/app/features/plugins/partials/ds_edit.html deleted file mode 100644 index 0b83e69c7d2..00000000000 --- a/public/app/features/plugins/partials/ds_edit.html +++ /dev/null @@ -1,72 +0,0 @@ - - -
-

Settings

- -
-
-
-
- Name - - - The name is used when you select the data source in panels. - The Default data source is preselected in new - panels. - -
- -
-
- -
- This plugin is marked as being in alpha state, which means it is in early development phase and - updates will include breaking changes. -
- -
- This plugin is marked as being in a beta development state. This means it is in currently in active development and could be - missing important features. -
- - - - - - -
-

Bundled Plugin Dashboards

-
- -
-
- -
-
Testing....
-
-
- - -
-
-
{{ctrl.testing.message}}
-
-
-
- -
- This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource. -
- -
- - - Back -
- -
-
-
- - -
diff --git a/public/app/features/plugins/plugin_edit_ctrl.ts b/public/app/features/plugins/plugin_edit_ctrl.ts deleted file mode 100644 index 44d3d31f996..00000000000 --- a/public/app/features/plugins/plugin_edit_ctrl.ts +++ /dev/null @@ -1,179 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash'; -import Remarkable from 'remarkable'; - -export class PluginEditCtrl { - model: any; - pluginIcon: string; - pluginId: any; - includes: any; - readmeHtml: any; - includedDatasources: any; - tab: string; - navModel: any; - hasDashboards: any; - preUpdateHook: () => any; - postUpdateHook: () => any; - - /** @ngInject */ - constructor(private $scope, private $rootScope, private backendSrv, private $sce, private $routeParams, navModelSrv) { - this.pluginId = $routeParams.pluginId; - this.preUpdateHook = () => Promise.resolve(); - this.postUpdateHook = () => Promise.resolve(); - - this.init(); - } - - setNavModel(model) { - let defaultTab = 'readme'; - - this.navModel = { - main: { - img: model.info.logos.large, - subTitle: model.info.author.name, - url: '', - text: model.name, - breadcrumbs: [{ title: 'Plugins', url: 'plugins' }], - children: [ - { - icon: 'fa fa-fw fa-file-text-o', - id: 'readme', - text: 'Readme', - url: `plugins/${this.model.id}/edit?tab=readme`, - }, - ], - }, - }; - - if (model.type === 'app') { - this.navModel.main.children.push({ - icon: 'gicon gicon-cog', - id: 'config', - text: 'Config', - url: `plugins/${this.model.id}/edit?tab=config`, - }); - - const hasDashboards = _.find(model.includes, { type: 'dashboard' }); - - if (hasDashboards) { - this.navModel.main.children.push({ - icon: 'gicon gicon-dashboard', - id: 'dashboards', - text: 'Dashboards', - url: `plugins/${this.model.id}/edit?tab=dashboards`, - }); - } - - defaultTab = 'config'; - } - - this.tab = this.$routeParams.tab || defaultTab; - - for (const tab of this.navModel.main.children) { - if (tab.id === this.tab) { - tab.active = true; - } - } - } - - init() { - return this.backendSrv.get(`/api/plugins/${this.pluginId}/settings`).then(result => { - this.model = result; - this.pluginIcon = this.getPluginIcon(this.model.type); - - this.model.dependencies.plugins.forEach(plug => { - plug.icon = this.getPluginIcon(plug.type); - }); - - this.includes = _.map(result.includes, plug => { - plug.icon = this.getPluginIcon(plug.type); - return plug; - }); - - this.setNavModel(this.model); - return this.initReadme(); - }); - } - - initReadme() { - return this.backendSrv.get(`/api/plugins/${this.pluginId}/markdown/readme`).then(res => { - const md = new Remarkable({ - linkify: true, - }); - this.readmeHtml = this.$sce.trustAsHtml(md.render(res)); - }); - } - - getPluginIcon(type) { - switch (type) { - case 'datasource': - return 'icon-gf icon-gf-datasources'; - case 'panel': - return 'icon-gf icon-gf-panel'; - case 'app': - return 'icon-gf icon-gf-apps'; - case 'page': - return 'icon-gf icon-gf-endpoint-tiny'; - case 'dashboard': - return 'icon-gf icon-gf-dashboard'; - default: - return 'icon-gf icon-gf-apps'; - } - } - - update() { - this.preUpdateHook() - .then(() => { - const updateCmd = _.extend( - { - enabled: this.model.enabled, - pinned: this.model.pinned, - jsonData: this.model.jsonData, - secureJsonData: this.model.secureJsonData, - }, - {} - ); - return this.backendSrv.post(`/api/plugins/${this.pluginId}/settings`, updateCmd); - }) - .then(this.postUpdateHook) - .then(res => { - window.location.href = window.location.href; - }); - } - - importDashboards() { - return Promise.resolve(); - } - - setPreUpdateHook(callback: () => any) { - this.preUpdateHook = callback; - } - - setPostUpdateHook(callback: () => any) { - this.postUpdateHook = callback; - } - - updateAvailable() { - const modalScope = this.$scope.$new(true); - modalScope.plugin = this.model; - - this.$rootScope.appEvent('show-modal', { - src: 'public/app/features/plugins/partials/update_instructions.html', - scope: modalScope, - }); - } - - enable() { - this.model.enabled = true; - this.model.pinned = true; - this.update(); - } - - disable() { - this.model.enabled = false; - this.model.pinned = false; - this.update(); - } -} - -angular.module('grafana.controllers').controller('PluginEditCtrl', PluginEditCtrl); diff --git a/public/app/features/org/change_password_ctrl.ts b/public/app/features/profile/ChangePasswordCtrl.ts similarity index 100% rename from public/app/features/org/change_password_ctrl.ts rename to public/app/features/profile/ChangePasswordCtrl.ts diff --git a/public/app/features/org/profile_ctrl.ts b/public/app/features/profile/ProfileCtrl.ts similarity index 100% rename from public/app/features/org/profile_ctrl.ts rename to public/app/features/profile/ProfileCtrl.ts diff --git a/public/app/features/org/partials/change_password.html b/public/app/features/profile/partials/change_password.html similarity index 100% rename from public/app/features/org/partials/change_password.html rename to public/app/features/profile/partials/change_password.html diff --git a/public/app/features/org/partials/profile.html b/public/app/features/profile/partials/profile.html similarity index 100% rename from public/app/features/org/partials/profile.html rename to public/app/features/profile/partials/profile.html diff --git a/public/app/features/org/create_team_ctrl.ts b/public/app/features/teams/CreateTeamCtrl.ts similarity index 100% rename from public/app/features/org/create_team_ctrl.ts rename to public/app/features/teams/CreateTeamCtrl.ts diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index 2534a08ed15..da57bfbdfd3 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -74,7 +74,7 @@ export class TeamMembers extends PureComponent {
- {syncEnabled ? this.renderLabels(member.labels) : ''} + {syncEnabled ? this.renderLabels(member.labels) : null} diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index ef9a5ae0b70..45977de95bf 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Label } from 'app/core/components/Forms/Forms'; +import { Label } from 'app/core/components/Label/Label'; import { Team } from '../../types'; import { updateTeam } from './state/actions'; import { getRouteParamsId } from '../../core/selectors/location'; diff --git a/public/app/features/org/partials/create_team.html b/public/app/features/teams/partials/create_team.html similarity index 100% rename from public/app/features/org/partials/create_team.html rename to public/app/features/teams/partials/create_team.html diff --git a/public/app/features/templating/specs/template_srv.test.ts b/public/app/features/templating/specs/template_srv.test.ts index d279029d64d..7805341d1a2 100644 --- a/public/app/features/templating/specs/template_srv.test.ts +++ b/public/app/features/templating/specs/template_srv.test.ts @@ -286,10 +286,40 @@ describe('templateSrv', () => { initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); - it('should return true if exists', () => { + it('should return true if $test exists', () => { const result = _templateSrv.variableExists('$test'); expect(result).toBe(true); }); + + it('should return true if $test exists in string', () => { + const result = _templateSrv.variableExists('something $test something'); + expect(result).toBe(true); + }); + + it('should return true if [[test]] exists in string', () => { + const result = _templateSrv.variableExists('something [[test]] something'); + expect(result).toBe(true); + }); + + it('should return true if [[test:csv]] exists in string', () => { + const result = _templateSrv.variableExists('something [[test:csv]] something'); + expect(result).toBe(true); + }); + + it('should return true if ${test} exists in string', () => { + const result = _templateSrv.variableExists('something ${test} something'); + expect(result).toBe(true); + }); + + it('should return true if ${test:raw} exists in string', () => { + const result = _templateSrv.variableExists('something ${test:raw} something'); + expect(result).toBe(true); + }); + + it('should return null if there are no variables in string', () => { + const result = _templateSrv.variableExists('string without variables'); + expect(result).toBe(null); + }); }); describe('can highlight variables in string', () => { diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 61326ad63ec..0db7b8e77e0 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -136,7 +136,8 @@ export class TemplateSrv { if (!match) { return null; } - return match[1] || match[2]; + const variableName = match.slice(1).find(match => match !== undefined); + return variableName; } variableExists(expression) { diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 260de80e389..a2bc8173766 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -2,7 +2,7 @@
{member.login} {member.email} this.onRemoveMember(member)} />
+ + + + + + + + + {seriesList.map((series, i) => ( + +
+ {LEGEND_STATS.map( + statName => + seriesValuesProps[statName] && ( + + ) + )} +
+ ); + } +} + +interface LegendTableHeaderProps { + statName: string; + onClick?: (statName: string) => void; +} + +class LegendTableHeaderItem extends PureComponent { + onClick = () => this.props.onClick(this.props.statName); + + render() { + const { statName, sort, sortDesc } = this.props; + return ( + + {statName} + {sort === statName && } + + ); + } +} + +export class Legend extends PureComponent { + render() { + return ( + + + + ); + } +} + +export default Legend; diff --git a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx new file mode 100644 index 00000000000..2105687d8e1 --- /dev/null +++ b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx @@ -0,0 +1,196 @@ +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import { TimeSeries } from 'app/core/core'; +import { SeriesColorPicker } from 'app/core/components/colorpicker/SeriesColorPicker'; + +export const LEGEND_STATS = ['min', 'max', 'avg', 'current', 'total']; + +export interface LegendLabelProps { + series: TimeSeries; + asTable?: boolean; + hidden?: boolean; + onLabelClick?: (series, event) => void; + onColorChange?: (series, color: string) => void; + onToggleAxis?: (series) => void; +} + +export interface LegendValuesProps { + values?: boolean; + min?: boolean; + max?: boolean; + avg?: boolean; + current?: boolean; + total?: boolean; +} + +type LegendItemProps = LegendLabelProps & LegendValuesProps; + +interface LegendItemState { + yaxis: number; +} + +export class LegendItem extends PureComponent { + static defaultProps = { + asTable: false, + hidden: false, + onLabelClick: () => {}, + onColorChange: () => {}, + onToggleAxis: () => {}, + }; + + constructor(props) { + super(props); + this.state = { + yaxis: this.props.series.yaxis, + }; + } + + onLabelClick = e => this.props.onLabelClick(this.props.series, e); + + onToggleAxis = () => { + const yaxis = this.state.yaxis === 2 ? 1 : 2; + const info = { alias: this.props.series.alias, yaxis: yaxis }; + this.setState({ yaxis: yaxis }); + this.props.onToggleAxis(info); + }; + + onColorChange = color => { + this.props.onColorChange(this.props.series, color); + // Because of PureComponent nature it makes only shallow props comparison and changing of series.color doesn't run + // component re-render. In this case we can't rely on color, selected by user, because it may be overwritten + // by series overrides. So we need to use forceUpdate() to make sure we have proper series color. + this.forceUpdate(); + }; + + renderLegendValues() { + const { series, asTable } = this.props; + const legendValueItems = []; + for (const valueName of LEGEND_STATS) { + if (this.props[valueName]) { + const valueFormatted = series.formatValue(series.stats[valueName]); + legendValueItems.push( + + ); + } + } + return legendValueItems; + } + + render() { + const { series, values, asTable, hidden } = this.props; + const seriesOptionClasses = classNames({ + 'graph-legend-series-hidden': hidden, + 'graph-legend-series--right-y': series.yaxis === 2, + }); + const valueItems = values ? this.renderLegendValues() : []; + const seriesLabel = ( + + ); + + if (asTable) { + return ( + + {seriesLabel} + {valueItems} + + ); + } else { + return ( +
+ {seriesLabel} + {valueItems} +
+ ); + } + } +} + +interface LegendSeriesLabelProps { + label: string; + color: string; + yaxis?: number; + onLabelClick?: (event) => void; +} + +class LegendSeriesLabel extends PureComponent { + static defaultProps = { + yaxis: undefined, + onLabelClick: () => {}, + }; + + render() { + const { label, color, yaxis } = this.props; + const { onColorChange, onToggleAxis } = this.props; + return [ + , + this.props.onLabelClick(e)}> + {label} + , + ]; + } +} + +interface LegendSeriesIconProps { + color: string; + yaxis?: number; + onColorChange?: (color: string) => void; + onToggleAxis?: () => void; +} + +interface LegendSeriesIconState { + color: string; +} + +function SeriesIcon(props) { + return ; +} + +class LegendSeriesIcon extends PureComponent { + static defaultProps = { + yaxis: undefined, + onColorChange: () => {}, + onToggleAxis: () => {}, + }; + + render() { + return ( + + + + ); + } +} + +interface LegendValueProps { + value: string; + valueName: string; + asTable?: boolean; +} + +function LegendValue(props: LegendValueProps) { + const value = props.value; + const valueName = props.valueName; + if (props.asTable) { + return {value}; + } + return
{value}
; +} diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 7a8e24539f7..01afd0716e6 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -20,6 +20,9 @@ import { EventManager } from 'app/features/annotations/all'; import { convertToHistogramData } from './histogram'; import { alignYLevel } from './align_yaxes'; import config from 'app/core/config'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Legend, GraphLegendProps } from './Legend/Legend'; import { GraphCtrl } from './module'; @@ -35,6 +38,7 @@ class GraphElement { panelWidth: number; eventManager: EventManager; thresholdManager: ThresholdManager; + legendElem: HTMLElement; constructor(private scope, private elem, private timeSrv) { this.ctrl = scope.ctrl; @@ -50,7 +54,7 @@ class GraphElement { }); // panel events - this.ctrl.events.on('panel-teardown', this.onPanelteardown.bind(this)); + this.ctrl.events.on('panel-teardown', this.onPanelTeardown.bind(this)); /** * Split graph rendering into two parts. @@ -63,13 +67,14 @@ class GraphElement { // global events appEvents.on('graph-hover', this.onGraphHover.bind(this), scope); - appEvents.on('graph-hover-clear', this.onGraphHoverClear.bind(this), scope); - this.elem.bind('plotselected', this.onPlotSelected.bind(this)); - this.elem.bind('plotclick', this.onPlotClick.bind(this)); - scope.$on('$destroy', this.onScopeDestroy.bind(this)); + + // get graph legend element + if (this.elem && this.elem.parent) { + this.legendElem = this.elem.parent().find('.graph-legend')[0]; + } } onRender(renderData) { @@ -82,7 +87,26 @@ class GraphElement { const graphHeight = this.elem.height(); updateLegendValues(this.data, this.panel, graphHeight); - this.ctrl.events.emit('render-legend'); + const { values, min, max, avg, current, total } = this.panel.legend; + const { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero } = this.panel.legend; + const legendOptions = { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero }; + const valueOptions = { values, min, max, avg, current, total }; + const legendProps: GraphLegendProps = { + seriesList: this.data, + hiddenSeries: this.ctrl.hiddenSeries, + ...legendOptions, + ...valueOptions, + onToggleSeries: this.ctrl.onToggleSeries, + onToggleSort: this.ctrl.onToggleSort, + onColorChange: this.ctrl.onColorChange, + onToggleAxis: this.ctrl.onToggleAxis, + }; + const legendReactElem = React.createElement(Legend, legendProps); + ReactDOM.render(legendReactElem, this.legendElem, () => this.onLegendRenderingComplete()); + } + + onLegendRenderingComplete() { + this.render_panel(); } onGraphHover(evt) { @@ -99,17 +123,19 @@ class GraphElement { this.tooltip.show(evt.pos); } - onPanelteardown() { + onPanelTeardown() { this.thresholdManager = null; if (this.plot) { this.plot.destroy(); this.plot = null; } - } - onLegendRenderingComplete() { - this.render_panel(); + this.tooltip.destroy(); + this.elem.off(); + this.elem.remove(); + + ReactDOM.unmountComponentAtNode(this.legendElem); } onGraphHoverClear(event, info) { @@ -157,12 +183,6 @@ class GraphElement { } } - onScopeDestroy() { - this.tooltip.destroy(); - this.elem.off(); - this.elem.remove(); - } - shouldAbortRender() { if (!this.data) { return true; diff --git a/public/app/plugins/panel/graph/legend.ts b/public/app/plugins/panel/graph/legend.ts deleted file mode 100644 index db4dfdefa1c..00000000000 --- a/public/app/plugins/panel/graph/legend.ts +++ /dev/null @@ -1,306 +0,0 @@ -import _ from 'lodash'; -import $ from 'jquery'; -import baron from 'baron'; -import coreModule from 'app/core/core_module'; - -/** @ngInject */ -function graphLegendDirective(popoverSrv, $timeout) { - return { - link: (scope, elem) => { - let firstRender = true; - const ctrl = scope.ctrl; - const panel = ctrl.panel; - let data; - let seriesList; - let i; - let legendScrollbar; - const legendRightDefaultWidth = 10; - const legendElem = elem.parent(); - - scope.$on('$destroy', () => { - destroyScrollbar(); - }); - - ctrl.events.on('render-legend', () => { - data = ctrl.seriesList; - if (data) { - render(); - } - ctrl.events.emit('legend-rendering-complete'); - }); - - function getSeriesIndexForElement(el) { - return el.parents('[data-series-index]').data('series-index'); - } - - function openColorSelector(e) { - // if we clicked inside poup container ignore click - if ($(e.target).parents('.popover').length) { - return; - } - - const el = $(e.currentTarget).find('.fa-minus'); - const index = getSeriesIndexForElement(el); - const series = seriesList[index]; - - $timeout(() => { - popoverSrv.show({ - element: el[0], - position: 'bottom left', - targetAttachment: 'top left', - template: - '' + - '', - openOn: 'hover', - model: { - series: series, - toggleAxis: () => { - ctrl.toggleAxis(series); - }, - colorSelected: color => { - ctrl.changeSeriesColor(series, color); - }, - }, - }); - }); - } - - function toggleSeries(e) { - const el = $(e.currentTarget); - const index = getSeriesIndexForElement(el); - const seriesInfo = seriesList[index]; - const scrollPosition = legendScrollbar.scroller.scrollTop; - ctrl.toggleSeries(seriesInfo, e); - legendScrollbar.scroller.scrollTop = scrollPosition; - } - - function sortLegend(e) { - const el = $(e.currentTarget); - const stat = el.data('stat'); - - if (stat !== panel.legend.sort) { - panel.legend.sortDesc = null; - } - - // if already sort ascending, disable sorting - if (panel.legend.sortDesc === false) { - panel.legend.sort = null; - panel.legend.sortDesc = null; - ctrl.render(); - return; - } - - panel.legend.sortDesc = !panel.legend.sortDesc; - panel.legend.sort = stat; - ctrl.render(); - } - - function getTableHeaderHtml(statName) { - if (!panel.legend[statName]) { - return ''; - } - let html = '' + statName; - - if (panel.legend.sort === statName) { - const cssClass = panel.legend.sortDesc ? 'fa fa-caret-down' : 'fa fa-caret-up'; - html += ' '; - } - - return html + ''; - } - - function render() { - const legendWidth = legendElem.width(); - if (!ctrl.panel.legend.show) { - elem.empty(); - firstRender = true; - return; - } - - if (firstRender) { - elem.on('click', '.graph-legend-icon', openColorSelector); - elem.on('click', '.graph-legend-alias', toggleSeries); - elem.on('click', 'th', sortLegend); - firstRender = false; - } - - seriesList = data; - - elem.empty(); - - // Set min-width if side style and there is a value, otherwise remove the CSS property - // Set width so it works with IE11 - const width: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth + 'px' : ''; - const ieWidth: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth - 1 + 'px' : ''; - legendElem.css('min-width', width); - legendElem.css('width', ieWidth); - - elem.toggleClass('graph-legend-table', panel.legend.alignAsTable === true); - - let tableHeaderElem; - if (panel.legend.alignAsTable) { - let header = ''; - header += ''; - if (panel.legend.values) { - header += getTableHeaderHtml('min'); - header += getTableHeaderHtml('max'); - header += getTableHeaderHtml('avg'); - header += getTableHeaderHtml('current'); - header += getTableHeaderHtml('total'); - } - header += ''; - tableHeaderElem = $(header); - } - - if (panel.legend.sort) { - seriesList = _.sortBy(seriesList, series => { - let sort = series.stats[panel.legend.sort]; - if (sort === null) { - sort = -Infinity; - } - return sort; - }); - if (panel.legend.sortDesc) { - seriesList = seriesList.reverse(); - } - } - - // render first time for getting proper legend height - if (!panel.legend.rightSide || (panel.legend.rightSide && legendWidth !== legendRightDefaultWidth)) { - renderLegendElement(tableHeaderElem); - elem.empty(); - } - - renderLegendElement(tableHeaderElem); - } - - function renderSeriesLegendElements() { - const seriesElements = []; - for (i = 0; i < seriesList.length; i++) { - const series = seriesList[i]; - - if (series.hideFromLegend(panel.legend)) { - continue; - } - - let html = '
'; - html += '
'; - html += ''; - html += '
'; - - html += - '' + series.aliasEscaped + ''; - - if (panel.legend.values) { - const avg = series.formatValue(series.stats.avg); - const current = series.formatValue(series.stats.current); - const min = series.formatValue(series.stats.min); - const max = series.formatValue(series.stats.max); - const total = series.formatValue(series.stats.total); - - if (panel.legend.min) { - html += '
' + min + '
'; - } - if (panel.legend.max) { - html += '
' + max + '
'; - } - if (panel.legend.avg) { - html += '
' + avg + '
'; - } - if (panel.legend.current) { - html += '
' + current + '
'; - } - if (panel.legend.total) { - html += '
' + total + '
'; - } - } - - html += '
'; - seriesElements.push($(html)); - } - return seriesElements; - } - - function renderLegendElement(tableHeaderElem) { - const legendWidth = elem.width(); - - const seriesElements = renderSeriesLegendElements(); - - if (panel.legend.alignAsTable) { - const tbodyElem = $(''); - tbodyElem.append(tableHeaderElem); - tbodyElem.append(seriesElements); - elem.append(tbodyElem); - tbodyElem.wrap('
'); - } else { - elem.append('
'); - elem.find('.graph-legend-scroll').append(seriesElements); - } - - if (!panel.legend.rightSide || (panel.legend.rightSide && legendWidth !== legendRightDefaultWidth)) { - addScrollbar(); - } else { - destroyScrollbar(); - } - } - - function addScrollbar() { - const scrollRootClass = 'baron baron__root'; - const scrollerClass = 'baron__scroller'; - const scrollBarHTML = ` -
-
-
- `; - - const scrollRoot = elem; - const scroller = elem.find('.graph-legend-scroll'); - - // clear existing scroll bar track to prevent duplication - scrollRoot.find('.baron__track').remove(); - - scrollRoot.addClass(scrollRootClass); - $(scrollBarHTML).appendTo(scrollRoot); - scroller.addClass(scrollerClass); - - const scrollbarParams = { - root: scrollRoot[0], - scroller: scroller[0], - bar: '.baron__bar', - track: '.baron__track', - barOnCls: '_scrollbar', - scrollingCls: '_scrolling', - }; - - if (!legendScrollbar) { - legendScrollbar = baron(scrollbarParams); - } else { - destroyScrollbar(); - legendScrollbar = baron(scrollbarParams); - } - - // #11830 - compensates for Firefox scrollbar calculation error in the baron framework - scroller[0].style.marginRight = '-' + (scroller[0].offsetWidth - scroller[0].clientWidth) + 'px'; - - legendScrollbar.scroll(); - } - - function destroyScrollbar() { - if (legendScrollbar) { - legendScrollbar.dispose(); - legendScrollbar = undefined; - } - } - }, - }; -} - -coreModule.directive('graphLegend', graphLegendDirective); diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 5878473b4e6..a6c5190d937 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -1,5 +1,4 @@ import './graph'; -import './legend'; import './series_overrides_ctrl'; import './thresholds_form'; @@ -244,67 +243,32 @@ class GraphCtrl extends MetricsPanelCtrl { } } - changeSeriesColor(series, color) { + onColorChange = (series, color) => { series.setColor(color); this.panel.aliasColors[series.alias] = series.color; this.render(); - } + }; - toggleSeries(serie, event) { - if (event.ctrlKey || event.metaKey || event.shiftKey) { - if (this.hiddenSeries[serie.alias]) { - delete this.hiddenSeries[serie.alias]; - } else { - this.hiddenSeries[serie.alias] = true; - } - } else { - this.toggleSeriesExclusiveMode(serie); - } + onToggleSeries = hiddenSeries => { + this.hiddenSeries = hiddenSeries; this.render(); - } + }; - toggleSeriesExclusiveMode(serie) { - const hidden = this.hiddenSeries; + onToggleSort = (sortBy, sortDesc) => { + this.panel.legend.sort = sortBy; + this.panel.legend.sortDesc = sortDesc; + this.render(); + }; - if (hidden[serie.alias]) { - delete hidden[serie.alias]; - } - - // check if every other series is hidden - const alreadyExclusive = _.every(this.seriesList, value => { - if (value.alias === serie.alias) { - return true; - } - - return hidden[value.alias]; - }); - - if (alreadyExclusive) { - // remove all hidden series - _.each(this.seriesList, value => { - delete this.hiddenSeries[value.alias]; - }); - } else { - // hide all but this serie - _.each(this.seriesList, value => { - if (value.alias === serie.alias) { - return; - } - - this.hiddenSeries[value.alias] = true; - }); - } - } - - toggleAxis(info) { + onToggleAxis = info => { let override = _.find(this.panel.seriesOverrides, { alias: info.alias }); if (!override) { override = { alias: info.alias }; this.panel.seriesOverrides.push(override); } - info.yaxis = override.yaxis = info.yaxis === 2 ? 1 : 2; + override.yaxis = info.yaxis; this.render(); - } + }; addSeriesOverride(override) { this.panel.seriesOverrides.push(override || {}); diff --git a/public/app/plugins/panel/graph/series_overrides_ctrl.ts b/public/app/plugins/panel/graph/series_overrides_ctrl.ts index 540d19fb47a..934fd835347 100644 --- a/public/app/plugins/panel/graph/series_overrides_ctrl.ts +++ b/public/app/plugins/panel/graph/series_overrides_ctrl.ts @@ -53,7 +53,7 @@ export function SeriesOverridesCtrl($scope, $element, popoverSrv) { element: $element.find('.dropdown')[0], position: 'top center', openOn: 'click', - template: '', + template: '', model: { autoClose: true, colorSelected: $scope.colorSelected, diff --git a/public/app/plugins/panel/graph2/module.tsx b/public/app/plugins/panel/graph2/module.tsx index c2b8c355440..b132d3374f1 100644 --- a/public/app/plugins/panel/graph2/module.tsx +++ b/public/app/plugins/panel/graph2/module.tsx @@ -1,21 +1,21 @@ -// Libraries import _ from 'lodash'; import React, { PureComponent } from 'react'; -// Components import Graph from 'app/viz/Graph'; -import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; +import { Switch } from 'app/core/components/Switch/Switch'; -// Types -import { PanelProps, NullValueMode } from 'app/types'; +import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; +import { PanelProps, PanelOptionsProps, NullValueMode } from 'app/types'; interface Options { showBars: boolean; + showLines: boolean; + showPoints: boolean; + + onChange: (options: Options) => void; } -interface Props extends PanelProps { - options: Options; -} +interface Props extends PanelProps {} export class Graph2 extends PureComponent { constructor(props) { @@ -24,20 +24,52 @@ export class Graph2 extends PureComponent { render() { const { timeSeries, timeRange } = this.props; + const { showLines, showBars, showPoints } = this.props.options; const vmSeries = getTimeSeriesVMs({ timeSeries: timeSeries, nullValueMode: NullValueMode.Ignore, }); - return ; + return ( + + ); } } -export class TextOptions extends PureComponent { +export class GraphOptions extends PureComponent> { + onToggleLines = () => { + this.props.onChange({ ...this.props.options, showLines: !this.props.options.showLines }); + }; + + onToggleBars = () => { + this.props.onChange({ ...this.props.options, showBars: !this.props.options.showBars }); + }; + + onTogglePoints = () => { + this.props.onChange({ ...this.props.options, showPoints: !this.props.options.showPoints }); + }; + render() { - return

Text2 Options component

; + const { showBars, showPoints, showLines } = this.props.options; + + return ( +
+
+
Draw Modes
+ + + +
+
+ ); } } -export { Graph2 as PanelComponent, TextOptions as PanelOptions }; +export { Graph2 as PanelComponent, GraphOptions as PanelOptionsComponent }; diff --git a/public/app/plugins/panel/graph2/plugin.json b/public/app/plugins/panel/graph2/plugin.json index b519a57fae4..2e674ab3557 100644 --- a/public/app/plugins/panel/graph2/plugin.json +++ b/public/app/plugins/panel/graph2/plugin.json @@ -3,6 +3,8 @@ "name": "React Graph", "id": "graph2", + "state": "alpha", + "info": { "author": { "name": "Grafana Project", diff --git a/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg b/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg index a1e15d4d58d..746687d360f 100644 --- a/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg +++ b/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg @@ -1,33 +1,83 @@ - - + + + + - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/public/app/plugins/panel/table/column_options.ts b/public/app/plugins/panel/table/column_options.ts index ca6d271643b..4c810d9987d 100644 --- a/public/app/plugins/panel/table/column_options.ts +++ b/public/app/plugins/panel/table/column_options.ts @@ -41,6 +41,7 @@ export class ColumnOptionsCtrl { { text: 'YYYY-MM-DD HH:mm:ss.SSS', value: 'YYYY-MM-DD HH:mm:ss.SSS' }, { text: 'MM/DD/YY h:mm:ss a', value: 'MM/DD/YY h:mm:ss a' }, { text: 'MMMM D, YYYY LT', value: 'MMMM D, YYYY LT' }, + { text: 'YYYY-MM-DD', value: 'YYYY-MM-DD' }, ]; this.mappingTypes = [{ text: 'Value to text', value: 1 }, { text: 'Range to text', value: 2 }]; diff --git a/public/app/plugins/panel/table/renderer.ts b/public/app/plugins/panel/table/renderer.ts index c25e37357cb..524aa06343b 100644 --- a/public/app/plugins/panel/table/renderer.ts +++ b/public/app/plugins/panel/table/renderer.ts @@ -211,16 +211,17 @@ export class TableRenderer { value = this.formatColumnValue(columnIndex, value); const column = this.table.columns[columnIndex]; - let style = ''; + let cellStyle = ''; + let textStyle = ''; const cellClasses = []; let cellClass = ''; if (this.colorState.cell) { - style = ' style="background-color:' + this.colorState.cell + '"'; + cellStyle = ' style="background-color:' + this.colorState.cell + '"'; cellClasses.push('table-panel-color-cell'); this.colorState.cell = null; } else if (this.colorState.value) { - style = ' style="color:' + this.colorState.value + '"'; + textStyle = ' style="color:' + this.colorState.value + '"'; this.colorState.value = null; } // because of the fixed table headers css only solution @@ -232,7 +233,7 @@ export class TableRenderer { } if (value === undefined) { - style = ' style="display:none;"'; + cellStyle = ' style="display:none;"'; column.hidden = true; } else { column.hidden = false; @@ -258,7 +259,7 @@ export class TableRenderer { cellClasses.push('table-panel-cell-link'); columnHtml += ` - + ${value} `; @@ -283,7 +284,7 @@ export class TableRenderer { cellClass = ' class="' + cellClasses.join(' ') + '"'; } - columnHtml = '' + columnHtml + ''; + columnHtml = '' + columnHtml + ''; return columnHtml; } diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index 5dfa8622614..75a34ac01c0 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -17,7 +17,6 @@ export class GrafanaCtrl { /** @ngInject */ constructor( $scope, - alertSrv, utilSrv, $rootScope, $controller, @@ -41,11 +40,8 @@ export class GrafanaCtrl { $scope._ = _; profiler.init(config, $rootScope); - alertSrv.init(); utilSrv.init(); bridgeSrv.init(); - - $scope.dashAlerts = alertSrv; }; $rootScope.colors = colors; diff --git a/public/app/routes/ReactContainer.tsx b/public/app/routes/ReactContainer.tsx index ed4d2d21827..807608e6960 100644 --- a/public/app/routes/ReactContainer.tsx +++ b/public/app/routes/ReactContainer.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import coreModule from 'app/core/core_module'; -import { store } from 'app/store/configureStore'; +import { store } from 'app/store/store'; import { BackendSrv } from 'app/core/services/backend_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ContextSrv } from 'app/core/services/context_srv'; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index af2d528afe9..d3b3488f1fe 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -15,6 +15,7 @@ import NewDataSourcePage from '../features/datasources/NewDataSourcePage'; import UsersListPage from 'app/features/users/UsersListPage'; import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards'; import DataSourceSettings from '../features/datasources/settings/DataSourceSettings'; +import OrgDetailsPage from '../features/org/OrgDetailsPage'; /** @ngInject */ export function setupAngularRoutes($routeProvider, $locationProvider) { @@ -133,8 +134,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { }, }) .when('/org', { - templateUrl: 'public/app/features/org/partials/orgDetails.html', - controller: 'OrgDetailsCtrl', + template: '', + resolve: { + component: () => OrgDetailsPage, + }, }) .when('/org/new', { templateUrl: 'public/app/features/org/partials/newOrg.html', @@ -166,7 +169,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { }, }) .when('/org/teams/new', { - templateUrl: 'public/app/features/org/partials/create_team.html', + templateUrl: 'public/app/features/teams/partials/create_team.html', controller: 'CreateTeamCtrl', controllerAs: 'ctrl', }) @@ -178,12 +181,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { }, }) .when('/profile', { - templateUrl: 'public/app/features/org/partials/profile.html', + templateUrl: 'public/app/features/profile/partials/profile.html', controller: 'ProfileCtrl', controllerAs: 'ctrl', }) .when('/profile/password', { - templateUrl: 'public/app/features/org/partials/change_password.html', + templateUrl: 'public/app/features/profile/partials/change_password.html', controller: 'ChangePasswordCtrl', }) .when('/profile/select-org', { diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index ccd027a0b6d..943aff80a70 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -10,6 +10,8 @@ import dashboardReducers from 'app/features/dashboard/state/reducers'; import pluginReducers from 'app/features/plugins/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; +import organizationReducers from 'app/features/org/state/reducers'; +import { setStore } from './store'; const rootReducers = { ...sharedReducers, @@ -21,10 +23,9 @@ const rootReducers = { ...pluginReducers, ...dataSourcesReducers, ...usersReducers, + ...organizationReducers, }; -export let store; - export function addRootReducer(reducers) { Object.assign(rootReducers, ...reducers); } @@ -36,8 +37,8 @@ export function configureStore() { if (process.env.NODE_ENV !== 'production') { // DEV builds we had the logger middleware - store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger()))); + setStore(createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger())))); } else { - store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk))); + setStore(createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk)))); } } diff --git a/public/app/store/store.ts b/public/app/store/store.ts new file mode 100644 index 00000000000..c8134ae77f3 --- /dev/null +++ b/public/app/store/store.ts @@ -0,0 +1,5 @@ +export let store; + +export function setStore(newStore) { + store = newStore; +} diff --git a/public/app/types/appNotifications.ts b/public/app/types/appNotifications.ts new file mode 100644 index 00000000000..81e6cfd55e1 --- /dev/null +++ b/public/app/types/appNotifications.ts @@ -0,0 +1,25 @@ +export interface AppNotification { + id?: number; + severity: AppNotificationSeverity; + icon: string; + title: string; + text: string; + timeout: AppNotificationTimeout; +} + +export enum AppNotificationSeverity { + Success = 'success', + Warning = 'warning', + Error = 'error', + Info = 'info', +} + +export enum AppNotificationTimeout { + Warning = 5000, + Success = 3000, + Error = 7000, +} + +export interface AppNotificationsState { + appNotifications: AppNotification[]; +} diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index d98dcb3e839..ef6077ffc20 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -1,5 +1,5 @@ import { LayoutMode } from '../core/components/LayoutSelector/LayoutSelector'; -import { Plugin } from './plugins'; +import { Plugin, PluginExports, PluginMeta } from './plugins'; export interface DataSource { id: number; @@ -19,6 +19,16 @@ export interface DataSource { jsonData: { authType: string; defaultRegion: string }; readOnly: boolean; withCredentials: boolean; + meta?: PluginMeta; + pluginExports?: PluginExports; + init?: () => void; + testDatasource?: () => Promise; +} + +export interface DataSourceTest { + inProgress: boolean; + message: string; + status: string; } export interface DataSourcesState { @@ -31,4 +41,5 @@ export interface DataSourcesState { dataSource: DataSource; dataSourceMeta: Plugin; hasFetched: boolean; + testing: DataSourceTest; } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 918dd4e4483..5a9db7e9b53 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -1,3 +1,77 @@ +import { Value } from 'slate'; + +import { RawTimeRange } from './series'; + +export interface CompletionItem { + /** + * The label of this completion item. By default + * this is also the text that is inserted when selecting + * this completion. + */ + label: string; + /** + * The kind of this completion item. Based on the kind + * an icon is chosen by the editor. + */ + kind?: string; + /** + * A human-readable string with additional information + * about this item, like type or symbol information. + */ + detail?: string; + /** + * A human-readable string, can be Markdown, that represents a doc-comment. + */ + documentation?: string; + /** + * A string that should be used when comparing this item + * with other items. When `falsy` the `label` is used. + */ + sortText?: string; + /** + * A string that should be used when filtering a set of + * completion items. When `falsy` the `label` is used. + */ + filterText?: string; + /** + * A string or snippet that should be inserted in a document when selecting + * this completion. When `falsy` the `label` is used. + */ + insertText?: string; + /** + * Delete number of characters before the caret position, + * by default the letters from the beginning of the word. + */ + deleteBackwards?: number; + /** + * Number of steps to move after the insertion, can be negative. + */ + move?: number; +} + +export interface CompletionItemGroup { + /** + * Label that will be displayed for all entries of this group. + */ + label: string; + /** + * List of suggestions of this group. + */ + items: CompletionItem[]; + /** + * If true, match only by prefix (and not mid-word). + */ + prefixMatch?: boolean; + /** + * If true, do not filter items in this group based on the search. + */ + skipFilter?: boolean; + /** + * If true, do not sort items. + */ + skipSort?: boolean; +} + interface ExploreDatasource { value: string; label: string; @@ -8,9 +82,29 @@ export interface HistoryItem { query: string; } -export interface Range { - from: string; - to: string; +export abstract class LanguageProvider { + datasource: any; + request: (url) => Promise; + /** + * Returns startTask that resolves with a task list when main syntax is loaded. + * Task list consists of secondary promises that load more detailed language features. + */ + start: () => Promise; + startTask?: Promise; +} + +export interface TypeaheadInput { + text: string; + prefix: string; + wrapperClasses: string[]; + labelKey?: string; + value?: Value; +} + +export interface TypeaheadOutput { + context?: string; + refresher?: Promise<{}>; + suggestions: CompletionItemGroup[]; } export interface Query { @@ -18,11 +112,29 @@ export interface Query { key?: string; } +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 QueryTransaction { id: string; done: boolean; - error?: string; - hints?: any[]; + error?: string | JSX.Element; + hints?: QueryHint[]; latency: number; options: any; query: string; @@ -39,13 +151,14 @@ export interface TextMatch { } export interface ExploreState { + StartPage?: any; datasource: any; datasourceError: any; datasourceLoading: boolean | null; datasourceMissing: boolean; datasourceName?: string; exploreDatasources: ExploreDatasource[]; - graphRange: Range; + graphRange: RawTimeRange; history: HistoryItem[]; /** * Initial rows of queries to push down the tree. @@ -57,7 +170,7 @@ export interface ExploreState { * Hints gathered for the query row. */ queryTransactions: QueryTransaction[]; - range: Range; + range: RawTimeRange; showingGraph: boolean; showingLogs: boolean; showingTable: boolean; @@ -69,7 +182,7 @@ export interface ExploreState { export interface ExploreUrlState { datasource: string; queries: Query[]; - range: Range; + range: RawTimeRange; } export type ResultType = 'Graph' | 'Logs' | 'Table'; diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 27c1644e6ab..013c132a94f 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -6,8 +6,8 @@ import { FolderDTO, FolderState, FolderInfo } from './folders'; import { DashboardState } from './dashboard'; import { DashboardAcl, OrgRole, PermissionLevel } from './acl'; import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys'; -import { Invitee, OrgUser, User, UsersState } from './user'; -import { DataSource, DataSourcesState } from './datasources'; +import { Invitee, OrgUser, User, UsersState, UserState } from './user'; +import { DataSource, DataSourceTest, DataSourcesState } from './datasources'; import { TimeRange, LoadingState, @@ -20,8 +20,16 @@ import { DataQueryResponse, DataQueryOptions, } from './series'; -import { PanelProps } from './panel'; +import { PanelProps, PanelOptionsProps } from './panel'; import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins'; +import { Organization, OrganizationPreferences, OrganizationState } from './organization'; +import { + AppNotification, + AppNotificationSeverity, + AppNotificationsState, + AppNotificationTimeout, +} from './appNotifications'; +import { DashboardSearchHit } from './search'; export { Team, @@ -61,6 +69,7 @@ export { TimeRange, LoadingState, PanelProps, + PanelOptionsProps, TimeSeries, TimeSeriesVM, TimeSeriesVMs, @@ -70,6 +79,16 @@ export { DataQueryResponse, DataQueryOptions, PluginDashboard, + Organization, + OrganizationState, + OrganizationPreferences, + AppNotification, + AppNotificationsState, + AppNotificationSeverity, + AppNotificationTimeout, + DashboardSearchHit, + UserState, + DataSourceTest, }; export interface StoreState { @@ -82,4 +101,7 @@ export interface StoreState { dashboard: DashboardState; dataSources: DataSourcesState; users: UsersState; + organization: OrganizationState; + appNotifications: AppNotificationsState; + user: UserState; } diff --git a/public/app/types/organization.ts b/public/app/types/organization.ts new file mode 100644 index 00000000000..52cb130e082 --- /dev/null +++ b/public/app/types/organization.ts @@ -0,0 +1,15 @@ +export interface Organization { + name: string; + id: number; +} + +export interface OrganizationPreferences { + homeDashboardId: number; + theme: string; + timezone: string; +} + +export interface OrganizationState { + organization: Organization; + preferences: OrganizationPreferences; +} diff --git a/public/app/types/panel.ts b/public/app/types/panel.ts index 5ece77fc5aa..7febd0cad26 100644 --- a/public/app/types/panel.ts +++ b/public/app/types/panel.ts @@ -1,7 +1,14 @@ import { LoadingState, TimeSeries, TimeRange } from './series'; -export interface PanelProps { +export interface PanelProps { timeSeries: TimeSeries[]; timeRange: TimeRange; loading: LoadingState; + options: T; + renderCounter: number; +} + +export interface PanelOptionsProps { + options: T; + onChange: (options: T) => void; } diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts index de640906407..5dbde61d8d0 100644 --- a/public/app/types/plugins.ts +++ b/public/app/types/plugins.ts @@ -1,11 +1,18 @@ +import { ComponentClass } from 'react'; +import { PanelProps, PanelOptionsProps } from './panel'; + export interface PluginExports { - PanelCtrl?; - PanelComponent?: any; Datasource?: any; QueryCtrl?: any; ConfigCtrl?: any; AnnotationsQueryCtrl?: any; - PanelOptions?: any; + ExploreQueryField?: any; + ExploreStartPage?: any; + + // Panel plugin + PanelCtrl?; + PanelComponent?: ComponentClass; + PanelOptionsComponent: ComponentClass; } export interface PanelPlugin { @@ -25,6 +32,12 @@ export interface PluginMeta { name: string; info: PluginMetaInfo; includes: PluginInclude[]; + + // Datasource-specific + metrics?: boolean; + logs?: boolean; + explore?: boolean; + annotations?: boolean; } export interface PluginInclude { diff --git a/public/app/types/search.ts b/public/app/types/search.ts new file mode 100644 index 00000000000..e5e17288de1 --- /dev/null +++ b/public/app/types/search.ts @@ -0,0 +1,9 @@ +export interface DashboardSearchHit { + id: number; + tags: string[]; + title: string; + type: string; + uid: string; + uri: string; + url: string; +} diff --git a/public/app/types/series.ts b/public/app/types/series.ts index 5396880611b..997090bf541 100644 --- a/public/app/types/series.ts +++ b/public/app/types/series.ts @@ -88,4 +88,5 @@ export interface DataQueryOptions { export interface DataSourceApi { query(options: DataQueryOptions): Promise; + testDatasource(): Promise; } diff --git a/public/app/types/user.ts b/public/app/types/user.ts index c0b7b135ff8..37c80074dca 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -1,4 +1,6 @@ -export interface OrgUser { +import { DashboardSearchHit } from './search'; + +export interface OrgUser { avatarUrl: string; email: string; lastSeenAt: string; @@ -43,3 +45,7 @@ export interface UsersState { externalUserMngInfo: string; hasFetched: boolean; } + +export interface UserState { + starredDashboards: DashboardSearchHit[]; +} diff --git a/public/app/viz/Graph.tsx b/public/app/viz/Graph.tsx index fab65225715..566080fbc92 100644 --- a/public/app/viz/Graph.tsx +++ b/public/app/viz/Graph.tsx @@ -8,6 +8,111 @@ import 'vendor/flot/jquery.flot.time'; // Types import { TimeRange, TimeSeriesVMs } from 'app/types'; +interface GraphProps { + timeSeries: TimeSeriesVMs; + timeRange: TimeRange; + showLines?: boolean; + showPoints?: boolean; + showBars?: boolean; + size?: { width: number; height: number }; +} + +export class Graph extends PureComponent { + static defaultProps = { + showLines: true, + showPoints: false, + showBars: false, + }; + + element: any; + + componentDidUpdate(prevProps: GraphProps) { + if ( + prevProps.timeSeries !== this.props.timeSeries || + prevProps.timeRange !== this.props.timeRange || + prevProps.size !== this.props.size + ) { + this.draw(); + } + } + + componentDidMount() { + this.draw(); + } + + draw() { + const { size, timeSeries, timeRange, showLines, showBars, showPoints } = this.props; + + if (!size) { + return; + } + + const ticks = (size.width || 0) / 100; + const min = timeRange.from.valueOf(); + const max = timeRange.to.valueOf(); + + const flotOptions = { + legend: { + show: false, + }, + series: { + lines: { + show: showLines, + linewidth: 1, + zero: false, + }, + points: { + show: showPoints, + fill: 1, + fillColor: false, + radius: 2, + }, + bars: { + show: showBars, + fill: 1, + barWidth: 1, + zero: false, + lineWidth: 0, + }, + shadowSize: 0, + }, + xaxis: { + mode: 'time', + min: min, + max: max, + label: 'Datetime', + ticks: ticks, + timeformat: time_format(ticks, min, max), + }, + grid: { + minBorderMargin: 0, + markings: [], + backgroundColor: null, + borderWidth: 0, + // hoverable: true, + clickable: true, + color: '#a1a1a1', + margin: { left: 0, right: 0 }, + labelMarginX: 0, + }, + }; + + try { + $.plot(this.element, timeSeries, flotOptions); + } catch (err) { + console.log('Graph rendering error', err, flotOptions, timeSeries); + } + } + + render() { + return ( +
+
(this.element = e)} /> +
+ ); + } +} + // Copied from graph.ts function time_format(ticks, min, max) { if (min && max && ticks) { @@ -34,91 +139,4 @@ function time_format(ticks, min, max) { return '%H:%M'; } -const FLOT_OPTIONS = { - legend: { - show: false, - }, - series: { - lines: { - linewidth: 1, - zero: false, - }, - shadowSize: 0, - }, - grid: { - minBorderMargin: 0, - markings: [], - backgroundColor: null, - borderWidth: 0, - // hoverable: true, - clickable: true, - color: '#a1a1a1', - margin: { left: 0, right: 0 }, - labelMarginX: 0, - }, -}; - -interface GraphProps { - timeSeries: TimeSeriesVMs; - timeRange: TimeRange; - size?: { width: number; height: number }; -} - -export class Graph extends PureComponent { - element: any; - - componentDidUpdate(prevProps: GraphProps) { - if ( - prevProps.timeSeries !== this.props.timeSeries || - prevProps.timeRange !== this.props.timeRange || - prevProps.size !== this.props.size - ) { - this.draw(); - } - } - - componentDidMount() { - this.draw(); - } - - draw() { - const { size, timeSeries, timeRange } = this.props; - - if (!size) { - return; - } - - const ticks = (size.width || 0) / 100; - const min = timeRange.from.valueOf(); - const max = timeRange.to.valueOf(); - - const dynamicOptions = { - xaxis: { - mode: 'time', - min: min, - max: max, - label: 'Datetime', - ticks: ticks, - timeformat: time_format(ticks, min, max), - }, - }; - - const options = { - ...FLOT_OPTIONS, - ...dynamicOptions, - }; - - console.log('plot', timeSeries, options); - $.plot(this.element, timeSeries, options); - } - - render() { - return ( -
-
(this.element = e)} /> -
- ); - } -} - export default withSize()(Graph); diff --git a/public/img/grafana_enterprise_typelogo.svg b/public/img/grafana_enterprise_typelogo.svg new file mode 100644 index 00000000000..30ee925787e --- /dev/null +++ b/public/img/grafana_enterprise_typelogo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/grafana_typelogo.svg b/public/img/grafana_typelogo.svg new file mode 100644 index 00000000000..bfceac7a78d --- /dev/null +++ b/public/img/grafana_typelogo.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/public/sass/_old_responsive.scss b/public/sass/_old_responsive.scss index 991b0f30aa1..cfe74d03bb1 100644 --- a/public/sass/_old_responsive.scss +++ b/public/sass/_old_responsive.scss @@ -10,6 +10,7 @@ max-width: 120px; } +.navbar-buttons--tv, .navbar-buttons--actions { display: none; } @@ -35,6 +36,7 @@ } @include media-breakpoint-up(md) { + .navbar-buttons--tv, .navbar-buttons--actions { display: flex; } diff --git a/public/sass/components/_alerts.scss b/public/sass/components/_alerts.scss index 3420dcfdfaf..710c4d1ec0f 100644 --- a/public/sass/components/_alerts.scss +++ b/public/sass/components/_alerts.scss @@ -7,13 +7,13 @@ .alert { padding: 1.25rem 2rem 1.25rem 1.5rem; - margin-bottom: $line-height-base; + margin-bottom: $panel-margin / 2; text-shadow: 0 2px 0 rgba(255, 255, 255, 0.5); background: $alert-error-bg; position: relative; color: $white; text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); - border-radius: 2px; + border-radius: $border-radius; display: flex; flex-direction: row; } diff --git a/public/sass/components/_footer.scss b/public/sass/components/_footer.scss index 3c30f5a5798..9bc99c30d1d 100644 --- a/public/sass/components/_footer.scss +++ b/public/sass/components/_footer.scss @@ -40,15 +40,15 @@ .login-page { .footer { + bottom: $spacer; + position: absolute; padding: 1rem 0 1rem 0; } } -@include media-breakpoint-up(md) { +@include media-breakpoint-up(sm) { .login-page { .footer { - bottom: $spacer; - position: absolute; padding: 5rem 0 1rem 0; } } diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index 63d9169a4f8..9f7a9575b61 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -14,7 +14,7 @@ .graph-legend-series { display: block; - padding-left: 0px; + padding-left: 4px; } .graph-legend-table .graph-legend-series { @@ -28,6 +28,7 @@ position: relative; cursor: crosshair; flex-grow: 1; + min-height: 65%; } .datapoints-warning { @@ -46,15 +47,12 @@ .graph-legend { display: flex; flex: 0 1 auto; - max-height: 30%; + max-height: 35%; margin: 0; text-align: center; padding-top: 6px; position: relative; - // fix for Firefox (white stripe on the right of scrollbar) - width: calc(100% - 1px); - .popover-content { padding: 0; } @@ -62,15 +60,6 @@ .graph-legend-content { position: relative; - - // fix for Firefox (white stripe on the right of scrollbar) - width: calc(100% - 1px); -} - -.graph-legend-scroll { - position: relative; - overflow: auto !important; - padding: 1px; } .graph-legend-icon { @@ -82,8 +71,8 @@ .graph-legend-icon, .graph-legend-alias, .graph-legend-value { + display: inline; cursor: pointer; - float: left; white-space: nowrap; font-size: 85%; text-align: left; @@ -120,6 +109,11 @@ } } +// Don't move series to the right if legend is on the right as well +.graph-panel--legend-right .graph-legend-series--right-y { + float: left; +} + .graph-legend-value { padding-left: 6px; } @@ -128,7 +122,8 @@ .body--phantomjs { .graph-panel--legend-right { .graph-legend { - display: inline-block; + display: block; + max-width: min-content; } .graph-panel__chart { @@ -138,24 +133,14 @@ .graph-legend-table { display: table; width: auto; - - .graph-legend-scroll { - display: table; - } } } } .graph-legend-table { - tbody { - display: block; - position: relative; - overflow-y: auto; - overflow-x: hidden; - padding-bottom: 1px; - padding-right: 5px; - padding-left: 5px; - } + padding-bottom: 1px; + padding-right: 5px; + padding-left: 5px; .graph-legend-series { display: table-row; diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index d9ab29cc91c..795766a22de 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -21,6 +21,9 @@ div.flot-text { height: 100%; &--solo { + position: fixed; + bottom: 0; + right: 0; margin: 0; .panel-container { border: none; diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index a3f60f2006b..b70b058879c 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -52,7 +52,7 @@ } .result-options { - margin-top: 2 * $panel-margin; + margin: 2 * $panel-margin 0; } .time-series-disclaimer { @@ -87,7 +87,7 @@ flex-wrap: wrap; } - .explore-graph__loader { + .explore-panel__loader { height: 2px; position: relative; overflow: hidden; @@ -95,7 +95,7 @@ margin: $panel-margin / 2; } - .explore-graph__loader:after { + .explore-panel__loader:after { content: ' '; display: block; width: 25%; @@ -219,7 +219,13 @@ } .logs-row-match-highlight { - background-color: lighten($blue, 20%); + // Undoing mark styling + background: inherit; + padding: inherit; + + color: $typeahead-selected-color; + border-bottom: 1px solid $typeahead-selected-color; + background-color: lighten($typeahead-selected-color, 60%); } .logs-row-level { @@ -258,6 +264,11 @@ .prom-query-field-info { margin: 0.25em 0.5em 0.5em; + display: flex; + + details { + margin-left: 1em; + } } } @@ -317,3 +328,29 @@ .ReactTable .rt-tr .rt-td:last-child { text-align: right; } + +// React-component cascade fix: show "loading" even though item can expand + +.rc-cascader-menu-item-loading:after { + position: absolute; + right: 12px; + content: 'loading'; + color: #767980; + font-style: italic; +} + +// TODO Experimental + +.cheat-sheet-item { + margin: 2*$panel-margin 0; + width: 50%; +} + +.cheat-sheet-item__title { + font-size: $font-size-h3; +} + +.cheat-sheet-item__expression { + margin: $panel-margin/2 0; + cursor: pointer; +} diff --git a/public/sass/pages/_login.scss b/public/sass/pages/_login.scss index 8e5c8f33e37..4baff47b2a8 100644 --- a/public/sass/pages/_login.scss +++ b/public/sass/pages/_login.scss @@ -1,7 +1,7 @@ $login-border: #8daac5; .login { - min-height: 85vh; + min-height: 100vh; background-position: center; background-repeat: no-repeat; min-width: 100%; @@ -97,6 +97,7 @@ select:-webkit-autofill:focus { .login-content { max-width: 700px; + width: 100%; display: flex; align-items: stretch; flex-direction: column; @@ -120,18 +121,29 @@ select:-webkit-autofill:focus { width: 70px; margin-bottom: 15px; } +} - .icon-gf-grafana_wordmark { - color: darken($white, 11%); - position: relative; - font-size: 2rem; - text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3); +.app-grafana { + .logo-wordmark { + background: url('../img/grafana_typelogo.svg') top center no-repeat; + width: 100%; + height: 70px; + } +} + +.app-enterprise { + .logo-wordmark { + background: url('../img/grafana_enterprise_typelogo.svg') top center no-repeat; + width: 100%; + height: 70px; } } .login-outer-box { display: flex; overflow-y: hidden; + align-items: center; + justify-content: center; } .login-inner-box { @@ -143,6 +155,7 @@ select:-webkit-autofill:focus { justify-content: center; flex-grow: 1; max-width: 415px; + width: 100%; transform: tranlate(0px, 0px); transition: 0.25s ease; @@ -324,23 +337,19 @@ select:-webkit-autofill:focus { } @include media-breakpoint-up(sm) { - .login-content { - flex-direction: row; - } - .login-branding { - width: 35%; - padding: 4rem 2rem; - border-right: 1px solid $login-border; + padding: 1rem; .logo-icon { width: 80px; } } +} - .login-inner-box { - width: 65%; - padding: 1rem 2rem; +@include media-breakpoint-up(md) { + .login-content { + flex-direction: row; + flex: 1 0 100%; } .login-divider { @@ -348,29 +357,16 @@ select:-webkit-autofill:focus { width: 110px; } } -} - -@include media-breakpoint-up(md) { - .login { - min-height: 100vh; - } - - .login-content { - flex: 1 0 100%; - } .login-branding { width: 45%; - padding: 2rem 4rem; + padding: 2rem; flex-grow: 1; + border-right: 1px solid $login-border; .logo-icon { width: 130px; } - - .icon-gf-grafana_wordmark { - font-size: 3.2rem; - } } .login-inner-box { @@ -386,9 +382,7 @@ select:-webkit-autofill:focus { padding-top: 0; padding-left: 10px; } -} -@include media-breakpoint-up(lg) { .login-form-input { min-width: 300px; } diff --git a/public/vendor/flot/jquery.flot.js b/public/vendor/flot/jquery.flot.js index 4a85b08c8d7..daa3ac6bb8e 100644 --- a/public/vendor/flot/jquery.flot.js +++ b/public/vendor/flot/jquery.flot.js @@ -2271,9 +2271,51 @@ Licensed under the MIT license. }); } + function drawOrphanedPoints(series) { + /* Filters series data for points with no neighbors before or after + * and plots single 0.5 radius points for them so that they are displayed. + */ + var abandonedPoints = []; + var beforeX = null; + var afterX = null; + var datapoints = series.datapoints; + // find any points with no neighbors before or after + var emptyPoints = []; + for (var j = 0; j < datapoints.pointsize - 2; j++) { + emptyPoints.push(0); + } + for (var i = 0; i < datapoints.points.length; i += datapoints.pointsize) { + var x = datapoints.points[i], y = datapoints.points[i + 1]; + if (i === datapoints.points.length - datapoints.pointsize) { + afterX = null; + } else { + afterX = datapoints.points[i + datapoints.pointsize]; + } + if (x !== null && y !== null && beforeX === null && afterX === null) { + abandonedPoints.push(x); + abandonedPoints.push(y); + abandonedPoints.push.apply(abandonedPoints, emptyPoints); + } + beforeX = x; + + } + var olddatapoints = datapoints.points + datapoints.points = abandonedPoints; + + series.points.radius = series.lines.lineWidth/2; + // plot the orphan points with a radius of lineWidth/2 + drawSeriesPoints(series); + // reset old info + datapoints.points = olddatapoints; + } + function drawSeries(series) { if (series.lines.show) drawSeriesLines(series); + if (!series.points.show && !series.bars.show) { + // not necessary if user wants points displayed for everything + drawOrphanedPoints(series); + } if (series.bars.show) drawSeriesBars(series); if (series.points.show) diff --git a/public/views/error.html b/public/views/error-template.html similarity index 98% rename from public/views/error.html rename to public/views/error-template.html index 5c51e28eff4..af430fc6c3d 100644 --- a/public/views/error.html +++ b/public/views/error-template.html @@ -10,7 +10,7 @@ - + diff --git a/public/views/index.template.html b/public/views/index-template.html similarity index 91% rename from public/views/index.template.html rename to public/views/index-template.html index c39d5e08321..096684d3e37 100644 --- a/public/views/index.template.html +++ b/public/views/index-template.html @@ -14,22 +14,18 @@ + + + - +