diff --git a/.drone.yml b/.drone.yml index 615de43cda6..9ddf828fa13 100644 --- a/.drone.yml +++ b/.drone.yml @@ -17,7 +17,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -142,6 +142,9 @@ steps: image: grafana/build-container:1.2.28 commands: - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 depends_on: - package @@ -231,32 +234,6 @@ trigger: event: - pull_request ---- -kind: pipeline -type: docker -name: windows-pr - -platform: - os: windows - arch: amd64 - version: 1809 - -steps: -- name: identify-runner - image: mcr.microsoft.com/windows:1809 - commands: - - echo $env:DRONE_RUNNER_NAME - -- name: initialize - image: grafana/ci-wix:0.1.1 - commands: - - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe - -trigger: - event: - - pull_request - --- kind: pipeline type: docker @@ -276,7 +253,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -438,6 +415,9 @@ steps: image: grafana/build-container:1.2.28 commands: - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 depends_on: - package @@ -608,7 +588,7 @@ steps: image: grafana/ci-wix:0.1.1 commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/windows/grabpl.exe -OutFile grabpl.exe - name: build-windows-installer image: grafana/ci-wix:0.1.1 @@ -657,7 +637,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl environment: DOCKERIZE_VERSION: 0.6.1 @@ -732,7 +712,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-version ${DRONE_TAG} - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -875,6 +855,9 @@ steps: image: grafana/build-container:1.2.28 commands: - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 depends_on: - package @@ -1027,7 +1010,7 @@ steps: image: grafana/ci-wix:0.1.1 commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/windows/grabpl.exe -OutFile grabpl.exe - name: build-windows-installer image: grafana/ci-wix:0.1.1 @@ -1077,7 +1060,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -1361,7 +1344,7 @@ steps: image: grafana/ci-wix:0.1.1 commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise - git checkout ${DRONE_TAG} @@ -1426,7 +1409,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-version ${DRONE_TAG} environment: @@ -1503,7 +1486,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-version v7.3.0-test - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -1646,6 +1629,9 @@ steps: image: grafana/build-container:1.2.28 commands: - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 depends_on: - package @@ -1788,7 +1774,7 @@ steps: image: grafana/ci-wix:0.1.1 commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/windows/grabpl.exe -OutFile grabpl.exe - name: build-windows-installer image: grafana/ci-wix:0.1.1 @@ -1838,7 +1824,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2116,7 +2102,7 @@ steps: image: grafana/ci-wix:0.1.1 commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise - git checkout master @@ -2181,7 +2167,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-version v7.3.0-test environment: @@ -2267,7 +2253,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz - tar -C bin -xzvf dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -2406,6 +2392,9 @@ steps: image: grafana/build-container:1.2.28 commands: - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 depends_on: - package @@ -2506,7 +2495,7 @@ steps: image: grafana/ci-wix:0.1.1 commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/windows/grabpl.exe -OutFile grabpl.exe - name: build-windows-installer image: grafana/ci-wix:0.1.1 @@ -2552,7 +2541,7 @@ steps: image: grafana/build-container:1.2.28 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl - chmod +x bin/grabpl - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2709,6 +2698,9 @@ steps: image: grafana/build-container:1.2.28 commands: - yarn storybook:build + - ./bin/grabpl verify-storybook + environment: + NODE_OPTIONS: --max_old_space_size=4096 depends_on: - package @@ -2812,7 +2804,7 @@ steps: image: grafana/ci-wix:0.1.1 commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise - git checkout $$env:DRONE_BRANCH diff --git a/.gitattributes b/.gitattributes index 176a458f94e..6313b56c578 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -* text=auto +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index 3ed4beb90f3..00d7d039c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -119,5 +119,5 @@ compilation-stats.json # report dumping the whole system env /report.*.json -# auto generated front end docs -/docs/sources/packages_api/* \ No newline at end of file +# auto generated frontend docs +/docs/sources/packages_api diff --git a/CHANGELOG.md b/CHANGELOG.md index f558ff0aed9..cfbf7ec5276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +# 7.4.0 (unreleased) + +### Breaking changes + +We have upgraded AngularJS from version 1.6.6 to 1.8.2. Due to this upgrade some old angular plugins might stop working and will require an small update. This is due to the deprecation and removal of pre-assigned bindings. So if your custom angular controllers expect component bindings in the controller constructor you need to move this code to an `$onInit` function. For more details on how to migrate AngularJS code open the [migration guide](https://docs.angularjs.org/guide/migration) and search for **pre-assigning bindings**. + +In order not to break all angular panel plugins and data sources we have some custom [angular inject behavior](https://github.com/grafana/grafana/blob/master/public/app/core/injectorMonkeyPatch.ts) that makes sure that bindings for these controllers are still set before constructor is called so many old angular panels and data source plugins will still work. + + +# 7.3.1 (2020-10-30) + + ### Bug Fixes + * **Cloudwatch**: Fix duplicate metric data. [#28642](https://github.com/grafana/grafana/pull/28642), [@zoltanbedi](https://github.com/zoltanbedi) + * **Loki**: Fix error when some queries return zero results. [#28645](https://github.com/grafana/grafana/pull/28645), [@ivanahuckova](https://github.com/ivanahuckova) + * **PanelMenu**: Fix panel submenu not being accessible for panels close to the right edge of the screen. [#28666](https://github.com/grafana/grafana/pull/28666), [@torkelo](https://github.com/torkelo) + * **Plugins**: Fix descendent frontend plugin signature validation. [#28638](https://github.com/grafana/grafana/pull/28638), [@wbrowne](https://github.com/wbrowne) + * **StatPanel**: Fix value being under graph and reduced likelihood for white and dark value text mixing. [#28641](https://github.com/grafana/grafana/pull/28641), [@torkelo](https://github.com/torkelo) + * **TextPanel**: Fix problems where text panel would show old content. [#28643](https://github.com/grafana/grafana/pull/28643), [@torkelo](https://github.com/torkelo) + +# 7.3.0 (2020-10-28) + + ### Features / Enhancements + * **AzureMonitor**: Support decimal (as float64) type in analytics/logs. [#28480](https://github.com/grafana/grafana/pull/28480), [@kylebrandt](https://github.com/kylebrandt) + * **Plugins signing**: UI information. [#28469](https://github.com/grafana/grafana/pull/28469), [@dprokop](https://github.com/dprokop) + * **Short URL**: Update last seen at when visiting a short URL. [#28565](https://github.com/grafana/grafana/pull/28565), [@marefr](https://github.com/marefr) + + ### Bug Fixes + * **Alerting**: Log warnings for obsolete notifiers when extracting alerts and remove frequent error log messages. [#28162](https://github.com/grafana/grafana/pull/28162), [@papagian](https://github.com/papagian) + * **Auth**: Fix SigV4 request verification step for Amazon Elasticsearch Service. [#28481](https://github.com/grafana/grafana/pull/28481), [@wbrowne](https://github.com/wbrowne) + * **Auth**: Should redirect to login when anonymous enabled and URL with different org than anonymous specified. [#28158](https://github.com/grafana/grafana/pull/28158), [@marefr](https://github.com/marefr) + * **Elasticsearch**: Fix handling of errors when testing data source. [#28498](https://github.com/grafana/grafana/pull/28498), [@marefr](https://github.com/marefr) + * **Graphite**: Fix default version to be 1.1. [#28471](https://github.com/grafana/grafana/pull/28471), [@ivanahuckova](https://github.com/ivanahuckova) + * **StatPanel**: Fixes BizChart error max: yyy should not be less than min zzz. [#28587](https://github.com/grafana/grafana/pull/28587), [@hugohaggmark](https://github.com/hugohaggmark) + + # 7.3.0-beta2 (2020-10-22) ### Features / Enhancements diff --git a/README.md b/README.md index b185aca3b1b..9077dca6af2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The open-source platform for monitoring and observability. Grafana allows you to query, visualize, alert on and understand your metrics no matter where they are stored. Create, explore, and share dashboards with your team and foster a data driven culture: -- **Visualize:** Fast and flexible client side graphs with a multitude of options. Panel plugins for many different way to visualize metrics and logs. +- **Visualize:** Fast and flexible client side graphs with a multitude of options. Panel plugins offer many different ways to visualize metrics and logs. - **Dynamic Dashboards:** Create dynamic & reusable dashboards with template variables that appear as dropdowns at the top of the dashboard. - **Explore Metrics:** Explore your data through ad-hoc queries and dynamic drilldown. Split view and compare different time ranges, queries and data sources side by side. - **Explore Logs:** Experience the magic of switching from metrics to logs with preserved label filters. Quickly search through all your logs or streaming them live. @@ -36,8 +36,8 @@ If you're interested in contributing to the Grafana project: ## Get involved -- Follow [@grafana on Twitter](https://twitter.com/grafana/) -- Read and subscribe to the [Grafana blog](https://grafana.com/blog/) +- Follow [@grafana on Twitter](https://twitter.com/grafana/). +- Read and subscribe to the [Grafana blog](https://grafana.com/blog/). - If you have a specific question, check out our [discussion forums](https://community.grafana.com/). - For general discussions, join us on the [official Slack](http://slack.raintank.io/) team. diff --git a/build.go b/build.go index 2e0beeea31e..906678fd485 100644 --- a/build.go +++ b/build.go @@ -37,6 +37,7 @@ var ( libc string pkgArch string version string = "v1" + buildTags []string // deb & rpm does not support semver so have to handle their version a little differently linuxPackageVersion string = "v1" linuxPackageIteration string = "" @@ -59,11 +60,13 @@ func main() { log.SetFlags(0) var buildIdRaw string + var buildTagsRaw string flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") flag.StringVar(&gocc, "cc", "", "CC") flag.StringVar(&libc, "libc", "", "LIBC") + flag.StringVar(&buildTagsRaw, "build-tags", "", "Sets custom build tags") flag.BoolVar(&cgo, "cgo-enabled", cgo, "Enable cgo") flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") flag.BoolVar(&race, "race", race, "Use race detector") @@ -89,6 +92,10 @@ func main() { return } + if len(buildTagsRaw) > 0 { + buildTags = strings.Split(buildTagsRaw, ",") + } + log.Printf("Version: %s, Linux Version: %s, Package Iteration: %s\n", version, linuxPackageVersion, linuxPackageIteration) if flag.NArg() == 0 { @@ -105,16 +112,16 @@ func main() { case "build-srv", "build-server": clean() - doBuild("grafana-server", "./pkg/cmd/grafana-server", []string{}) + doBuild("grafana-server", "./pkg/cmd/grafana-server", buildTags) case "build-cli": clean() - doBuild("grafana-cli", "./pkg/cmd/grafana-cli", []string{}) + doBuild("grafana-cli", "./pkg/cmd/grafana-cli", buildTags) case "build": //clean() for _, binary := range binaries { - doBuild(binary, "./pkg/cmd/"+binary, []string{}) + doBuild(binary, "./pkg/cmd/"+binary, buildTags) } case "build-frontend": @@ -451,7 +458,12 @@ func gruntBuildArg(task string) []string { } func setup() { - runPrint("go", "install", "-v", "./pkg/cmd/grafana-server") + args := []string{"install", "-v"} + if goos == windows { + args = append(args, "-buildmode=exe") + } + args = append(args, "./pkg/cmd/grafana-server") + runPrint("go", args...) } func printGeneratedVersion() { @@ -460,7 +472,12 @@ func printGeneratedVersion() { func test(pkg string) { setBuildEnv() - runPrint("go", "test", "-short", "-timeout", "60s", pkg) + args := []string{"test", "-short", "-timeout", "60s"} + if goos == windows { + args = append(args, "-buildmode=exe") + } + args = append(args, pkg) + runPrint("go", args...) } func doBuild(binaryName, pkg string, tags []string) { @@ -482,6 +499,10 @@ func doBuild(binaryName, pkg string, tags []string) { rmr(binary, binary+".md5") } args := []string{"build", "-ldflags", ldflags()} + if goos == windows { + // Work around a linking error on Windows: "export ordinal too large" + args = append(args, "-buildmode=exe") + } if len(tags) > 0 { args = append(args, "-tags", strings.Join(tags, ",")) } diff --git a/conf/defaults.ini b/conf/defaults.ini index 4c4c37d0414..f685e0cf2ce 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -671,7 +671,7 @@ disable_total_stats = false basic_auth_username = basic_auth_password = -# Metrics environment info adds dimensions to the `grafana_environment_info` metric, which +# Metrics environment info adds dimensions to the `grafana_environment_info` metric, which # can expose more information about the Grafana instance. [metrics.environment_info] #exampleLabel1 = exampleValue1 @@ -769,6 +769,7 @@ enable_alpha = false app_tls_skip_verify_insecure = false # Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. allow_loading_unsigned_plugins = +marketplace_url = https://grafana.com/grafana/plugins/ #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] diff --git a/conf/sample.ini b/conf/sample.ini index 46f45b68864..d34edede275 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -665,7 +665,7 @@ ; basic_auth_username = ; basic_auth_password = -# Metrics environment info adds dimensions to the `grafana_environment_info` metric, which +# Metrics environment info adds dimensions to the `grafana_environment_info` metric, which # can expose more information about the Grafana instance. [metrics.environment_info] #exampleLabel1 = exampleValue1 @@ -757,6 +757,7 @@ ;app_tls_skip_verify_insecure = false # Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. ;allow_loading_unsigned_plugins = +;marketplace_url = https://grafana.com/grafana/plugins/ #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 88f6b547178..26ed45cacdf 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -76,7 +76,10 @@ When you log in for the first time, Grafana asks you to change your password. #### Building on Windows -The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on Windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). +The Grafana backend includes SQLite which requires GCC to compile. So in order to compile Grafana on Windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). Eventually, if you use [Scoop](https://scoop.sh), you can install GCC through that. + +You can simply build the back-end as follows: `go run build.go build`. The Grafana binaries will be in bin\\windows-amd64. +Alternately, if you wish to use the `make` command, install [Make for Windows](http://gnuwin32.sourceforge.net/packages/make.htm) and use it in a Unix shell (f.ex. Git Bash). ## Test Grafana @@ -98,6 +101,13 @@ If you're developing for the backend, run the tests with the standard Go tool: go test -v ./pkg/... ``` +#### On Windows +Running the backend tests on Windows currently needs some tweaking, so use the build.go script: + +``` +go run build.go test +``` + ### Run end-to-end tests The end to end tests in Grafana use [Cypress](https://www.cypress.io/) to run automated scripts in a headless Chromium browser. Read more about our [e2e framework](/contribute/style-guides/e2e.md). diff --git a/contribute/style-guides/documentation-style-guide.md b/contribute/style-guides/documentation-style-guide.md index 109c28d1be2..a7333273469 100644 --- a/contribute/style-guides/documentation-style-guide.md +++ b/contribute/style-guides/documentation-style-guide.md @@ -51,12 +51,12 @@ The following sections provide general guidelines on topics specific to Grafana ### General - Use active voice. Avoid passive voice. - - Passive: The heatmap visualization is displayed. - Active: Grafana displays the heatmap visualization. + - Passive: The heatmap visualization is displayed. - Write in the imperative second person. Examples: You can write a query. Click the panel. Close the window. - Write in present tense. - - Not: The panel will open. - Use: The panel opens. Grafana opens the panel. + - Not: The panel will open. - Do not use an ampersand (&) as an abbreviation for "and." - **Exceptions:** If an ampersand is used in the Grafana UI, then match the UI. - Avoid using internal slang and jargon in technical documentation. @@ -78,9 +78,9 @@ The following sections provide general guidelines on topics specific to Grafana - Task topic headings start with a verb. - Write a query. Create a dashboard. - Concept and reference topic headings should be nouns or gerunds. Examples: Contributing to docs, Visualizations, Style guide -- Avoid following one heading with another heading. +- Avoid stacked headings, which is following one heading with another heading. - Avoid skipping heading levels. For example, an h1 should be followed by an h2 rather than an h3. -- Avoid having just one lower-level heading. For example, h1, h2, h2, h3, h3, h2 is a good order. Do not go h1, h2, h3, h2, h3, h2. +- Avoid having just one lower-level heading. For example, h1, h2, h2, h3, h3, h2, h2 is a good order. Do not go h1, h2, h3, h2, h3, h2. - Don't include parenthetical words like (Important!) in headings. ### Images @@ -152,9 +152,8 @@ Warnings tell the user not to do something. For example: - Do not assume everyone is using Linux. Make sure instructions include enough information for Windows and Mac users to successfully complete procedures. - Do not add `$` before commands. Make it easy for users to copy and paste commands. - - - **Wrong:** `$ sudo yum install grafana` - **Right:** `sudo yum install grafana` + - **Wrong:** `$ sudo yum install grafana` - Include `sudo` before commands that require `sudo` to work. @@ -185,7 +184,7 @@ Two words if used as a verb, one word if used as a noun. #### data source -Two words, not one +Two words, not one. **Exceptions:** - "datasource" used as an identifier @@ -213,6 +212,10 @@ Do not use. This is developer jargon that refers to a UI panel. Refer to the pan One word, not two. +#### mixin + +One word, not two. Also, not hyphenated. + #### open source, open-source Do not hyphenate when used as an adjective unless the lack of hyphen would cause confusion. For example: _Open source software design is the most open open-source system I can imagine._ @@ -223,14 +226,14 @@ Do not hyphenate when it is used as a noun. For example: _Open source is the bes Two words if used as a verb, one word if used as a noun. +**Examples** + +- Set up the workspace. +- Initial setup might take five minutes. + ### node_exporter, windows_exporter When referencing the Prometheus data source exporters, always use "node_exporter" and "windows_exporter" when referring to those tools. **Correct:** node_exporter, windows_exporter -**Incorrect:** Node Exporter, node exporter, Windows Exporter, Windows exporter, windows exporter - -**Examples** - -- Set up the workspace. -- Initial setup might take five minutes. +**Incorrect:** Node Exporter, node exporter, Windows Exporter, Windows exporter, windows exporter. diff --git a/devenv/dev-dashboards/feature-templating/global-variables-and-interpolation.json b/devenv/dev-dashboards/feature-templating/global-variables-and-interpolation.json index 86aae9c58bf..4f1ab116d58 100644 --- a/devenv/dev-dashboards/feature-templating/global-variables-and-interpolation.json +++ b/devenv/dev-dashboards/feature-templating/global-variables-and-interpolation.json @@ -34,7 +34,7 @@ }, "id": 11, "options": { - "content": "## Global variables\n\n* `__dashboard` = `${__dashboard}`\n* `__dashboard.name` = `${__dashboard.name}`\n* `__dashboard.uid` = `${__dashboard.uid}`\n* `__org.name` = `${__org.name}`\n* `__org.id` = `${__org.id}`\n* `__user.id` = `${__user.id}`\n* `__user.login` = `${__user.login}`\n \n## Formats\n\n* `Server:raw` = `${Server:raw}`\n* `Server:regex` = `${Server:regex}`\n* `Server:lucene` = `${Server:lucene}`\n* `Server:glob` = `${Server:glob}`\n* `Server:pipe` = `${Server:pipe}`\n* `Server:distributed` = `${Server:distributed}`\n* `Server:csv` = `${Server:csv}`\n* `Server:html` = `${Server:html}`\n* `Server:json` = `${Server:json}`\n* `Server:percentencode` = `${Server:percentencode}`\n* `Server:singlequote` = `${Server:singlequote}`\n* `Server:doublequote` = `${Server:doublequote}`\n* `Server:sqlstring` = `${Server:sqlstring}`\n* `Server:date` = `${Server:date}`\n* `Server:text` = `${Server:text}`\n\n", + "content": "## Global variables\n\n* `__dashboard` = `${__dashboard}`\n* `__dashboard.name` = `${__dashboard.name}`\n* `__dashboard.uid` = `${__dashboard.uid}`\n* `__org.name` = `${__org.name}`\n* `__org.id` = `${__org.id}`\n* `__user.id` = `${__user.id}`\n* `__user.login` = `${__user.login}`\n* `__user.email` = `${__user.email}`\n \n## Formats\n\n* `Server:raw` = `${Server:raw}`\n* `Server:regex` = `${Server:regex}`\n* `Server:lucene` = `${Server:lucene}`\n* `Server:glob` = `${Server:glob}`\n* `Server:pipe` = `${Server:pipe}`\n* `Server:distributed` = `${Server:distributed}`\n* `Server:csv` = `${Server:csv}`\n* `Server:html` = `${Server:html}`\n* `Server:json` = `${Server:json}`\n* `Server:percentencode` = `${Server:percentencode}`\n* `Server:singlequote` = `${Server:singlequote}`\n* `Server:doublequote` = `${Server:doublequote}`\n* `Server:sqlstring` = `${Server:sqlstring}`\n* `Server:date` = `${Server:date}`\n* `Server:text` = `${Server:text}`\n\n", "mode": "markdown" }, "pluginVersion": "7.1.0", diff --git a/devenv/docker/blocks/mssql/build/Dockerfile b/devenv/docker/blocks/mssql/build/Dockerfile index 620f0ca4013..ade5714a709 100644 --- a/devenv/docker/blocks/mssql/build/Dockerfile +++ b/devenv/docker/blocks/mssql/build/Dockerfile @@ -1,5 +1,12 @@ -FROM mcr.microsoft.com/mssql/server:2017-CU4-ubuntu +FROM mcr.microsoft.com/mssql/server:2019-CU8-ubuntu-18.04 WORKDIR /usr/setup -COPY . /usr/setup -RUN chmod +x /usr/setup/setup.sh -CMD /bin/bash ./entrypoint.sh \ No newline at end of file +COPY setup.sh setup.sql.template entrypoint.sh ./ + +USER root + +RUN chmod +x setup.sh +RUN chown -R mssql ./ + +USER mssql + +CMD /bin/bash ./entrypoint.sh diff --git a/devenv/docker/blocks/mssql/build/setup.sh b/devenv/docker/blocks/mssql/build/setup.sh index e1d509b042e..5d22062bd2f 100755 --- a/devenv/docker/blocks/mssql/build/setup.sh +++ b/devenv/docker/blocks/mssql/build/setup.sh @@ -1,4 +1,5 @@ #/bin/bash +set -eo pipefail #wait for the SQL Server to come up sleep 15s @@ -9,4 +10,4 @@ cat /usr/setup/setup.sql.template | awk '{ gsub(/%%PWD%%/,"'$MSSQL_PASSWORD'") }1' > /usr/setup/setup.sql -/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $MSSQL_SA_PASSWORD -d master -i /usr/setup/setup.sql \ No newline at end of file +/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $MSSQL_SA_PASSWORD -d master -i /usr/setup/setup.sql diff --git a/devenv/docker/blocks/nginx_proxy/Dockerfile b/devenv/docker/blocks/nginx_proxy/Dockerfile index 04de507499d..d781d286364 100644 --- a/devenv/docker/blocks/nginx_proxy/Dockerfile +++ b/devenv/docker/blocks/nginx_proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:alpine +FROM nginx:1.19.3-alpine COPY nginx.conf /etc/nginx/nginx.conf COPY htpasswd /etc/nginx/htpasswd diff --git a/devenv/docker/blocks/nginx_proxy_mac/Dockerfile b/devenv/docker/blocks/nginx_proxy_mac/Dockerfile index 04de507499d..d781d286364 100644 --- a/devenv/docker/blocks/nginx_proxy_mac/Dockerfile +++ b/devenv/docker/blocks/nginx_proxy_mac/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:alpine +FROM nginx:1.19.3-alpine COPY nginx.conf /etc/nginx/nginx.conf COPY htpasswd /etc/nginx/htpasswd diff --git a/devenv/docker/blocks/prometheus_basic_auth_proxy/Dockerfile b/devenv/docker/blocks/prometheus_basic_auth_proxy/Dockerfile index 04de507499d..d781d286364 100644 --- a/devenv/docker/blocks/prometheus_basic_auth_proxy/Dockerfile +++ b/devenv/docker/blocks/prometheus_basic_auth_proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:alpine +FROM nginx:1.19.3-alpine COPY nginx.conf /etc/nginx/nginx.conf COPY htpasswd /etc/nginx/htpasswd diff --git a/devenv/docker/blocks/prometheus_basic_auth_proxy/docker-compose.yaml b/devenv/docker/blocks/prometheus_basic_auth_proxy/docker-compose.yaml index d7b1d8f866f..bbea5197627 100644 --- a/devenv/docker/blocks/prometheus_basic_auth_proxy/docker-compose.yaml +++ b/devenv/docker/blocks/prometheus_basic_auth_proxy/docker-compose.yaml @@ -2,5 +2,6 @@ # http://prometheus:9090 (Prometheus inside the docker compose) nginxproxy: - build: docker/blocks/nginx_proxy - network_mode: host + build: docker/blocks/prometheus_basic_auth_proxy + ports: + - "10090:10090" diff --git a/devenv/docker/blocks/prometheus_basic_auth_proxy/nginx.conf b/devenv/docker/blocks/prometheus_basic_auth_proxy/nginx.conf index fd968a9c969..57fa759e017 100644 --- a/devenv/docker/blocks/prometheus_basic_auth_proxy/nginx.conf +++ b/devenv/docker/blocks/prometheus_basic_auth_proxy/nginx.conf @@ -2,6 +2,7 @@ events { } http { server { + error_log stderr info; listen 10090; diff --git a/docs/Makefile b/docs/Makefile index 7fef8c68f2f..ae035da788f 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,23 +1,14 @@ -.PHONY: docs docs-test docs-no-pull docs-generate-frontend docs-no-generate-frontend +.PHONY: docs docs-no-pull docs-test -IMAGE = grafana/docs-base:latest +IMAGE = grafana/grafana-docs-dev:latest -docs-generate-frontend: - yarn install --pure-lockfile - yarn run packages:build - yarn run packages:docsExtract - yarn run packages:docsToMarkdown - yarn run packages:clean - -docs-no-generate-frontend: - docker run -v $(shell pwd)/sources:/hugo/content/docs/grafana/latest -p 3002:3002 --rm -it $(IMAGE) /bin/bash -c 'make server' - -docs: docs-generate-frontend +docs: docker pull $(IMAGE) - docker run -v $(shell pwd)/sources:/hugo/content/docs/grafana/latest -p 3002:3002 --rm -it $(IMAGE) /bin/bash -c 'make server' + docker run -v $(shell pwd)/sources:/hugo/content/docs/grafana/latest -p 3002:3002 --rm -it $(IMAGE) + +docs-no-pull: + docker run -v $(shell pwd)/sources:/hugo/content/docs/grafana/latest -p 3002:3002 --rm -it $(IMAGE) -docs-no-pull: docs-generate-frontend docs-no-generate-frontend - -docs-test: docs-generate-frontend +docs-test: docker pull $(IMAGE) docker run -v $(shell pwd)/sources:/hugo/content/docs/grafana/latest --rm -it $(IMAGE) /bin/bash -c 'make prod' diff --git a/docs/README.md b/docs/README.md index 8ca41d57852..305282120c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,8 +9,8 @@ Yarn >= 1.22.4 ## Build the doc site -1. In the command line, make sure you are in the docs folder: `cd docs`. -1. Run `make docs`. This launches a preview of the docs website at `http://localhost:3002/docs/grafana/latest/` which will refresh automatically when changes to content in the `sources` directory are made. +1. On the command line, first change to the docs folder: `cd docs`. +1. Run `make docs`. This launches a preview of the docs website at `http://localhost:3002/docs/grafana/latest/` which will refresh automatically when changes are made to content in the `sources` directory. --- @@ -34,6 +34,6 @@ Images are currently hosted in the grafana/website repo. ## Deploy changes to grafana.com -When a PR is merged to master with changes in the `docs/sources` directory, those changes are automatically synched to the grafana/website repo and published to the staging site. +When a PR is merged to master with changes in the `docs/sources` directory, those changes are automatically synced to the grafana/website repo and published to the staging site. -Generally, someone from marketing will publish to production each day, so as long as the sync is successful your docs edits will be published. Alternatively, you can refer to [publishing to production](https://github.com/grafana/website#publishing-to-production-grafanacom) if you'd like to do it yourself. \ No newline at end of file +Generally, someone from marketing will publish to production each day: so as long as the sync is successful your docs edits will be published. Alternatively, you can refer to [publishing to production](https://github.com/grafana/website#publishing-to-production-grafanacom) if you'd like to do it yourself. diff --git a/docs/sources/administration/cli.md b/docs/sources/administration/cli.md index 98322332c58..f5edd13ce8e 100644 --- a/docs/sources/administration/cli.md +++ b/docs/sources/administration/cli.md @@ -112,7 +112,7 @@ grafana-cli --debug plugins install `--configOverrides` is a command line argument that acts like an environmental variable override. -For example, you can use it to redirect logging to another file (maybe to log plugin installations in Grafana Cloud) or when resetting the admin password and you have non-default values for some important config value (like where the database is located). +For example, you can use it to redirect logging to another file (maybe to log plugin installations in Grafana Cloud) or when resetting the admin password and you have non-default values for some important configuration value (like where the database is located). **Example:** ```bash diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 6a5db6e1208..f5653b6a533 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -1336,6 +1336,10 @@ Set to `true` if you want to test alpha plugins that are not yet ready for gener Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. +### marketplace_url + +Custom install/learn more url for enterprise plugins. Defaults to https://grafana.com/grafana/plugins/. +
## [plugin.grafana-image-renderer] diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index a5f3e0c6800..0093bc18f15 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -30,9 +30,9 @@ Check out the [configuration]({{< relref "configuration.md" >}}) page for more i ### Using Environment Variables -It is possible to use environment variable interpolation in all 3 provisioning config types. Allowed syntax +It is possible to use environment variable interpolation in all 3 provisioning configuration types. Allowed syntax is either `$ENV_VAR_NAME` or `${ENV_VAR_NAME}` and can be used only for values not for keys or bigger parts -of the configs. It is not available in the dashboard's definition files just the dashboard provisioning +of the configurations. It is not available in the dashboard's definition files just the dashboard provisioning configuration. Example: @@ -157,6 +157,11 @@ Since not all datasources have the same configuration settings we only have the | interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | | logMessageField | string | Elasticsearch | Which field should be used as the log message | | logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message | +| sigV4AuthType | string | Elasticsearch | SigV4 auth provider. default/credentials/keys | +| sigV4ExternalId | string | Elasticsearch | Optional SigV4 External ID | +| sigV4AssumeRoleArn | string | Elasticsearch | Optional SigV4 ARN role to assume | +| sigV4Region | string | Elasticsearch | SigV4 AWS region | +| sigV4Profile | string | Elasticsearch | Optional SigV4 credentials profile | | authType | string | Cloudwatch | Auth provider. default/credentials/keys | | externalId | string | Cloudwatch | Optional External ID | | assumeRoleArn | string | Cloudwatch | Optional ARN role to assume | @@ -191,6 +196,8 @@ Secure json data is a map of settings that will be encrypted with [secret key]({ | basicAuthPassword | string | _All_ | password for basic authentication | | accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch | | secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch | +| sigV4AccessKey | string | Elasticsearch | SigV4 access key. Required when using keys auth provider | +| sigV4SecretKey | string | Elasticsearch | SigV4 secret key. Required when using keys auth provider | #### Custom HTTP headers for datasources diff --git a/docs/sources/administration/security.md b/docs/sources/administration/security.md index 445b2eb1506..247915abc6f 100644 --- a/docs/sources/administration/security.md +++ b/docs/sources/administration/security.md @@ -39,5 +39,5 @@ Users with the Viewer role can enter *any possible query* in *any* of the data s To address this vulnerability, you can restrict data source query access in the following ways: -- Create multiple data sources with some restrictions added in data source config that restrict access (like database name or credentials). Then use the [Data Source Permissions]({{< relref "../permissions/datasource_permissions.md" >}}) Enterprise feature to restrict user access to the data source in Grafana. +- Create multiple data sources with some restrictions added in data source configuration that restrict access (like database name or credentials). Then use the [Data Source Permissions]({{< relref "../permissions/datasource_permissions.md" >}}) Enterprise feature to restrict user access to the data source in Grafana. - Create a separate Grafana organization, and in that organization, create a separate data source. Make sure the data source has some option/user/credentials setting that limits access to a subset of the data. Not all data sources have an option to limit access. diff --git a/docs/sources/alerting/create-alerts.md b/docs/sources/alerting/create-alerts.md index abd1bea786e..262c2906479 100644 --- a/docs/sources/alerting/create-alerts.md +++ b/docs/sources/alerting/create-alerts.md @@ -42,7 +42,7 @@ This section describes the fields you fill out to create an alert. - **Evaluate every -** Specify how often the scheduler should evaluate the alert rule. This is referred to as the _evaluation interval_. - **For -** Specify how long the query needs to violate the configured thresholds before the alert notification triggers. -You can set a minimum evaluation interval in the `alerting.min_interval_seconds` config field, to set a minimum time between evaluations. Refer to [Configuration]({{< relref "../administration/configuration.md" >}}#min-interval-seconds) for more information. +You can set a minimum evaluation interval in the `alerting.min_interval_seconds` configuration field, to set a minimum time between evaluations. Refer to [Configuration]({{< relref "../administration/configuration.md" >}}#min-interval-seconds) for more information. > **Caution:** Do not use `For` with the `If no data or all values are null` setting set to `No Data`. The triggering of `No Data` will trigger instantly and not take `For` into consideration. This may also result in that an OK notification not being sent if alert transitions from `No Data -> Pending -> OK`. diff --git a/docs/sources/auth/ldap.md b/docs/sources/auth/ldap.md index a4856be3231..facac356c74 100644 --- a/docs/sources/auth/ldap.md +++ b/docs/sources/auth/ldap.md @@ -94,7 +94,7 @@ email = "email" ### Using environment variables -You can interpolate variables in the TOML config from environment variables. For instance, you could externalize your `bind_password` that way: +You can interpolate variables in the TOML configuration from environment variables. For instance, you could externalize your `bind_password` that way: ```bash bind_password = "${LDAP_ADMIN_PASSWORD}" @@ -161,8 +161,7 @@ In `[[servers.group_mappings]]` you can map an LDAP group to a Grafana organizat the authoritative source. So, if you change a user's role in the Grafana Org. Users page, this change will be reset the next time the user logs in. If you change the LDAP groups of a user, the change will take effect the next time the user logs in. -The first group mapping that an LDAP user is matched to will be used for the sync. If you have LDAP users that fit multiple mappings, the topmost mapping in the -TOML config will be used. +The first group mapping that an LDAP user is matched to will be used for the sync. If you have LDAP users that fit multiple mappings, the topmost mapping in the TOML configuration will be used. **LDAP specific configuration file (ldap.toml) example:** ```bash diff --git a/docs/sources/dashboards/share-panel.md b/docs/sources/dashboards/share-panel.md index 9f7bfd50cb8..bb571303bf5 100644 --- a/docs/sources/dashboards/share-panel.md +++ b/docs/sources/dashboards/share-panel.md @@ -10,7 +10,20 @@ weight = 600 # Share a panel -Click a panel title to open the panel menu, then click share in the panel menu to open the Share Panel dialog. Here you have access to a link that will take you to exactly this panel with the current time range and selected template variables. Below are ways to share a panel. +To share a panel + +1. Click a panel title to open the panel menu. +1. Click share in the panel menu to open the Share Panel dialog. + + Here you have access to a link that will take you to exactly this panel with the current time range and selected template variables. Here are some of the ways you can share a panel. + +## Publish snapshots + +You can publish snapshots to your local instance or to [snapshot.raintank.io](http://snapshot.raintank.io). The latter is a free service provided by [Raintank](http://raintank.io), that allows you to publish dashboard snapshots to an external Grafana instance. + +Anyone with the link can view it. You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. + +{{< docs-imagebox img="/img/docs/panels/Share_a_panel.png" max-width="700px" >}} ## Direct Link Rendered Image diff --git a/docs/sources/datasources/azuremonitor.md b/docs/sources/datasources/azuremonitor.md index 37df276e380..0348b420547 100644 --- a/docs/sources/datasources/azuremonitor.md +++ b/docs/sources/datasources/azuremonitor.md @@ -47,7 +47,7 @@ The data source can access metrics from four different services. You can configu - The Subscription Id can be changed per query. Save the data source and refresh the page to see the list of subscriptions available for the specified Client Id. -1. If you are also using the Azure Log Analytics service, then you need to specify these two config values (or you can reuse the Client Id and Secret from the previous step). +1. If you are also using the Azure Log Analytics service, then you need to specify these two configuration values (or you can reuse the Client Id and Secret from the previous step). - Client Id (Azure Active Directory -> App Registrations -> Choose your app -> Application ID) - Client Secret (Azure Active Directory -> App Registrations -> Choose your app -> Keys -> Create a key -> Use client secret) diff --git a/docs/sources/datasources/loki.md b/docs/sources/datasources/loki.md index ad97af98be5..d29189fd7fe 100644 --- a/docs/sources/datasources/loki.md +++ b/docs/sources/datasources/loki.md @@ -134,8 +134,37 @@ Note that Live Tailing relies on two Websocket connections: one between the brow ``` ProxyPassMatch "^/(api/datasources/proxy/\d+/loki/api/v1/tail)" "ws://127.0.0.1:3000/$1" ``` +The following example shows basic NGINX proxy configuration. It assumes that the Grafana server is available at `http://localhost:3000/`, Loki server is running locally without proxy, and your external site uses HTTPS. If you also host Loki behind NGINX proxy, then you might want to repeat the following configuration for Loki as well. -> **Note:** This feature is only available in Grafana v6.3+ +In the `http` section of NGINX configuration, add the following map definition: +``` + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } +``` +In your `server` section, add the following configuration: +``` + location ~ /(api/datasources/proxy/\d+/loki/api/v1/tail) { + proxy_pass http://localhost:3000$request_uri; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto "https"; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + } + + location / { + proxy_pass http://localhost:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto "https"; + } +``` + +> **Note:** This feature is only available in Grafana v6.3+. ## Log Context diff --git a/docs/sources/developers/plugins/_index.md b/docs/sources/developers/plugins/_index.md index 52559e9f95a..c8207ba2b12 100644 --- a/docs/sources/developers/plugins/_index.md +++ b/docs/sources/developers/plugins/_index.md @@ -83,4 +83,4 @@ Learn more about Grafana options and packages. - [Grafana Plugin SDK for Go]({{< relref "backend/grafana-plugin-sdk-for-go" >}}) - \ No newline at end of file + diff --git a/docs/sources/developers/plugins/legacy/apps.md b/docs/sources/developers/plugins/legacy/apps.md index 4bca36ca875..ad9728dfeed 100644 --- a/docs/sources/developers/plugins/legacy/apps.md +++ b/docs/sources/developers/plugins/legacy/apps.md @@ -15,7 +15,7 @@ Data source and panel plugins will show up like normal plugins. The app pages wi ## Enabling app plugins -After installing an app, it has to be enabled before it shows up as a data source or panel. You can do that on the app page in the config tab. +After installing an app, it has to be enabled before it shows up as a data source or panel. You can do that on the app page in the configuration tab. ## Developing an App Plugin diff --git a/docs/sources/developers/plugins/legacy/review-guidelines.md b/docs/sources/developers/plugins/legacy/review-guidelines.md index 572fbd29500..b0d93a3d2b4 100644 --- a/docs/sources/developers/plugins/legacy/review-guidelines.md +++ b/docs/sources/developers/plugins/legacy/review-guidelines.md @@ -135,7 +135,7 @@ Use the `width-x` and `max-width-x` classes to control the width of your labels ## Data Sources For more information about data sources, refer to the [basic guide for data sources](http://docs.grafana.org/plugins/developing/datasources/). -### Config Page Guidelines +### Configuration Page Guidelines - It should be as easy as possible for a user to configure a URL. If the data source is using the `datasource-http-settings` component, it should use the `suggest-url` attribute to suggest the default URL or a URL that is similar to what it should be (especially important if the URL refers to a REST endpoint that is not common knowledge for most users e.g. `https://yourserver:4000/api/custom-endpoint`). @@ -155,7 +155,7 @@ If possible, any passwords or secrets should be saved in the `secureJsonData` bl Read more here about how [authentication for data sources]({{< relref "../add-authentication-for-data-source-plugins.md" >}}) works. -If using the proxy feature, the Config page should use the `secureJsonData` blob like this: +If using the proxy feature, the Configuration page should use the `secureJsonData` blob like this: - good: `` - bad: `` diff --git a/docs/sources/developers/plugins/migration-guide.md b/docs/sources/developers/plugins/migration-guide.md index 596997dff7e..4354db6b4e4 100644 --- a/docs/sources/developers/plugins/migration-guide.md +++ b/docs/sources/developers/plugins/migration-guide.md @@ -46,7 +46,7 @@ For plugins prior to Grafana 7.0, all options are considered _Display options_. While backend plugins were available as an experimental feature in previous versions of Grafana, the support has been greatly improved for Grafana 7. Backend plugins for Grafana 7.0 are backwards-compatible and will continue to work. However, the old backend plugin system has been deprecated, and we recommend that you use the new SDK for backend plugins. -Since Grafana 7.0 introduced [signing of backend plugins]({{< relref "../../plugins/plugin-signature-verification.md" >}}), community plugins won’t load by default if they’re unsigned. +Since Grafana 7.0 introduced [signing of backend plugins]({{< relref "../../plugins/plugin-signatures.md" >}}), community plugins won’t load by default if they’re unsigned. To learn more, refer to [Backend plugins]({{< relref "backend" >}}). @@ -127,7 +127,7 @@ export const MyPanel: React.FC = ({ options, data, width, height }) => { While all plugins are different, we'd like to share a migration process that has worked for some of our users. -1. Define your configuration model and `ConfigEditor`. For many plugins, the config editor is the simplest component so it's a good candidate to start with. +1. Define your configuration model and `ConfigEditor`. For many plugins, the configuration editor is the simplest component so it's a good candidate to start with. 1. Implement the `testDatasource()` method on the class that extends `DataSourceApi` using the settings in your configuration model to make sure you can successfully configure and access the external API. 1. Implement the `query()` method. At this point, you can hard-code your query, because we haven’t yet implemented the query editor. The `query()` method supports both the new data frame response and the old TimeSeries response, so don’t worry about converting to the new format just yet. 1. Implement the `QueryEditor`. How much work this requires depends on how complex your query model is. @@ -165,4 +165,4 @@ For more information, refer to [Data frames](https://grafana.com/docs/grafana/la ## Troubleshoot plugin migration -With Grafana 7.0, backend plugins can now be cryptographically signed to verify their origin. By default, Grafana will ignore unsigned plugins. For more information, refer to [Allow unsigned plugins]({{< relref "../../plugins/plugin-signature-verification.md#allow-unsigned-plugins" >}}). +With Grafana 7.0, backend plugins can now be cryptographically signed to verify their origin. By default, Grafana will ignore unsigned plugins. For more information, refer to [Allow unsigned plugins]({{< relref "../../plugins/plugin-signatures.md#allow-unsigned-plugins" >}}). diff --git a/docs/sources/developers/plugins/package-a-plugin.md b/docs/sources/developers/plugins/package-a-plugin.md new file mode 100644 index 00000000000..0a4a37094ba --- /dev/null +++ b/docs/sources/developers/plugins/package-a-plugin.md @@ -0,0 +1,47 @@ ++++ +title = "Package a plugin" +type = "docs" +aliases = ["/docs/grafana/latest/developers/plugins/share-a-plugin/"] ++++ + +# Package a plugin + +You've just built your first plugin, and now you want to share it with the world. In this guide, you'll learn how to package and share your plugin with others. + +For Grafana to be able to load a plugin, it first needs to be built. When you build a plugin from source, a `dist` directory is created that contains the production build, or _plugin assets_, for your plugin. + +When the Grafana server starts, it recursively looks in the plugin directory for any directory that contains a `plugin.json` file and tries to load the plugin assets in the same directory. + +There are three steps needed to package a plugin: + +- Building the plugin +- Signing the plugin +- Archiving the plugin + +1. Build the plugin + + ``` + yarn install --pure-lockfile + yarn build + ``` + +1. (Optional) If your data source plugin has a backend plugin, build it as well. + + ``` + mage + ``` + +1. [Sign the plugin]({{< relref "sign-a-plugin.md" >}}). + +1. Create a ZIP archive of the `dist` directory. + + ``` + mv dist/ myorg-simple-panel + zip myorg-simple-panel-1.0.0.zip myorg-simple-panel -r + ``` + +## Publish your plugin on Grafana.com + +The best way to share your plugin with the world is to publish it on [Grafana Plugins](https://grafana.com/plugins). By having your plugin published on Grafana.com, more users will be able to discover your plugin. + +To publish a plugin to [Grafana Plugins](https://grafana.com/grafana/plugins), create a pull request to the [Grafana Plugin Repository](https://github.com/grafana/grafana-plugin-repository). Please note that both the source code and the packaged plugin archive need to be publicly available. diff --git a/docs/sources/developers/plugins/share-a-plugin.md b/docs/sources/developers/plugins/share-a-plugin.md deleted file mode 100644 index b6ddc1e8522..00000000000 --- a/docs/sources/developers/plugins/share-a-plugin.md +++ /dev/null @@ -1,120 +0,0 @@ -+++ -title = "Share a plugin" -type = "docs" -+++ - -# Share a plugin - -You've just built your first plugin, and now you want to share it with the world. In this guide, you'll learn how to package and share your plugin with others. - -When you build a plugin from source, a `dist` directory is created that contains the production build, or _plugin assets_, for your plugin. - -When loading your plugin, Grafana only cares about the plugin assets. Specifically, when the Grafana server starts, it attempts to discover and load plugins like this: - -1. Look for a `plugin.json` file in any of the subdirectories in the plugin directory. -1. If a `plugin.json` was found, try to load the plugin assets from a `dist` directory in the same directory as the `plugin.json` file. -1. If there's no `dist` directory, try to load the plugin assets from the same directory as the `plugin.json` file. - -Now that you know what Grafana needs to load your plugin, let's see how you can share the plugin with other users. - -The best way to share your plugin with the world is to publish it on [Grafana Plugins](https://grafana.com/plugins). However, if you're not ready to make your plugin public just yet, you can still share your plugin by hosting the plugin yourself. - -## Publish your plugin on Grafana.com - -To publish a plugin to [Grafana Plugins](https://grafana.com/grafana/plugins), your plugin first needs to be publicly available in a commit on [GitHub](https://github.com). - -The commit you submit needs to either: - -- Contain a `dist` directory with the plugin assets -- Contain the plugin assets in the root directory - -We strongly recommend that you don't check in the plugin assets to the main branch. Instead, use the following steps to create a release branch that contains the plugin assets. - -1. Create a release branch. - - ``` - git checkout -b release-0.1.x - ``` - -1. Build the plugin assets. - - ``` - yarn build - ``` - -1. Add the `dist` directory. The `-f` flag adds the directory even if it's ignored by `.gitignore`. - - ``` - git add -f dist - ``` - -1. Create the release commit. - - ``` - git commit -m "Release v0.1.0" - ``` - -1. Create a release tag. You can also [create the release on GitHub](https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository). If you do, then you can skip this step and the next one. - - ``` - git tag -a v0.1.0 -m "Create release tag v0.1.0" - ``` - -1. Push to GitHub. `follow-tags` tells Git to push the release tag along with our release branch. - - ``` - git push --set-upstream origin release-0.1.x --follow-tags - ``` - -The next step is to submit the URL to your repository, and the release commit, to the [Grafana Plugin Repository](https://github.com/grafana/grafana-plugin-repository). - -## Host the plugin yourself - -If you want to share your plugin by hosting it yourself, then we recommend that you package it by adding the plugin assets to a .zip archive. You can then make the archive available by hosting it yourself. - -How you package the plugin depends on whether you want to include the source code or not. - -### Package the plugin with source code - -If you want to distribute the source code along with your plugin assets, then you can archive the entire plugin directory. - -To create a .zip archive that contains the plugin assets and source code, run the following commands in your terminal: - -``` -cd my-plugin/ -yarn build -zip my-plugin-0.2.0.src.zip . -r -x "node_modules/*" -x ".git/*" -``` - -### Package the plugin without source code - -If you don't want to distribute the plugin with the source code, then you can archive the `dist` directory. - -To create a .zip archive that only contains the bare minimum to load the plugin, run the following commands in your terminal: - -``` -cd my-plugin/ -yarn build -cd dist/ -zip my-plugin-0.2.0.nosrc.zip . -r -``` - -### Package and host your plugin using GitHub - -If you host your plugin on GitHub, then you can share the plugin using the following URL: - -``` -https://github.com/GITHUB_USERNAME/GITHUB_REPO_NAME/archive/.zip -``` - -For example, you can download the [Worldmap Panel](https://github.com/grafana/worldmap-panel) using the following URL: - -[https://github.com/grafana/worldmap-panel/archive/v0.3.2.zip](https://github.com/grafana/worldmap-panel/archive/v0.3.2.zip) - -### Install a packaged plugin - -After the user has downloaded the archive containing the plugin assets, they can install it by extracting the archive into their plugin directory. - -``` -unzip my-plugin-0.2.0.zip -d YOUR_PLUGIN_DIR/my-plugin -``` diff --git a/docs/sources/developers/plugins/sign-a-plugin.md b/docs/sources/developers/plugins/sign-a-plugin.md index 0f43b87ea5e..b1e387b187c 100644 --- a/docs/sources/developers/plugins/sign-a-plugin.md +++ b/docs/sources/developers/plugins/sign-a-plugin.md @@ -5,6 +5,97 @@ type = "docs" # Sign a plugin -Signing a plugin allows Grafana to verify the authenticity of the plugin with [signature verification]({{< relref "../../plugins/plugin-signature-verification.md" >}}). This gives users a way to make sure plugins haven't been tampered with. All Grafana Labs-authored backend plugins, including Enterprise plugins, are signed. +Signing a plugin allows Grafana to verify the authenticity of the plugin with [signature verification]({{< relref "../../plugins/plugin-signatures.md" >}}). This gives users a way to make sure plugins haven't been tampered with. All Grafana Labs-authored backend plugins, including Enterprise plugins, are signed. -We're looking into providing a process for allowing community plugins to be signed in an upcoming version of Grafana. +> **Important:** Future versions of Grafana will require all plugins to be signed. + +## Sign your plugin using Grafana Toolkit + +The easiest way to sign your plugin is by using the [Grafana Toolkit](https://www.npmjs.com/package/@grafana/toolkit). + +You can sign your plugin as a _public_ or a _private_ plugin. In both cases, you need to [create an account on Grafana.com](https://grafana.com/signup) and generate an API key with the `PluginPublisher` role. By creating an account, you can verify that you own the plugin that you want to sign. + +### Sign a public plugin + +Plugins signed under the community or commercial signature level are considered _public plugins_. Public plugins are published on [Grafana Plugin](https://grafana.com/plugins). For more information about installing public plugins, refer to [Install Grafana plugins]({{< relref "../../plugins/installation.md" >}}). + +1. Request a plugin signature level by sending an email to [plugins@grafana.com](mailto:plugins@grafana.com). + +1. Sign the plugin with the API key you just created. Grafana Toolkit creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. + + ``` + export GRAFANA_API_KEY= + npx @grafana/toolkit plugin:sign + ``` + +### Sign a private plugin + +If you're developing plugins for internal use only and don't want to make it public, you can sign it under a Private [signature level](#plugin-signature-levels). + +1. Sign the plugin with the API key you just created. Grafana Toolkit creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. + + The `rootUrls` flag accepts a comma-separated list of URLs for which the plugin can be used. The URLs need to match the [root_url]({{< relref "../../administration/configuration.md#root_url" >}}) setting. + + ``` + export GRAFANA_API_KEY= + npx @grafana/toolkit plugin:sign --rootUrls https://example.com/grafana + ``` + +## Plugin signature levels + +To sign a plugin, you need to decide the _signature level_ you want to sign it under. The signature level of your plugin determines how you can distribute it. + +You can sign your plugin under three different _signature levels_. + +|**Plugin Level**|**Paid Subscription Required?**|**Description**| +|---|---|---| +|Private|No;
Free of charge|

You can create and sign a Private Plugin for any technology at no charge.

Private Plugins are for use on your own Grafana. They may not be distributed to the Grafana community, and are not published in the Grafana catalog.

| +|Community|No;
Free of charge|

You can create, sign and distribute plugins at no charge, provided that all dependent technologies are open source and not for profit.

Community Plugins are published in the official Grafana catalog, and are available to the Grafana community.

| +|Commercial|Yes;
Commercial Plugin Subscription required|

You can create, sign and distribute plugins with dependent technologies that are closed source or commercially backed, by entering into a Commercial Plugin Subscription with Grafana Labs.

Commercial Plugins are published on the official Grafana catalog, and are available to the Grafana community.

| + +For instructions on how to sign a plugin under the Community and Commercial signature level, refer to [Sign a public plugin](#sign-a-public-plugin). + +For instructions on how to sign a plugin under the Private signature level, refer to [Sign a private plugin](#sign-a-private-plugin). + +## Plugin manifest + +For Grafana to verify the digital signature of a plugin, the plugin must include a signed manifest file, _MANIFEST.txt_. The signed manifest file contains two sections: + +- **Signed message -** The signed message contains plugin metadata and plugin files with their respective checksums (SHA256). +- **Digital signature -** The digital signature is created by encrypting the signed message using a private key. Grafana has a public key built-in that can be used to verify that the digital signature have been encrypted using expected private key. + +**Example manifest file:** + +```txt +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +{ + "manifestVersion": "2.0.0", + "signatureType": "community", + "signedByOrg": "myorgid", + "signedByOrgName": "My Org", + "plugin": "myorgid-simple-panel", + "version": "1.0.0", + "time": 1602753404133, + "keyId": "7e4d0c6a708866e7", + "files": { + "LICENSE": "12ab7a0961275f5ce7a428e662279cf49bab887d12b2ff7bfde738346178c28c", + "module.js.LICENSE.txt": "0d8f66cd4afb566cb5b7e1540c68f43b939d3eba12ace290f18abc4f4cb53ed0", + "module.js.map": "8a4ede5b5847dec1c6c30008d07bef8a049408d2b1e862841e30357f82e0fa19", + "plugin.json": "13be5f2fd55bee787c5413b5ba6a1fae2dfe8d2df6c867dadc4657b98f821f90", + "README.md": "2d90145b28f22348d4f50a81695e888c68ebd4f8baec731fdf2d79c8b187a27f", + "module.js": "b4b6945bbf3332b08e5e1cb214a5b85c82557b292577eb58c8eb1703bc8e4577" + } +} +-----BEGIN PGP SIGNATURE----- +Version: OpenPGP.js v4.10.1 +Comment: https://openpgpjs.org + +wqEEARMKAAYFAl+IE3wACgkQfk0ManCIZudpdwIHTCqjVzfm7DechTa7BTbd ++dNIQtwh8Tv2Q9HksgN6c6M9nbQTP0xNHwxSxHOI8EL3euz/OagzWoiIWulG +7AQo7FYCCQGucaLPPK3tsWaeFqVKy+JtQhrJJui23DAZLSYQYZlKQ+nFqc9x +T6scfmuhWC/TOcm83EVoCzIV3R5dOTKHqkjIUg== +=GdNq +-----END PGP SIGNATURE----- +``` diff --git a/docs/sources/enterprise/_index.md b/docs/sources/enterprise/_index.md index 490c40b7e41..d56032de7c3 100644 --- a/docs/sources/enterprise/_index.md +++ b/docs/sources/enterprise/_index.md @@ -59,17 +59,19 @@ With Grafana Enterprise, you get access to new features, including: With a Grafana Enterprise license, you get access to premium plugins, including: -- [Amazon Timestream](https://grafana.com/plugins/grafana-timestream-datasource) - [AppDynamics](https://grafana.com/plugins/dlopes7-appdynamics-datasource) - [DataDog](https://grafana.com/plugins/grafana-datadog-datasource) - [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource) +- [MongoDB](https://grafana.com/grafana/plugins/grafana-mongodb-datasource) - [New Relic](https://grafana.com/plugins/grafana-newrelic-datasource) - [Oracle Database](https://grafana.com/plugins/grafana-oracle-datasource) - [ServiceNow](https://grafana.com/grafana/plugins/grafana-servicenow-datasource) +- [Snowflake](https://grafana.com/grafana/plugins/grafana-snowflake-datasource) - [Splunk](https://grafana.com/plugins/grafana-splunk-datasource) +- [Wavefront](https://grafana.com/grafana/plugins/grafana-wavefront-datasource) ## Try Grafana Enterprise To purchase or obtain a trial license contact the Grafana Labs [Sales Team](https://grafana.com/contact?about=support&topic=Grafana%20Enterprise). - \ No newline at end of file + diff --git a/docs/sources/enterprise/saml.md b/docs/sources/enterprise/saml.md index 82a6522fe5b..bc5c0a78e0b 100644 --- a/docs/sources/enterprise/saml.md +++ b/docs/sources/enterprise/saml.md @@ -185,7 +185,7 @@ Organization mapping allows you to assign users to particular organization in Gr 1. In configuration file, set [`assertion_attribute_org`]({{< relref "./enterprise-configuration.md#assertion-attribute-org" >}}) to the attribute name you store organization info in. 1. Set [`org_mapping`]({{< relref "./enterprise-configuration.md#org-mapping" >}}) option to the comma-separated list of `Organization:OrgId` pairs to map organization from IdP to Grafana organization specified by id. -For example, use following config to assign users from `Engineering` organization to the Grafana organization with id `2` and users from `Sales` - to the org with id `3`, based on `Org` assertion attribute value: +For example, use following configuration to assign users from `Engineering` organization to the Grafana organization with id `2` and users from `Sales` - to the org with id `3`, based on `Org` assertion attribute value: ```bash [auth.saml] diff --git a/docs/sources/enterprise/usage-insights.md b/docs/sources/enterprise/usage-insights.md index 2e82a32fb8a..09cfee0ec03 100644 --- a/docs/sources/enterprise/usage-insights.md +++ b/docs/sources/enterprise/usage-insights.md @@ -55,3 +55,21 @@ It shows two kinds of information: In the search view, you can sort dashboards using these insights data. It helps you find unused or broken dashboards or discover most viewed ones. {{< docs-imagebox img="/img/docs/enterprise/improved_search.png" max-width="650px" class="docs-image--no-shadow" >}} + +## Data source insights + +> Only available in Grafana Enterprise v7.3+. + +Data source insights give you information about how a data source has been used in the last thirty days. + +- Queries per day +- Errors per day +- Average load duration per day (ms) + +To find data source insights, go to: +1. Data source list view +1. Click on a data source +1. Click the insights tab + +{{< docs-imagebox img="/img/docs/enterprise/datasource_insights.png" max-width="650px" class="docs-image--no-shadow" >}} + diff --git a/docs/sources/explore/index.md b/docs/sources/explore/index.md index b1039ac2cb5..982f10e485f 100644 --- a/docs/sources/explore/index.md +++ b/docs/sources/explore/index.md @@ -145,7 +145,7 @@ Suggestions can appear under the query field - click on them to update your quer ### Table filters -Click on the filter button in a labels column in the Table panel to add filters to the query expression. This works with multiple queries too - the filter will be added for all the queries. +Click on the filter button in the "label" column of a Table panel to add filters to the query expression. You can add filters for multiple queries as well - the filter is added for all the queries. ## Logs integration @@ -155,6 +155,10 @@ Along with metrics, Explore allows you to investigate your logs with the followi - [InfluxDB](../datasources/influxdb) - [Elasticsearch](../datasources/elasticsearch) +### Logs visualization + +Results of log queries are shown as histograms in the graph and individual logs are displayed below. If the data source does not send histogram data for the requested time range, the logs model computes a time series based on the log row counts bucketed by an automatically calculated time interval and the start of the histogram is then anchored by the first log row's timestamp from the result. The end of the time series is anchored to the time picker's **To** range. + ### Visualization options You can customize how logs are displayed and select which columns are shown. @@ -297,4 +301,4 @@ This functionality is similar to the panel inspector [Stats tab]({{< relref "../ {{< docs-imagebox img="/img/docs/v71/query_inspector_explore.png" class="docs-image--no-shadow" caption="Screenshot of the query inspector button in Explore" >}} - \ No newline at end of file + diff --git a/docs/sources/getting-started/glossary.md b/docs/sources/getting-started/glossary.md index 6dfee100b03..73274625ae6 100644 --- a/docs/sources/getting-started/glossary.md +++ b/docs/sources/getting-started/glossary.md @@ -40,12 +40,36 @@ This topic lists words and abbreviations that are commonly used in the Grafana d Explore allows a user to focus on a building a query. User can refine the query to return the expected metrics before building a dashboard. + + Export/Import dashboard + + Grafana includes the ability to export your dashboards to a file containing JSON. Community members sometimes share their created dashboards on the [Grafana Dashboards page](https://grafana.com/grafana/dashboards). Dashboards previously exported or found on this site may be imported by other users. + + + + Exporter + + An exporter translates data that comes out of a data source into a format that Prometheus can digest. + + + + Integration (Grafana Cloud) + + Each Integration in Grafana Cloud uses the cloud agent to connect your data source to Grafana Cloud for visualizing. Note: Prometheus uses the word “integrations” to refer to software that exposes Prometheus metrics without needing an exporter, which is a different use of the same word we use here. + + Graph A commonly-used visualization that displays data as points, lines, or bars. + + Mixin + + A mixin is a set of Grafana dashboards and Prometheus rules and alerts, written in Jsonnet and packaged together in a bundle. + + Panel diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 1b234ac982a..69c72f41890 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -471,7 +471,7 @@ Content-Type: application/json Reloads the provisioning config files for specified type and provision entities again. It won't return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop -polling for changes in dashboard files and then restart it with new configs after returning. +polling for changes in dashboard files and then restart it with new configurations after returning. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. diff --git a/docs/sources/http_api/org.md b/docs/sources/http_api/org.md index 74469520eb6..dbf1c7dfc5d 100644 --- a/docs/sources/http_api/org.md +++ b/docs/sources/http_api/org.md @@ -309,7 +309,7 @@ Content-Type: application/json ``` Note: The api will work in the following two ways 1) Need to set GF_USERS_ALLOW_ORG_CREATE=true -2) Set the config users.allow_org_create to true in ini file +2) Set the config value users.allow_org_create to true in ini file **Example Response**: diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index 9ed1784aea7..cecb4391175 100755 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -21,7 +21,7 @@ Upgrading is generally safe (between many minor and one major version) and dashb We recommend that you backup a few things in case you have to rollback the upgrade. - Installed plugins - Back them up before you upgrade them in case you want to rollback the Grafana version and want to get the exact same versions you were running before the upgrade. -- Configuration files do not need to be backed up. However, you might want to in case you add new config options after upgrade and then rollback. +- Configuration files do not need to be backed up. However, you might want to in case you add new configuration options after upgrade and then rollback. ### Database backup @@ -82,7 +82,7 @@ sudo apt-get upgrade If you downloaded the binary `.tar.gz` package, then you can just download and extract the new package and overwrite all your existing files. However, this might overwrite your config changes. -We recommend that you save your custom config changes in a file named `/conf/custom.ini`. +We recommend that you save your custom configuration changes in a file named `/conf/custom.ini`. This allows you to upgrade Grafana without risking losing your configuration changes. ### Centos / RHEL @@ -108,7 +108,7 @@ docker run -d --name=my-grafana-container --restart=always -v /var/lib/grafana:/ ### Windows -If you downloaded the Windows binary package you can just download a newer package and extract to the same location (and overwrite the existing files). This might overwrite your config changes. We recommend that you save your config changes in a file named `/conf/custom.ini` as this will make upgrades easier without risking losing your config changes. +If you downloaded the Windows binary package you can just download a newer package and extract to the same location (and overwrite the existing files). This might overwrite your configuration changes. We recommend that you save your configuration changes in a file named `/conf/custom.ini` as this will make upgrades easier without risking losing your configuration changes. ## Update plugins @@ -297,4 +297,21 @@ The other authentication methods, _Access & secret key_ and _Credentials file_, For more information and details, please refer to [Using AWS CloudWatch in Grafana]({{< relref "../datasources/cloudwatch.md#authentication" >}}). - \ No newline at end of file +### User invites database migration + +The database table _temp\_user_, that tracks user invites, is subject to a database migration that changes the data type of the _created_ and _updated_ columns: + +| Database | Old data type | New data type | +| -------- | ------------- | ------------- | +| Sqlite | DATETIME | INTEGER | +| MySQL | DATETIME | INT | +| Postgres | TIMESTAMP | INTEGER | + +> Please note that if downgrading Grafana to an earlier version, you have to manually change the data type of the _created_ and _updated_ columns back to _old data type_ , otherwise the user invite feature doesn't function as expected. + +### Snapshots database migration + +The database table _dashboard\_snapshot_, that stores dashboard snapshots, adds a new column _dashboard\_encrypted_ for storing an encrypted snapshot. +NOTE: Only snapshots created on Grafana 7.3 or later will use this column to store snapshot data as encrypted. Snapshots created before this version will be unaffected and remain unencrypted. + + diff --git a/docs/sources/linking/data-links.md b/docs/sources/linking/data-links.md index 791da258202..a7158d0508b 100644 --- a/docs/sources/linking/data-links.md +++ b/docs/sources/linking/data-links.md @@ -9,11 +9,9 @@ aliases = ["/docs/grafana/latest/reference/datalinks/"] Data links allow you to provide more granular context to your links. You can create links that include the series name or even the value under the cursor. For example, if your visualization showed four servers, you could add a data link to one or two of them. -The link itself is accessible in different ways depending on the visualization. For the graph you need to click on a data point or line, for a panel like +The link itself is accessible in different ways depending on the visualization. For the Graph you need to click on a data point or line, for a panel like Stat, Gauge, or Bar Gauge you can click anywhere on the visualization to open the context menu. -> **Note:** For stat, gauge, bar gauge, and table visualizations, you add and edit data links on the Field tab. For the graph visualization, you add and edit data links on the Panel tab. - You can use variables in data links to send people to a detailed dashboard with preserved data filters. For example, you could use variables to specify a time range, series, and variable selection. For more information, refer to [Data link variables]({{< relref "data-link-variables.md" >}}). ## Typeahead suggestions @@ -25,7 +23,7 @@ When creating or updating a data link, press Cmd+Space or Ctrl+Space on your key ## Add a data link 1. Hover your cursor over the panel that you want to add a link to and then press `e`. Or click the dropdown arrow next to the panel title and then click **Edit**. -1. On the Field tab, scroll down to the Data links section. (Panel tab for graph visualizations.) +1. On the Field tab, scroll down to the Data links section. 1. Expand Data links and then click **Add link**. 1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI. 1. Enter the **URL** you want to link to. @@ -38,14 +36,14 @@ When creating or updating a data link, press Cmd+Space or Ctrl+Space on your key ## Update a data link -1. On the Field tab, find the link that you want to make changes to. (Panel tab for graph visualizations.) -1. Click the Edit (pencil) icon to open the Edit link window. +1. On the Field tab, find the link that you want to make changes to. +1. Click the Edit (pencil) icon to open the Edit link window. 1. Make any necessary changes. 1. Click **Save** to save changes and close the window. 1. Click **Save** in the upper right to save your changes to the dashboard. ## Delete a data link -1. On the Field tab, find the link that you want to delete. (Panel tab for graph visualizations.) -1. Click the **X** icon next to the link you want to delete. +1. On the Field tab, find the link that you want to delete. +1. Click the **X** icon next to the link you want to delete. 1. Click **Save** in the upper right to save your changes to the dashboard. diff --git a/docs/sources/menu.yaml b/docs/sources/menu.yaml index 7000f0b6eed..8ca4bd0f461 100644 --- a/docs/sources/menu.yaml +++ b/docs/sources/menu.yaml @@ -438,8 +438,8 @@ link: /plugins/ - name: Install plugins link: /plugins/installation/ - - name: Plugin signature verification - link: /plugins/plugin-signature-verification/ + - name: Plugin signatures + link: /plugins/plugin-signatures/ - name: HTTP APIs link: /http_api/ children: @@ -537,8 +537,8 @@ name: Plugin protocol - link: /developers/plugins/backend/grafana-plugin-sdk-for-go/ name: Grafana plugin SDK for Go - - name: Share a plugin - link: /developers/plugins/share-a-plugin/ + - name: Package a plugin + link: /developers/plugins/package-a-plugin/ - name: Error handling link: /developers/plugins/error-handling/ - name: Plugin migration guide diff --git a/docs/sources/panels/calculations-list.md b/docs/sources/panels/calculations-list.md index 5c11faebdf3..c5f5ff469c3 100644 --- a/docs/sources/panels/calculations-list.md +++ b/docs/sources/panels/calculations-list.md @@ -21,6 +21,7 @@ Among other places, these calculations are used in the Transform tab and the bar | Count | number of values in a field| | Delta | cumulative change in value | | Difference | difference between first and last value of a field| +| Difference percent | percentage change between first and last value of a field | | Distinct count | number of unique values in a field | | First (not null) | first, not null value in a field| | Max | maximum value of a field | diff --git a/docs/sources/panels/field-options/standard-field-options.md b/docs/sources/panels/field-options/standard-field-options.md index e35dd2a211c..8f81f293ad6 100644 --- a/docs/sources/panels/field-options/standard-field-options.md +++ b/docs/sources/panels/field-options/standard-field-options.md @@ -18,6 +18,8 @@ For more information about applying these options, refer to: - [Configure all fields]({{< relref "configure-all-fields.md" >}}) - [Configure specific fields]({{< relref "configure-specific-fields.md" >}}) +> **Note:** We are constantly working to add and expand options for all visualization, so all options might not be available for all visualizations. + ## Decimals Number of decimals to render value with. Leave empty for Grafana to use the number of decimals provided by the data source. diff --git a/docs/sources/panels/visualizations/graph-panel.md b/docs/sources/panels/visualizations/graph-panel.md index 340047560a2..17a9e882e64 100644 --- a/docs/sources/panels/visualizations/graph-panel.md +++ b/docs/sources/panels/visualizations/graph-panel.md @@ -16,8 +16,9 @@ This visualization is the most-used in the Grafana ecosystem. It can render as a Graph visualizations allow you to apply: -- [Data transformations]({{< relref "../transformations/_index.md" >}}) - [Alerts]({{< relref "../../alerting/alerts-overview.md" >}}) - This is the only type of visualization that allows you to set alerts. +- [Data transformations]({{< relref "../transformations/_index.md" >}}) +- [Field options and overrides]({{< relref "../field-options/_index.md" >}}) - [Thresholds]({{< relref "../thresholds.md" >}}) ## Display options diff --git a/docs/sources/plugins/_index.md b/docs/sources/plugins/_index.md index 9da394ade74..3ff30990803 100644 --- a/docs/sources/plugins/_index.md +++ b/docs/sources/plugins/_index.md @@ -44,7 +44,7 @@ Use app plugins when you want to create an custom out-of-the-box monitoring expe ## Learn more - [Install plugins]({{< relref "./installation.md" >}}) -- [Plugin signature verification]({{< relref "./plugin-signature-verification.md" >}}) +- [Plugin signatures]({{< relref "./plugin-signatures.md" >}}) - Browse the available [Plugins](https://grafana.com/grafana/plugins) - \ No newline at end of file + diff --git a/docs/sources/plugins/installation.md b/docs/sources/plugins/installation.md index 29fb139bae8..fcd69b81d9c 100644 --- a/docs/sources/plugins/installation.md +++ b/docs/sources/plugins/installation.md @@ -25,3 +25,11 @@ Grafana Cloud handles the plugin installation automatically. Follow the instructions on the Install tab. You can either install the plugin with a Grafana CLI command or by downloading and uncompress a .zip file into the Grafana plugins directory. We recommend using Grafana CLI in most instances. The .zip option is available if your Grafana server does not have access to the internet. For more information about Grafana CLI plugin commands, refer to [Plugin commands]({{< relref "../administration/cli.md#plugins-commands" >}}). + +### Install a packaged plugin + +After the user has downloaded the archive containing the plugin assets, they can install it by extracting the archive into their plugin directory. + +``` +unzip my-plugin-0.2.0.zip -d YOUR_PLUGIN_DIR/my-plugin +``` diff --git a/docs/sources/plugins/plugin-signature-verification.md b/docs/sources/plugins/plugin-signature-verification.md deleted file mode 100644 index 2fd10848435..00000000000 --- a/docs/sources/plugins/plugin-signature-verification.md +++ /dev/null @@ -1,85 +0,0 @@ -+++ -title = "Plugin signature verification" -type = "docs" -+++ - -# Plugin signature verification - -Plugin signature verification (signing) is a security measure to make sure plugins haven't been tampered with. Upon loading, Grafana checks to see if a plugin is signed or unsigned when inspecting and verifying its digital signature. - -## How it works - -For Grafana to verify the digital signature of a plugin, the plugin must include a signed manifest file, _MANIFEST.txt_. The signed manifest file contains two sections: - -- **Signed message -** The signed message contains plugin metadata and plugin files with their respective checksums (SHA256). -- **Digital signature -** The digital signature is created by encrypting the signed message using a private key. Grafana has a public key built-in that can be used to verify that the digital signature have been encrypted using expected private key. - -### Signed manifest example file - -```txt -// MANIFEST.txt ------BEGIN PGP SIGNED MESSAGE----- -Hash: SHA512 - -{ - "plugin": "grafana-test-plugin", - "version": "1.0.0", - "files": { - "LICENSE": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", - "README.md": "5bfefcdce6eafce3388d1fb200f3b10954cfeac6c7a45fd7dec42687e01ac75d", - "module.js": "3c07596a6a4796c65ef10ba2bc0805e7f3bc9e4e8fc9970a1307b97e29db1c0a", - "module.js.LICENSE.txt": "fdbc28c10f3d21976b4bc16464ad7c630538c0c3101347b5fd44af9066f7022b", - "module.js.map": "c3ac1e8aa98d83c54fd13e43b65e1cf4182a924d2eb23a2f1a6fe40b7785a1bb", - "plugin.json": "cf26a3afb7c10cd9ae40b5296d04172b5dac927d69a51082e6d085b34341ccc3" - }, - "time": 1589558058070, - "keyId": "7e4d0c6a708866e7" -} ------BEGIN PGP SIGNATURE----- -Version: OpenPGP.js v4.10.1 -Comment: https://openpgpjs.org - -wqAEARMKAAYFAl6+uyoACgkQfk0ManCIZuc0+QIHdWC0dp7GRRFu3Hgk9tnl -FJnPwM6Y2tTdq7AkpVTTAb3RTFadA8dRmLfajxgHxmDf5yUv9M2M6sa1eTSG -8kJtOlwCB096dXOKsH1IOGQMCY+/xM2081FqbMTvWgN81xrxMoxftQn8z6VC -2nA2Rmt1VStppFVCCUXaq6Y4sFGHQF/yq5oi -=vqUQ ------END PGP SIGNATURE----- - -``` - -## Plugin signatures - -When Grafana starts, it discovers plugins to load. For each discovered plugin it verifies the authenticity of it, and then decides whether to load it or not based on the state of the plugin signature: - -| Plugin signature | Description | -| ---------------- | ----------- | -| internal | Core plugin built into Grafana. | -| invalid | Digital signature of _MANIFEST.txt_ file is not valid. | -| modified | Manifest plugin id or version have been changed or files checksums doesn't match. | -| unsigned | Plugin don't have a _MANIFEST.txt_ file. | -| valid | If any of the above descriptions is false. | - -The plugin signature state can be inspected for each plugin in the plugins listing page (Configuration -> Plugins). - -## Backend plugins - -If a [backend plugin]({{< relref "../developers/plugins/backend/_index.md" >}}) is not signed, then Grafana will not load or start it. If you try to load a backend plugin with an invalid signature, then Grafana writes an error message to the server log: - -```bash -EROR[06-01|16:45:59] Failed to load plugin error=plugin is unsigned -``` - -> **Note:** All Grafana Labs authored backend plugins, including Enterprise plugins, are signed. - -## Allow unsigned plugins - -While you can allow unsigned plugins using a configuration setting, we strongly advise you not to. For more information on how to allow unsigned backend plugin, refer to [Configuration]({{< relref "../administration/configuration.md#allow-loading-unsigned-plugins" >}}). Allowing unsigned plugins will not skip verifying the authenticity of a plugin if plugin has a _MANIFEST.txt_ file. - -If you run an unsigned backend plugin, then Grafana writes a warning message to the server log: - -```bash -WARN[06-01|16:45:59] Running an unsigned backend plugin pluginID= -``` - -If you're developing plugins and run Grafana from source, the development mode is enabled by default and also allow you to run unsigned backend plugins. diff --git a/docs/sources/plugins/plugin-signatures.md b/docs/sources/plugins/plugin-signatures.md new file mode 100644 index 00000000000..628b68ecdea --- /dev/null +++ b/docs/sources/plugins/plugin-signatures.md @@ -0,0 +1,53 @@ ++++ +title = "Plugin signatures" +type = "docs" +aliases = ["/docs/plugins/plugin-signature-verification"] ++++ + +# Plugin signatures + +Plugin signature verification (signing) is a security measure to make sure plugins haven't been tampered with. Upon loading, Grafana checks to see if a plugin is signed or unsigned when inspecting and verifying its digital signature. + +At startup, Grafana verifies the signatures of every plugin in the plugin directory. You can see the result of this verification for each plugin by navigating to **Configuration** -> **Plugins**. + +> **Note:** If you're a plugin developer and want to know how to sign your plugin, refer to [Sign a plugin]({{< relref "../developers/plugins/sign-a-plugin.md" >}}). + +| Signature status | Description | +| ---------------- | ----------- | +| Core | Core plugin built into Grafana. | +| Invalid signature | The plugin has a invalid signature. | +| Modified signature | The plugin has changed since it was signed. This may indicate malicious intent. | +| Unsigned | The plugin is not signed. | +| Signed | The plugin signature was successfully verified. | + +## Plugin signature levels + +All plugins is signed under a _signature level_. The signature level determines how the plugin can be distributed. + +|**Plugin Level**|**Description**| +|---|---| +|Private|

Private plugins are for use on your own Grafana. They may not be distributed to the Grafana community, and are not published in the Grafana catalog.

| +|Community|

Community plugins have dependent technologies that are open source and not for profit.

Community plugins are published in the official Grafana catalog, and are available to the Grafana community.

| +|Commercial|

Commercial plugins have dependent technologies that are closed source or commercially backed.

Commercial Plugins are published on the official Grafana catalog, and are available to the Grafana community.

| + +## Backend plugins + +If a [backend plugin]({{< relref "../developers/plugins/backend/_index.md" >}}) is unsigned, then Grafana won't load or start it. If you try to load a backend plugin with an missing or invalid signature, then Grafana writes an error message to the server log: + +```bash +EROR[06-01|16:45:59] Failed to load plugin error=plugin is unsigned +``` + +> **Note:** All Grafana Labs authored backend plugins, including Enterprise plugins, are signed. + +## Allow unsigned plugins + +We strongly recommend that you don't run unsigned plugins in your Grafana installation. If you're aware of the risks and you still want to load an unsigned plugin, refer to [Configuration]({{< relref "../administration/configuration.md#allow-loading-unsigned-plugins" >}}). + +If you've allowed loading of an unsigned backend plugin, then Grafana writes a warning message to the server log: + +```bash +WARN[06-01|16:45:59] Running an unsigned backend plugin pluginID= +``` + +> **Note:** If you're developing a plugin, then you can enable development mode to allow all unsigned plugins. diff --git a/docs/sources/sharing/share-panel.md b/docs/sources/sharing/share-panel.md index 18986e5d280..3cd19cf39ad 100644 --- a/docs/sources/sharing/share-panel.md +++ b/docs/sources/sharing/share-panel.md @@ -10,7 +10,20 @@ draft = "true" # Share a panel -Click a panel title to open the panel menu, then click share in the panel menu to open the Share Panel dialog. Here you have access to a link that will take you to exactly this panel with the current time range and selected template variables. Below are ways to share a panel. +To share a panel + +1. Click a panel title to open the panel menu. +1. Click share in the panel menu to open the Share Panel dialog. + + Here you have access to a link that will take you to exactly this panel with the current time range and selected template variables. Here are some of the ways you can share a panel. + +## Publish snapshots + +You can publish snapshots to your local instance or to [snapshot.raintank.io](http://snapshot.raintank.io). The latter is a free service provided by [Raintank](http://raintank.io), that allows you to publish dashboard snapshots to an external Grafana instance. + +Anyone with the link can view it. You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. + +{{< docs-imagebox img="/img/docs/panels/Share_a_panel.png" max-width="700px" >}} ## Direct Link Rendered Image diff --git a/docs/sources/variables/filter-variables-with-regex.md b/docs/sources/variables/filter-variables-with-regex.md index 4a79dabdaeb..2f6c1c63fe7 100644 --- a/docs/sources/variables/filter-variables-with-regex.md +++ b/docs/sources/variables/filter-variables-with-regex.md @@ -79,4 +79,35 @@ demo.robustperception.io:9090 demo.robustperception.io:9093 demo.robustperception.io:9100 ``` + +## Filter and modify using named text and value capture groups + +Using named capture groups, you can capture separate 'text' and 'value' parts from the options returned by the variable query. This allows the variable drop-down list to contain a friendly name for each value that can be selected. + +For example, when querying the `node_hwmon_chip_names` Prometheus metric, the `chip_name` is a lot friendlier that the `chip` value. So the following variable query result: + +```text +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_0",chip_name="enp216s0f0np0"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_1",chip_name="enp216s0f0np1"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_2",chip_name="enp216s0f0np2"} 1 +node_hwmon_chip_names{chip="0000:d7:00_0_0000:d8:00_3",chip_name="enp216s0f0np3"} 1 ``` + +Passed through the following Regex: + +```regex +/chip_name="(?[^"]+)|chip="(?[^"]+)/g +``` + +Would produce the following drop-down list: + +```text +Display Name Value +------------ ------------------------- +enp216s0f0np0 0000:d7:00_0_0000:d8:00_0 +enp216s0f0np1 0000:d7:00_0_0000:d8:00_1 +enp216s0f0np2 0000:d7:00_0_0000:d8:00_2 +enp216s0f0np3 0000:d7:00_0_0000:d8:00_3 +``` + +**Note:** Only `text` and `value` capture group names are supported. diff --git a/docs/sources/variables/variable-types/global-variables.md b/docs/sources/variables/variable-types/global-variables.md index 13f2b0aa994..4a43b974837 100644 --- a/docs/sources/variables/variable-types/global-variables.md +++ b/docs/sources/variables/variable-types/global-variables.md @@ -70,6 +70,7 @@ This variable is the ID of the current organization. `${__user.id}` is the ID of the current user. `${__user.login}` is the login handle of the current user. +`${__user.email}` is the email for the current user. ## $__range diff --git a/docs/sources/whatsnew/whats-new-in-v2-1.md b/docs/sources/whatsnew/whats-new-in-v2-1.md index 86567d7ac5f..223f4f7c3b8 100644 --- a/docs/sources/whatsnew/whats-new-in-v2-1.md +++ b/docs/sources/whatsnew/whats-new-in-v2-1.md @@ -61,7 +61,7 @@ You can also specify mappings between LDAP group memberships and Grafana Organiz ### Basic Auth Support You can now authenticate against the Grafana API utilizing a simple username and password with basic HTTP authentication. -> **Note:** This can be useful for provisioning and config management systems that need +> **Note:** This can be useful for provisioning and configuring management systems that need > to utilize the API without having to create an API key. @@ -129,4 +129,3 @@ string values. ### Changelog For a detailed list and link to github issues for everything included in the 2.1 release please view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file. - diff --git a/docs/sources/whatsnew/whats-new-in-v4-1.md b/docs/sources/whatsnew/whats-new-in-v4-1.md index d0d71060e53..790aee8a3b6 100644 --- a/docs/sources/whatsnew/whats-new-in-v4-1.md +++ b/docs/sources/whatsnew/whats-new-in-v4-1.md @@ -15,7 +15,7 @@ weight = 3 - **Graph**: Support for shared tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274) - **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin) - **Opsgenie**: Add OpsGenie notification integratiion [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc) -- **Cloudwatch**: Make it possible to specify access and secret key on the data source config page [#6697](https://github.com/grafana/grafana/issues/6697) +- **Cloudwatch**: Make it possible to specify access and secret key on the data source configuration page [#6697](https://github.com/grafana/grafana/issues/6697) - **Elasticsearch**: Added support for Elasticsearch 5.x [#5740](https://github.com/grafana/grafana/issues/5740), thx [@lpic10](https://github.com/lpic10) - **Panel**: Added help text for panels. [#4079](https://github.com/grafana/grafana/issues/4079), thx [@utkarshcmu](https://github.com/utkarshcmu) - [Full changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) diff --git a/docs/sources/whatsnew/whats-new-in-v4.md b/docs/sources/whatsnew/whats-new-in-v4.md index abe5a0ac40c..ca694c4a5c5 100644 --- a/docs/sources/whatsnew/whats-new-in-v4.md +++ b/docs/sources/whatsnew/whats-new-in-v4.md @@ -38,7 +38,7 @@ on but will be not ready for v4 release. {{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} -The rule config allows you to specify a name, how often the rule should be evaluated and a series +The rule configuration allows you to specify a name, how often the rule should be evaluated and a series of conditions that all need to be true for the alert to fire. Currently the only condition type that exists is a `Query` condition that allows you to diff --git a/docs/sources/whatsnew/whats-new-in-v5-3.md b/docs/sources/whatsnew/whats-new-in-v5-3.md index 3cb8fac533d..feae52d43db 100644 --- a/docs/sources/whatsnew/whats-new-in-v5-3.md +++ b/docs/sources/whatsnew/whats-new-in-v5-3.md @@ -29,7 +29,7 @@ Grafana v5.3 brings new features, many enhancements and bug fixes. This article Grafana v5.3 ships with built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) and enables you to visualize your Stackdriver metrics in Grafana. -Getting started with the plugin is easy. Simply create a GCE Service account that has access to the Stackdriver API scope, download the Service Account key file from Google and upload it on the Stackdriver data source config page in Grafana and you should have a secure server-to-server authentication setup. Like other core plugins, Stackdriver has built-in support for alerting. It also comes with support for heatmaps and basic variables. +Getting started with the plugin is easy. Simply create a GCE Service account that has access to the Stackdriver API scope, download the Service Account key file from Google and upload it on the Stackdriver data source configuration page in Grafana and you should have a secure server-to-server authentication setup. Like other core plugins, Stackdriver has built-in support for alerting. It also comes with support for heatmaps and basic variables. If you're already accustomed to the Stackdriver Metrics Explorer UI, you'll notice that there are a lot of similarities to the query editor in Grafana. It is possible to add filters using wildcards and regular expressions. You can do Group By, Primary Aggregation and Alignment. diff --git a/docs/sources/whatsnew/whats-new-in-v6-1.md b/docs/sources/whatsnew/whats-new-in-v6-1.md index b8b8c3f7867..4934cc2b1bd 100644 --- a/docs/sources/whatsnew/whats-new-in-v6-1.md +++ b/docs/sources/whatsnew/whats-new-in-v6-1.md @@ -27,13 +27,13 @@ Support for fetching a list of label names was released in Prometheus v2.6.0 so ### Permissions: Editors can own dashboards, folders and teams they create -When the dashboard folders feature and permissions system was released in Grafana 5.0, users with the editor role were not allowed to administrate dashboards, folders or teams. In the 6.1 release, we have added a config option that can change the default permissions so that editors are admins for any Dashboard, Folder or Team they create. +When the dashboard folders feature and permissions system was released in Grafana 5.0, users with the editor role were not allowed to administrate dashboards, folders or teams. In the 6.1 release, we have added a configuration option that can change the default permissions so that editors are admins for any Dashboard, Folder or Team they create. This feature also adds a new Team permission that can be assigned to any user with the editor or viewer role and enables that user to add other users to the Team. We believe that this is more in line with the Grafana philosophy, as it will allow teams to be more self-organizing. This option will be made permanent if it gets positive feedback from the community so let us know what you think in the [issue on GitHub](https://github.com/grafana/grafana/issues/15590). -To turn this feature on add the following [config option](/administration/configuration/#editors-can-admin) to your Grafana ini file in the `users` section and then restart the Grafana server: +To turn this feature on add the following [configuration option](/administration/configuration/#editors-can-admin) to your Grafana ini file in the `users` section and then restart the Grafana server: ```ini [users] diff --git a/docs/sources/whatsnew/whats-new-in-v7-3.md b/docs/sources/whatsnew/whats-new-in-v7-3.md index ce89346fdc2..5bc0ab4ae96 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-3.md +++ b/docs/sources/whatsnew/whats-new-in-v7-3.md @@ -22,6 +22,7 @@ The main highlights are: - [**Table improvements and new image cell mode**]({{< relref "#table-improvements-and-new-image-cell-mode" >}}) - [**New color scheme option**]({{< relref "#new-color-scheme-option" >}}) - [**SigV4 Authentication for Amazon Elasticsearch Service**]({{< relref "#sigv4-authentication-for-aws-users" >}}) +- [**CSV exports for Excel**]({{< relref "#csv-exports-for-excel" >}}) ## Table improvements and new image cell mode @@ -59,6 +60,11 @@ As this new option is a standard field option it works in every panel. Here is a {{< figure src="/img/docs/v73/bar_gauge_gradient_color_scheme.png" max-width="900px" caption="bar gauge color scheme" >}} +## CSV exports for Excel + +In v7.0, we introduced a new table panel and inspect mode with Download CSV enabled. However, CSV export to Excel was removed. Due to a large number of inquiries and requests, this [community contribution from tomdaly](https://github.com/grafana/grafana/pull/27284) brought the feature back. + +For more information, refer to [Download raw query results as CSV]({{< relref "../panels/inspect-panel/#download-raw-query-results-as-csv" >}}) in the Grafana documentation. ## Google Cloud monitoring out-of-the-box dashboards @@ -84,6 +90,10 @@ You can now configure your Elasticsearch data source to access your Amazon Elast For more details, refer to the [Elasticsearch docs]({{}}). +## Chaining pipeline aggregation in Elasticsearch + +Thanks to a contribution from a community member, it's now possible to chain multiple pipeline aggregations together and use the results of one pipeline aggregation as the input of another. This unleashes the full power of Elasticsearch's pipeline aggregations in Grafana, allowing users to perform high order derivatives or use a pipeline aggregation result as a variable for a Bucket Script Aggregation. + ## Grafana Enterprise features These features are included in the Grafana Enterprise edition software. diff --git a/e2e/suite1/specs/dashboard-templating.spec.ts b/e2e/suite1/specs/dashboard-templating.spec.ts index 87e30ba2677..4d8a6669825 100644 --- a/e2e/suite1/specs/dashboard-templating.spec.ts +++ b/e2e/suite1/specs/dashboard-templating.spec.ts @@ -19,6 +19,7 @@ e2e.scenario({ '__org.id = 1', '__user.id = 1', '__user.login = admin', + '__user.email = admin@localhost', `Server:raw = A'A"A,BB\\B,CCC`, `Server:regex = (A'A"A|BB\\\\B|CCC)`, `Server:lucene = ("A'A\\"A" OR "BB\\\\B" OR "CCC")`, @@ -38,7 +39,7 @@ e2e.scenario({ e2e() .get('.markdown-html li') - .should('have.length', 22) + .should('have.length', 23) .each(element => { items.push(element.text()); }) diff --git a/go.mod b/go.mod index 66ece421511..10803268325 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,11 @@ require ( cloud.google.com/go/storage v1.12.0 github.com/BurntSushi/toml v0.3.1 github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f - github.com/aws/aws-sdk-go v1.33.12 + github.com/aws/aws-sdk-go v1.35.5 github.com/beevik/etree v1.1.0 github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b - github.com/centrifugal/centrifuge v0.11.0 + github.com/centrifugal/centrifuge v0.13.0 github.com/crewjam/saml v0.4.1 github.com/davecgh/go-spew v1.1.1 github.com/deepmap/oapi-codegen v1.3.11 // indirect @@ -33,16 +33,16 @@ require ( github.com/gchaincl/sqlhooks v1.3.0 github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 - github.com/go-macaron/session v0.0.0-20190805070824-1a3cdc6f5659 github.com/go-sql-driver/mysql v1.5.0 github.com/go-stack/stack v1.8.0 github.com/gobwas/glob v0.2.3 github.com/golang/mock v1.4.4 github.com/golang/protobuf v1.4.3 github.com/google/go-cmp v0.5.2 + github.com/google/uuid v1.1.2 github.com/gosimple/slug v1.4.2 github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 - github.com/grafana/grafana-plugin-sdk-go v0.78.0 + github.com/grafana/grafana-plugin-sdk-go v0.79.0 github.com/grafana/loki v1.6.0 github.com/grpc-ecosystem/go-grpc-middleware v1.2.1 github.com/hashicorp/go-hclog v0.12.2 @@ -50,7 +50,7 @@ require ( github.com/hashicorp/go-version v1.2.0 github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec github.com/influxdata/influxdb-client-go/v2 v2.0.1 - github.com/jmespath/go-jmespath v0.3.0 + github.com/jmespath/go-jmespath v0.4.0 github.com/jonboulle/clockwork v0.2.1 // indirect github.com/jung-kurt/gofpdf v1.10.1 github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect diff --git a/go.sum b/go.sum index ff845928962..827670c4d8e 100644 --- a/go.sum +++ b/go.sum @@ -16,9 +16,7 @@ cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bP cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0 h1:Dg9iHVQfrhq82rUNu9ZxUDrJLaxFUe/HlCVaLyRruq8= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.66.0 h1:DZeAkuQGQqnm9Xv36SbMJEU8aFBz4wL04UpMWPWwjzg= cloud.google.com/go v0.66.0/go.mod h1:dgqGAjKCDxyhGTtC9dAREQGUJpkceNm1yt590Qno0Ko= cloud.google.com/go v0.70.0 h1:ujhG1RejZYi+HYfJNlgBh3j/bVKD8DewM7AkJ5UPyBc= cloud.google.com/go v0.70.0/go.mod h1:/UTKYRQTWjVnSe7nGvoSzxEFUELzSI/yAYd0JQT6cRo= @@ -40,9 +38,7 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy cloud.google.com/go/storage v1.3.0/go.mod h1:9IAwXhoyBJ7z9LcAwkj0/7NnPzYaPeZxxVp3zm+5IqA= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0 h1:86K1Gel7BQ9/WmNWn7dTKMvTLFzwtBe5FNqYbi9X35g= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0 h1:STgFzyU5/8miMl0//zKh2aQeTyeaUH3WN9bSUiJ09bA= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.12.0 h1:4y3gHptW1EHVtcPAVE0eBBlFuGqEejTTG3KdIE0lUX4= cloud.google.com/go/storage v1.12.0/go.mod h1:fFLk2dp2oAhDz8QFKwqrjdJvxSp/W2g7nillojlL5Ho= @@ -153,8 +149,9 @@ github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.33.5/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= -github.com/aws/aws-sdk-go v1.33.12 h1:eydMoSwfrSTD9PWKUJOiDL7+/UwDW8AjInUGVE5Llh4= github.com/aws/aws-sdk-go v1.33.12/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.35.5 h1:doSEOxC0UkirPcle20Rc+1kAhJ4Ip+GSEeZ3nKl7Qlk= +github.com/aws/aws-sdk-go v1.35.5/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= @@ -175,7 +172,6 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0= github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= github.com/c-bata/go-prompt v0.2.2/go.mod h1:VzqtzE2ksDBcdln8G7mk2RX9QyGjH+OVqOCSiVIqS34= @@ -186,8 +182,8 @@ github.com/cenkalti/backoff v1.0.0/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQ github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/centrifugal/centrifuge v0.11.0 h1:aaG74PBO+rBmz3hE9W8bCGz8kofiam1LNE4EAVtiMaA= -github.com/centrifugal/centrifuge v0.11.0/go.mod h1:jdFw/2dBFpME3OTisc5FVAC1ilqodnyMnQFjem0k0yg= +github.com/centrifugal/centrifuge v0.13.0 h1:nfcpaa/TXoA56ba/xXs3rc2Vrj/UZTFS1iTQ9q2MSZE= +github.com/centrifugal/centrifuge v0.13.0/go.mod h1:jdFw/2dBFpME3OTisc5FVAC1ilqodnyMnQFjem0k0yg= github.com/centrifugal/protocol v0.3.4 h1:9q22iSp4CQOdQahfopvfmWWxDbj1Lo7ERG2j56mAxkE= github.com/centrifugal/protocol v0.3.4/go.mod h1:2YbBCaDwQHl37ErRdMrKSj18X2yVvpkQYtSX6aVbe5A= github.com/cespare/xxhash v0.0.0-20181017004759-096ff4a8a059/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -227,16 +223,12 @@ github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfc github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cortexproject/cortex v0.6.1-0.20200228110116-92ab6cbe0995/go.mod h1:3Xa3DjJxtpXqxcMGdk850lcIRb81M0fyY1MQ6udY134= github.com/cortexproject/cortex v1.2.1-0.20200803161316-7014ff11ed70/go.mod h1:PVPxNLrxKH+yc8asaJOxuz7TiRmMizFfnSMOnRzM6oM= -github.com/couchbase/gomemcached v0.0.0-20190515232915-c4b4ca0eb21d/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c= -github.com/couchbase/goutils v0.0.0-20190315194238-f9d42b11473b/go.mod h1:BQwMFlJzDjFDG3DJUdU0KORxn88UlsOULuxLExMh3Hs= -github.com/couchbaselabs/go-couchbase v0.0.0-20190708161019-23e7ca2ce2b7/go.mod h1:mby/05p8HE5yHEAKiIH/555NoblMs7PtW6NrYshDruc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/crewjam/httperr v0.0.0-20190612203328-a946449404da/go.mod h1:+rmNIXRvYMqLQeR4DHyTvs6y0MEMymTz4vyFpFkKTPs= github.com/crewjam/saml v0.4.1 h1:ZNSRJvdbypQDY2uApMngeIHNcxS6UCRAgiw3S+pmgRU= github.com/crewjam/saml v0.4.1/go.mod h1:vHcshzXm2WkPOV1dcToZa99cCB1h3nPiKLtLYK+erBE= -github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY= github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= @@ -356,8 +348,6 @@ github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 h1:YSIA98PevNf1NtC github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07/go.mod h1://cJFfDp/70L0oTNAMB+M8Jd0rpuIx/55iARuJ6StwE= github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI= github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw= -github.com/go-macaron/session v0.0.0-20190805070824-1a3cdc6f5659 h1:YXDFNK98PgKeBd+xM2Babdd6gyABG8H+SSAh5+hr0os= -github.com/go-macaron/session v0.0.0-20190805070824-1a3cdc6f5659/go.mod h1:tLd0QEudXocQckwcpCq5pCuTCuYc24I0bRJDuRe9OuQ= github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= github.com/go-openapi/analysis v0.17.2/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= @@ -500,7 +490,6 @@ github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= @@ -517,7 +506,6 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= @@ -539,7 +527,6 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= @@ -567,7 +554,6 @@ github.com/google/pprof v0.0.0-20201009210932-67992a1a5a35/go.mod h1:kpwsk12EmLe github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -604,8 +590,8 @@ github.com/gosimple/slug v1.4.2 h1:jDmprx3q/9Lfk4FkGZtvzDQ9Cj9eAmsjzeQGp24PeiQ= github.com/gosimple/slug v1.4.2/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 h1:SPdxCL9BChFTlyi0Khv64vdCW4TMna8+sxL7+Chx+Ag= github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4/go.mod h1:nc0XxBzjeGcrMltCDw269LoWF9S8ibhgxolCdA1R8To= -github.com/grafana/grafana-plugin-sdk-go v0.78.0 h1:w43X+b36goTvis4TAW5PzhkGuSJokgm3KKYPOYiENAc= -github.com/grafana/grafana-plugin-sdk-go v0.78.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60= +github.com/grafana/grafana-plugin-sdk-go v0.79.0 h1:7NVEIMlF8G9H7XUdLX9jH/g01FllE1GEBcFvzXZD+Kw= +github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60= github.com/grafana/loki v1.6.0 h1:vHuFgfhW1iRMCm7/LgnZi02Ifvqj389vWhyfNJlHQTQ= github.com/grafana/loki v1.6.0/go.mod h1:X+GvtCzAf2ok/xRLLvGB8kuWP1R+75nXnvjCEnenP0s= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= @@ -716,11 +702,13 @@ github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZ github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= -github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jonboulle/clockwork v0.2.1 h1:S/EaQvW6FpWMYAvYvY+OBDvpaM+izu0oiwo5y0MH7U0= @@ -801,8 +789,6 @@ github.com/linkedin/goavro/v2 v2.9.7 h1:Vd++Rb/RKcmNJjM0HP/JJFMEWa21eUBVKPYlKehO github.com/linkedin/goavro/v2 v2.9.7/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= github.com/lovoo/gcloud-opentracing v0.3.0/go.mod h1:ZFqk2y38kMDDikZPAK7ynTTGuyt17nSPdS3K5e+ZTBY= github.com/lufia/iostat v1.1.0/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= -github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ= -github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magefile/mage v1.9.0 h1:t3AU2wNwehMCW97vuqQLtw6puppWXHO+O2MHo5a50XE= github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= @@ -1052,7 +1038,6 @@ github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= -github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7 h1:J4AOUcOh/t1XbQcJfkEqhzgvMJ2tDxdCVvmHxW5QXao= github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7/go.mod h1:Oz4y6ImuOQZxynhbSXk7btjEfNBtGlj2dcaOvXl2FSM= github.com/russellhaering/goxmldsig v0.0.0-20200902171629-2e1fbc2c5593 h1:wkyiSzH81tsd3tSoznvnXMIJo0cpHjbFuJhs/E9t/B8= github.com/russellhaering/goxmldsig v0.0.0-20200902171629-2e1fbc2c5593/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o= @@ -1085,10 +1070,6 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/shurcooL/vfsgen v0.0.0-20180825020608-02ddb050ef6b/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/shurcooL/vfsgen v0.0.0-20200627165143-92b8a710ab6c/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw= -github.com/siddontang/go-snappy v0.0.0-20140704025258-d8f7bb82a96d/go.mod h1:vq0tzqLRu6TS7Id0wMo2N5QzJoKedVeovOpHjnykSzY= -github.com/siddontang/ledisdb v0.0.0-20190202134119-8ceb77e66a92/go.mod h1:mF1DpOSOUiJRMR+FDqaqu3EBqrybQtrDDszLUZ6oxPg= -github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA= github.com/siebenmann/go-kstat v0.0.0-20160321171754-d34789b79745/go.mod h1:G81aIFAMS9ECrwBYR9YxhlPjWgrItd+Kje78O6+uqm8= github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -1130,11 +1111,9 @@ github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= github.com/thanos-io/thanos v0.8.1-0.20200109203923-552ffa4c1a0d/go.mod h1:usT/TxtJQ7DzinTt+G9kinDQmRS5sxwu0unVKZ9vdcw= @@ -1222,9 +1201,7 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4 h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= @@ -1363,7 +1340,6 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201022231255-08b38378de70 h1:Z6x4N9mAi4oF0TbHweCsH618MO6OI6UFgV0FP5n0wBY= golang.org/x/net v0.0.0-20201022231255-08b38378de70/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -1373,7 +1349,6 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= @@ -1385,7 +1360,6 @@ golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1464,7 +1438,6 @@ golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd h1:WgqgiQvkiZWz7XLhphjt2GI2GcGCTIZs9jqXMWmH+oc= golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1552,13 +1525,11 @@ golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200603131246-cc40288be839/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200725200936-102e7d357031 h1:VtIxiVHWPhnny2ZTi4f9/2diZKqyLaq3FUTuud5+khA= golang.org/x/tools v0.0.0-20200725200936-102e7d357031/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200828161849-5deb26317202/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858 h1:xLt+iB5ksWcZVxqc+g9K41ZHy+6MKWfXCDsjSThnsPA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20200915173823-2db8f0ff891c/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= @@ -1567,7 +1538,6 @@ golang.org/x/tools v0.0.0-20201023150057-2f4fa188d925 h1:iGGR3nU1TUd+WTF17QPTTSh golang.org/x/tools v0.0.0-20201023150057-2f4fa188d925/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1594,7 +1564,6 @@ google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.26.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0 h1:BaiDisFir8O4IJxvAabCGGkQ6yCJegNQqSVoYUNAnbk= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.31.0/go.mod h1:CL+9IBCa2WWU6gRuBWaKqGWLFFwbEUXkfeMkHLQWYWo= @@ -1609,7 +1578,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= @@ -1651,16 +1619,13 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20200603110839-e855014d5736/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200710124503-20a17af7bd0e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200724131911-43cab4749ae7 h1:AWgNCmk2V5HZp9AiCDRBExX/b9I0Ey9F8STHDZlhCC4= google.golang.org/genproto v0.0.0-20200724131911-43cab4749ae7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200831141814-d751682dd103/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d h1:92D1fum1bJLKSdr11OJ+54YeCMCGYIygTA7R/YZxH5M= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200914193844-75d14daec038/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200921151605-7abf4a1a14d5 h1:B9nroC8SSX5GtbVvxPF9tYIVkaCpjhVLOrlAY8ONzm8= google.golang.org/genproto v0.0.0-20200921151605-7abf4a1a14d5/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201022181438-0ff5f38871d5 h1:YejJbGvoWsTXHab4OKNrzk27Dr7s4lPLnewbHue1+gM= @@ -1685,12 +1650,9 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0 h1:M5a8xTlYTxwMn5ZFkwhRabsygDY5G8TYLyQDBxJNAxE= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1 h1:SfXqXS5hkufcdZ/mHtYCh53P2b+92WQq/DZcKLgsFRs= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.32.0 h1:zWTV+LMdc3kaiJMSTOFz2UgSBgx8RNQoTGiZu3fR9S0= google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1 h1:DGeFlSan2f+WEtCERJ4J9GJWk15TxUi8QGagfI87Xyc= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= @@ -1711,7 +1673,6 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gG gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= -gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e/go.mod h1:xsQCaysVCudhrYTfzYWe577fCe7Ceci+6qjO2Rdc0Z4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= @@ -1736,7 +1697,6 @@ gopkg.in/macaron.v1 v1.3.9 h1:Dw+DDRYdXgQyEsPlfAfKz+UA5qVUrH3KPD7JhmZ9MFc= gopkg.in/macaron.v1 v1.3.9/go.mod h1:uMZCFccv9yr5TipIalVOyAyZQuOH3OkmXvgcWwhJuP4= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= -gopkg.in/redis.v2 v2.3.2/go.mod h1:4wl9PJ/CqzeHk3LVq1hNLHH8krm3+AXEgut4jVc++LU= gopkg.in/redis.v5 v5.2.9 h1:MNZYOLPomQzZMfpN3ZtD1uyJ2IDonTTlxYiV/pEApiw= gopkg.in/redis.v5 v5.2.9/go.mod h1:6gtv0/+A4iM08kdRfocWYB3bLX2tebpNtfKlFT6H4mY= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= diff --git a/latest.json b/latest.json index 613d8efff16..8e7c4378a10 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "7.2.2", - "testing": "7.3.0-beta2" + "stable": "7.3.1", + "testing": "7.3.1" } diff --git a/package.json b/package.json index 4a9c4c76b14..ac8d3e317d0 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "@babel/preset-typescript": "7.8.3", "@emotion/core": "10.0.27", "@grafana/api-documenter": "0.9.3", - "@grafana/eslint-config": "2.0.4", "@grafana/api-extractor": "7.10.1", + "@grafana/eslint-config": "2.0.5", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@testing-library/jest-dom": "^5.11.3", "@testing-library/react": "^10.4.8", @@ -94,7 +94,6 @@ "@types/jquery": "3.3.38", "@types/lodash": "4.14.149", "@types/lru-cache": "^5.1.0", - "@types/marked": "1.1.0", "@types/moment-timezone": "0.5.13", "@types/mousetrap": "1.6.3", "@types/node": "13.7.0", @@ -149,7 +148,6 @@ "grunt-angular-templates": "1.1.0", "grunt-cli": "1.3.2", "grunt-contrib-clean": "2.0.0", - "grunt-contrib-compress": "1.6.0", "grunt-contrib-copy": "1.0.0", "grunt-exec": "3.0.0", "grunt-newer": "1.3.0", @@ -175,7 +173,6 @@ "monaco-editor-webpack-plugin": "1.9.0", "mutationobserver-shim": "0.3.3", "ngtemplate-loader": "2.0.1", - "node-sass": "4.13.1", "optimize-css-assets-webpack-plugin": "5.0.3", "postcss-browser-reporter": "0.6.0", "postcss-loader": "3.0.0", @@ -187,6 +184,7 @@ "regexp-replace-loader": "1.0.1", "rimraf": "3.0.1", "rxjs-spy": "^7.5.1", + "sass": "1.27.0", "sass-lint": "1.12.1", "sass-loader": "8.0.2", "sinon": "8.1.1", @@ -220,11 +218,10 @@ "@types/uuid": "8.3.0", "@welldone-software/why-did-you-render": "4.0.6", "abortcontroller-polyfill": "1.4.0", - "angular": "1.6.9", + "angular": "1.8.2", "angular-bindonce": "0.3.1", - "angular-native-dragdrop": "1.2.2", - "angular-route": "1.6.6", - "angular-sanitize": "1.6.6", + "angular-route": "1.8.2", + "angular-sanitize": "1.8.2", "antlr4": "^4.8.0", "baron": "3.0.3", "brace": "0.11.1", @@ -248,7 +245,6 @@ "jsurl": "^0.1.5", "lodash": "4.17.19", "lru-cache": "^5.1.1", - "marked": "1.1.1", "md5": "^2.2.1", "memoize-one": "5.1.1", "moment": "2.24.0", @@ -281,7 +277,7 @@ "regenerator-runtime": "0.13.3", "reselect": "4.0.0", "rst2html": "github:thoward/rst2html#990cb89", - "rxjs": "6.6.2", + "rxjs": "6.6.3", "search-query-parser": "1.5.4", "slate": "0.47.8", "slate-plain-serializer": "0.7.10", @@ -291,7 +287,8 @@ "tinycolor2": "1.4.1", "tti-polyfill": "0.2.2", "uuid": "8.3.0", - "whatwg-fetch": "3.1.0" + "whatwg-fetch": "3.1.0", + "visjs-network": "4.25.0" }, "resolutions": { "caniuse-db": "1.0.30000772" @@ -310,7 +307,7 @@ "puppeteer": "node_modules/puppeteer-core" }, "engines": { - "node": ">=12 <13" + "node": ">= 12" }, "volta": { "node": "12.19.0" diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 9384fb11ed1..a72437fc971 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -25,8 +25,10 @@ "@braintree/sanitize-url": "4.0.0", "@types/d3-interpolate": "^1.3.1", "apache-arrow": "0.16.0", + "eventemitter3": "4.0.7", "lodash": "4.17.19", - "rxjs": "6.6.2", + "rxjs": "6.6.3", + "marked": "1.2.2", "xss": "1.0.6" }, "devDependencies": { @@ -39,6 +41,7 @@ "@types/jquery": "3.3.38", "@types/lodash": "4.14.123", "@types/node": "10.14.1", + "@types/marked": "1.1.0", "@types/papaparse": "5.2.0", "@types/react": "16.8.16", "@types/rollup-plugin-visualizer": "2.6.0", diff --git a/packages/grafana-data/src/events/EventBus.test.ts b/packages/grafana-data/src/events/EventBus.test.ts new file mode 100644 index 00000000000..458a74fa392 --- /dev/null +++ b/packages/grafana-data/src/events/EventBus.test.ts @@ -0,0 +1,173 @@ +import { EventBusSrv } from './EventBus'; +import { BusEventWithPayload } from './types'; +import { eventFactory } from './eventFactory'; + +interface LoginEventPayload { + logins: number; +} + +interface HelloEventPayload { + hellos: number; +} + +class LoginEvent extends BusEventWithPayload { + static type = 'login-event'; +} + +class HelloEvent extends BusEventWithPayload { + static type = 'hello-event'; +} + +type LegacyEventPayload = [string, string]; + +export const legacyEvent = eventFactory('legacy-event'); + +class AlertSuccessEvent extends BusEventWithPayload { + static type = 'legacy-event'; +} + +describe('EventBus', () => { + it('Can create events', () => { + expect(new LoginEvent({ logins: 1 }).type).toBe('login-event'); + }); + + it('Can subscribe specific event', () => { + const bus = new EventBusSrv(); + const events: LoginEvent[] = []; + + bus.subscribe(LoginEvent, event => { + events.push(event); + }); + + bus.publish(new LoginEvent({ logins: 10 })); + bus.publish(new HelloEvent({ hellos: 10 })); + + expect(events[0].payload.logins).toBe(10); + expect(events.length).toBe(1); + }); + + describe('Legacy emitter behavior', () => { + it('Supports legacy events', () => { + const bus = new EventBusSrv(); + const events: any = []; + const handler = (event: LegacyEventPayload) => { + events.push(event); + }; + + bus.on(legacyEvent, handler); + bus.emit(legacyEvent, ['hello', 'hello2']); + + bus.off(legacyEvent, handler); + bus.emit(legacyEvent, ['hello', 'hello2']); + + expect(events.length).toEqual(1); + expect(events[0]).toEqual(['hello', 'hello2']); + }); + + it('Interoperability with legacy events', () => { + const bus = new EventBusSrv(); + const legacyEvents: any = []; + const newEvents: any = []; + + bus.on(legacyEvent, event => { + legacyEvents.push(event); + }); + + bus.subscribe(AlertSuccessEvent, event => { + newEvents.push(event); + }); + + bus.emit(legacyEvent, ['legacy', 'params']); + bus.publish(new AlertSuccessEvent(['new', 'event'])); + + expect(legacyEvents).toEqual([ + ['legacy', 'params'], + ['new', 'event'], + ]); + + expect(newEvents).toEqual([ + { + type: 'legacy-event', + payload: ['legacy', 'params'], + }, + { + type: 'legacy-event', + payload: ['new', 'event'], + }, + ]); + }); + + it('should notfiy subscribers', () => { + const bus = new EventBusSrv(); + let sub1Called = false; + let sub2Called = false; + + bus.on(legacyEvent, () => { + sub1Called = true; + }); + bus.on(legacyEvent, () => { + sub2Called = true; + }); + + bus.emit(legacyEvent, null); + + expect(sub1Called).toBe(true); + expect(sub2Called).toBe(true); + }); + + it('when subscribing twice', () => { + const bus = new EventBusSrv(); + let sub1Called = 0; + + function handler() { + sub1Called += 1; + } + + bus.on(legacyEvent, handler); + bus.on(legacyEvent, handler); + + bus.emit(legacyEvent, null); + + expect(sub1Called).toBe(2); + }); + + it('should handle errors', () => { + const bus = new EventBusSrv(); + let sub1Called = 0; + let sub2Called = 0; + + bus.on(legacyEvent, () => { + sub1Called++; + throw { message: 'hello' }; + }); + + bus.on(legacyEvent, () => { + sub2Called++; + }); + + try { + bus.emit(legacyEvent, null); + } catch (_) {} + try { + bus.emit(legacyEvent, null); + } catch (_) {} + + expect(sub1Called).toBe(2); + expect(sub2Called).toBe(0); + }); + + it('removeAllListeners should unsubscribe to all', () => { + const bus = new EventBusSrv(); + const events: LoginEvent[] = []; + + bus.subscribe(LoginEvent, event => { + events.push(event); + }); + + bus.removeAllListeners(); + bus.publish(new LoginEvent({ logins: 10 })); + + expect(events.length).toBe(0); + }); + }); +}); diff --git a/packages/grafana-data/src/events/EventBus.ts b/packages/grafana-data/src/events/EventBus.ts new file mode 100644 index 00000000000..a279788d035 --- /dev/null +++ b/packages/grafana-data/src/events/EventBus.ts @@ -0,0 +1,85 @@ +import EventEmitter from 'eventemitter3'; +import { Unsubscribable, Observable } from 'rxjs'; +import { AppEvent } from './types'; +import { EventBus, LegacyEmitter, BusEventHandler, BusEventType, LegacyEventHandler, BusEvent } from './types'; + +/** + * @alpha + */ +export class EventBusSrv implements EventBus, LegacyEmitter { + private emitter: EventEmitter; + + constructor() { + this.emitter = new EventEmitter(); + } + + publish(event: T): void { + this.emitter.emit(event.type, event); + } + + subscribe(typeFilter: BusEventType, handler: BusEventHandler): Unsubscribable { + return this.getStream(typeFilter).subscribe({ next: handler }); + } + + getStream(eventType: BusEventType): Observable { + return new Observable(observer => { + const handler = (event: T) => { + observer.next(event); + }; + + this.emitter.on(eventType.type, handler); + + return () => { + this.emitter.off(eventType.type, handler); + }; + }); + } + + /** + * Legacy functions + */ + emit(event: AppEvent | string, payload?: T | any): void { + // console.log(`Deprecated emitter function used (emit), use $emit`); + + if (typeof event === 'string') { + this.emitter.emit(event, { type: event, payload }); + } else { + this.emitter.emit(event.name, { type: event.name, payload }); + } + } + + on(event: AppEvent | string, handler: LegacyEventHandler, scope?: any) { + // console.log(`Deprecated emitter function used (on), use $on`); + + // need this wrapper to make old events compatible with old handlers + handler.wrapper = (emittedEvent: BusEvent) => { + handler(emittedEvent.payload); + }; + + if (typeof event === 'string') { + this.emitter.on(event, handler.wrapper); + } else { + this.emitter.on(event.name, handler.wrapper); + } + + if (scope) { + const unbind = scope.$on('$destroy', () => { + this.off(event, handler); + unbind(); + }); + } + } + + off(event: AppEvent | string, handler: LegacyEventHandler) { + if (typeof event === 'string') { + this.emitter.off(event, handler.wrapper); + return; + } + + this.emitter.off(event.name, handler.wrapper); + } + + removeAllListeners() { + this.emitter.removeAllListeners(); + } +} diff --git a/packages/grafana-data/src/types/utils.ts b/packages/grafana-data/src/events/eventFactory.ts similarity index 65% rename from packages/grafana-data/src/types/utils.ts rename to packages/grafana-data/src/events/eventFactory.ts index 355c502fc55..2dd1b92091f 100644 --- a/packages/grafana-data/src/types/utils.ts +++ b/packages/grafana-data/src/events/eventFactory.ts @@ -1,9 +1,7 @@ -import { AppEvent } from './appEvents'; - -export type Omit = Pick>; -export type Subtract = Omit; +import { AppEvent } from './types'; const typeList: Set = new Set(); + export function eventFactory(name: string): AppEvent { if (typeList.has(name)) { throw new Error(`There is already an event defined with type '${name}'`); diff --git a/packages/grafana-data/src/events/index.ts b/packages/grafana-data/src/events/index.ts new file mode 100644 index 00000000000..69c33f4300d --- /dev/null +++ b/packages/grafana-data/src/events/index.ts @@ -0,0 +1,3 @@ +export * from './eventFactory'; +export * from './types'; +export * from './EventBus'; diff --git a/packages/grafana-data/src/events/types.ts b/packages/grafana-data/src/events/types.ts new file mode 100644 index 00000000000..63b4738623a --- /dev/null +++ b/packages/grafana-data/src/events/types.ts @@ -0,0 +1,115 @@ +import { Unsubscribable, Observable } from 'rxjs'; + +/** + * @alpha + * internal interface + */ +export interface BusEvent { + readonly type: string; + readonly payload?: any; +} + +/** + * @alpha + * Base event type + */ +export abstract class BusEventBase implements BusEvent { + readonly type: string; + readonly payload?: any; + + constructor() { + //@ts-ignore + this.type = this.__proto__.constructor.type; + } +} + +/** + * @alpha + * Base event type with payload + */ +export abstract class BusEventWithPayload extends BusEventBase { + readonly payload: T; + + constructor(payload: T) { + super(); + this.payload = payload; + } +} + +/* + * Interface for an event type constructor + */ +export interface BusEventType { + type: string; + new (...args: any[]): T; +} + +/** + * @alpha + * Event callback/handler type + */ +export interface BusEventHandler { + (event: T): void; +} + +/** + * @alpha + * Main minimal interface + */ +export interface EventBus { + /** + * Publish single vent + */ + publish(event: T): void; + + /** + * Subscribe to single event + */ + subscribe(eventType: BusEventType, handler: BusEventHandler): Unsubscribable; + + /** + * Get observable of events + */ + getStream(eventType: BusEventType): Observable; + + /** + * Remove all event subscriptions + */ + removeAllListeners(): void; +} + +/** + * @public + * @deprecated event type + */ +export interface AppEvent { + readonly name: string; + payload?: T; +} + +/** @public */ +export interface LegacyEmitter { + /** + * @deprecated use $emit + */ + emit(event: AppEvent | string, payload?: T): void; + + /** + * @deprecated use $on + */ + on(event: AppEvent | string, handler: LegacyEventHandler, scope?: any): void; + + /** + * @deprecated use $on + */ + off(event: AppEvent | string, handler: (payload?: T | any) => void): void; +} + +/** @public */ +export interface LegacyEventHandler { + (payload: T): void; + wrapper?: (event: BusEvent) => void; +} + +/** @alpha */ +export interface EventBusExtended extends EventBus, LegacyEmitter {} diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts index 9d7c4b5f998..7876087832b 100644 --- a/packages/grafana-data/src/field/displayProcessor.test.ts +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -1,4 +1,4 @@ -import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; +import { getDecimalsForValue, getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; import { DisplayProcessor, DisplayValue } from '../types/displayValue'; import { MappingType, ValueMapping } from '../types/valueMapping'; import { FieldConfig, FieldType, ThresholdsMode } from '../types'; @@ -329,3 +329,21 @@ describe('getRawDisplayProcessor', () => { expect(result).toEqual({ text: expected, numeric: null }); }); }); + +describe('getDecimalsForValue', () => { + it.each` + value | expected + ${0} | ${0} + ${13.37} | ${0} + ${-13.37} | ${0} + ${12679.3712345811212} | ${0} + ${-12679.3712345811212} | ${0} + ${0.3712345} | ${2} + ${-0.37123458} | ${2} + ${-0.04671994403853774} | ${3} + ${0.04671994403853774} | ${3} + `('should return correct suggested decimal count', ({ value, expected }) => { + const result = getDecimalsForValue(value); + expect(result.decimals).toEqual(expected); + }); +}); diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 66705096ebf..e7c5c45cf54 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -143,7 +143,7 @@ export function getDecimalsForValue(value: number, decimalOverride?: DecimalCoun return { decimals: decimalOverride, scaledDecimals: null }; } - let dec = -Math.floor(Math.log(value) / Math.LN10) + 1; + let dec = -Math.floor(Math.log(Math.abs(value)) / Math.LN10) + 1; const magn = Math.pow(10, -dec); const norm = value / magn; // norm is between 1.0 and 10.0 let size; diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts index 0fb824b0a6a..ba7db8eae4e 100644 --- a/packages/grafana-data/src/field/fieldColor.ts +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -24,6 +24,7 @@ export const fieldColorModeRegistry = new Registry(() => { { id: FieldColorModeId.Thresholds, name: 'From thresholds', + isByValue: true, description: 'Derive colors from thresholds', getCalculator: (_field, theme) => { return (_value, _percent, threshold) => { @@ -66,6 +67,13 @@ export const fieldColorModeRegistry = new Registry(() => { isByValue: true, colors: ['green', 'yellow', 'red'], }), + new FieldColorSchemeMode({ + id: 'continuous-RdYlGr', + name: 'Red-Yellow-Green', + isContinuous: true, + isByValue: true, + colors: ['red', 'yellow', 'green'], + }), new FieldColorSchemeMode({ id: 'continuous-BlYlRd', name: 'Blue-Yellow-Red', diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index f7e26bfadbc..44084927f6f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -11,14 +11,14 @@ import { MutableDataFrame, toDataFrame } from '../dataframe'; import { DataFrame, Field, + FieldColorModeId, FieldConfig, FieldConfigPropertyItem, FieldConfigSource, FieldType, InterpolateFunction, - ThresholdsMode, - FieldColorModeId, ScopedVars, + ThresholdsMode, } from '../types'; import { locationUtil, Registry } from '../utils'; import { mockStandardProperties } from '../utils/tests/mockStandardProperties'; @@ -87,6 +87,21 @@ describe('Global MinMax', () => { expect(minmax.min).toEqual(-20); expect(minmax.max).toEqual(1234); }); + + describe('when value is null', () => { + it('then global min max should be null', () => { + const frame = toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1] }, + { name: 'Value', type: FieldType.number, values: [null] }, + ], + }); + const { min, max } = findNumericFieldMinMax([frame]); + + expect(min).toBeNull(); + expect(max).toBeNull(); + }); + }); }); describe('applyFieldOverrides', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index fad547fc982..fba14e335b2 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -55,11 +55,23 @@ export function findNumericFieldMinMax(data: DataFrame[]): GlobalMinMax { for (const field of frame.fields) { if (field.type === FieldType.number) { const stats = reduceField({ field, reducers }); - if (stats[ReducerID.min] < min) { - min = stats[ReducerID.min]; + const statsMin = stats[ReducerID.min]; + const statsMax = stats[ReducerID.max]; + + if (!statsMin) { + min = statsMin; } - if (stats[ReducerID.max] > max) { - max = stats[ReducerID.max]; + + if (!statsMax) { + max = statsMax; + } + + if (statsMin && statsMin < min) { + min = statsMin; + } + + if (statsMax && statsMax > max) { + max = statsMax; } } } @@ -294,7 +306,6 @@ const processFieldConfigValue = ( const currentConfig = get(destination, fieldConfigProperty.path); if (currentConfig === null || currentConfig === undefined) { const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id); - // console.log(item); if (!item) { return; } diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts index a50da40520e..feff3993a09 100644 --- a/packages/grafana-data/src/field/overrides/processors.ts +++ b/packages/grafana-data/src/field/overrides/processors.ts @@ -24,6 +24,12 @@ export const numberOverrideProcessor = ( return parseFloat(value); }; +export interface SliderFieldConfigSettings { + min: number; + max: number; + step?: number; +} + export interface DataLinksFieldConfigSettings {} export const dataLinksOverrideProcessor = ( @@ -114,8 +120,15 @@ export const booleanOverrideProcessor = ( return value; // !!!! likely not !!!! }; -export interface ColorFieldConfigSettings { - allowUndefined?: boolean; - textWhenUndefined?: string; // Pick Color - disableNamedColors?: boolean; +export interface FieldColorConfigSettings { + /** + * When switching to a visualization that does not support by value coloring then Grafana will + * switch to a by series palette based color mode + */ + byValueSupport?: boolean; + /** + * When switching to a visualization that has this set to true then Grafana will change color mode + * to from thresholds if it was set to a by series palette + */ + preferThresholdsMode?: boolean; } diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 43a488d7095..741f9584f22 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -12,4 +12,5 @@ export * from './datetime'; export * from './text'; export * from './valueFormats'; export * from './field'; +export * from './events'; export { PanelPlugin } from './panel/PanelPlugin'; diff --git a/packages/grafana-data/src/panel/PanelPlugin.test.tsx b/packages/grafana-data/src/panel/PanelPlugin.test.tsx index 10e76878236..e0a96b91785 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.test.tsx +++ b/packages/grafana-data/src/panel/PanelPlugin.test.tsx @@ -9,11 +9,11 @@ describe('PanelPlugin', () => { standardFieldConfigEditorRegistry.setInit(() => { return [ { - id: 'min', + id: FieldConfigProperty.Min, path: 'min', }, { - id: 'max', + id: FieldConfigProperty.Max, path: 'max', }, ] as any; @@ -210,15 +210,15 @@ describe('PanelPlugin', () => { expect(panel.fieldConfigRegistry.list()).toHaveLength(2); }); - test('selected standard config', () => { + test('disabling standard config properties', () => { const panel = new PanelPlugin(() => { return
Panel
; }); panel.useFieldConfig({ - standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max], + disableStandardOptions: [FieldConfigProperty.Min], }); - expect(panel.fieldConfigRegistry.list()).toHaveLength(2); + expect(panel.fieldConfigRegistry.list()).toHaveLength(1); }); describe('default values', () => { @@ -228,10 +228,9 @@ describe('PanelPlugin', () => { }); panel.useFieldConfig({ - standardOptions: [FieldConfigProperty.Max, FieldConfigProperty.Min], - standardOptionsDefaults: { - [FieldConfigProperty.Max]: 20, - [FieldConfigProperty.Min]: 10, + standardOptions: { + [FieldConfigProperty.Max]: { defaultValue: 20 }, + [FieldConfigProperty.Min]: { defaultValue: 10 }, }, }); @@ -247,17 +246,16 @@ describe('PanelPlugin', () => { }); }); - it('should ignore defaults that are not specified as available properties', () => { + it('should disable properties independently from the default values settings', () => { const panel = new PanelPlugin(() => { return
Panel
; }); panel.useFieldConfig({ - standardOptions: [FieldConfigProperty.Max], - standardOptionsDefaults: { - [FieldConfigProperty.Max]: 20, - [FieldConfigProperty.Min]: 10, + standardOptions: { + [FieldConfigProperty.Max]: { defaultValue: 20 }, }, + disableStandardOptions: [FieldConfigProperty.Min], }); expect(panel.fieldConfigRegistry.list()).toHaveLength(1); diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index fd07b8f52c7..e86a9fec18f 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -15,33 +15,38 @@ import set from 'lodash/set'; import { deprecationWarning } from '../utils'; import { FieldConfigOptionsRegistry, standardFieldConfigEditorRegistry } from '../field'; +type StandardOptionConfig = { + defaultValue?: any; + settings?: any; +}; + export interface SetFieldConfigOptionsArgs { /** - * Array of standard field config properties + * Configuration object of the standard field config properites * * @example * ```typescript * { - * standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max, FieldConfigProperty.Unit] - * } - * ``` - */ - standardOptions?: FieldConfigProperty[]; - - /** - * Object specifying standard option properties default values - * - * @example - * ```typescript - * { - * standardOptionsDefaults: { - * [FieldConfigProperty.Min]: 20, - * [FieldConfigProperty.Max]: 100 + * standardOptions: { + * [FieldConfigProperty.Decimals]: { + * defaultValue: 3 + * } * } * } * ``` */ - standardOptionsDefaults?: Partial>; + standardOptions?: Partial>; + + /** + * Array of standard field config properties that should not be available in the panel + * @example + * ```typescript + * { + * disableStandardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max, FieldConfigProperty.Unit] + * } + * ``` + */ + disableStandardOptions?: FieldConfigProperty[]; /** * Function that allows custom field config properties definition. @@ -305,13 +310,13 @@ export class PanelPlugin) { + useFieldConfig(config: SetFieldConfigOptionsArgs = {}) { // builder is applied lazily when custom field configs are accessed this._initConfigRegistry = () => { const registry = new FieldConfigOptionsRegistry(); // Add custom options - if (config && config.useCustomConfig) { + if (config.useCustomConfig) { const builder = new FieldConfigEditorBuilder(); config.useCustomConfig(builder); @@ -326,20 +331,32 @@ export class PanelPlugin -1; + if (isDisabled) { + continue; + } } - } else { - for (const fieldConfigProp of standardFieldConfigEditorRegistry.list()) { - registry.register(fieldConfigProp); + if (config.standardOptions) { + const customDefault: any = config.standardOptions[fieldConfigProp.id as FieldConfigProperty]?.defaultValue; + const customSettings: any = config.standardOptions[fieldConfigProp.id as FieldConfigProperty]?.settings; + if (customDefault) { + fieldConfigProp = { + ...fieldConfigProp, + defaultValue: customDefault, + }; + } + + if (customSettings) { + fieldConfigProp = { + ...fieldConfigProp, + settings: fieldConfigProp.settings ? { ...fieldConfigProp.settings, ...customSettings } : customSettings, + }; + } } + + registry.register(fieldConfigProp); } return registry; diff --git a/packages/grafana-data/src/text/markdown.test.ts b/packages/grafana-data/src/text/markdown.test.ts index 3df231c77d4..ed51f2c28a2 100644 --- a/packages/grafana-data/src/text/markdown.test.ts +++ b/packages/grafana-data/src/text/markdown.test.ts @@ -5,4 +5,9 @@ describe('Markdown wrapper', () => { const str = renderMarkdown(undefined); expect(str).toBe(''); }); + + it('should sanitize by default', () => { + const str = renderMarkdown(''); + expect(str).toBe('<script>alert()</script>'); + }); }); diff --git a/packages/grafana-data/src/text/markdown.ts b/packages/grafana-data/src/text/markdown.ts index f3c8d2f8981..4c9b369e291 100644 --- a/packages/grafana-data/src/text/markdown.ts +++ b/packages/grafana-data/src/text/markdown.ts @@ -1,19 +1,28 @@ -import marked, { MarkedOptions } from 'marked'; +import marked from 'marked'; +import { sanitize } from './sanitize'; -const defaultMarkedOptions: MarkedOptions = { - renderer: new marked.Renderer(), - pedantic: false, - gfm: true, - sanitize: true, - smartLists: true, - smartypants: false, - xhtml: false, -}; +let hasInitialized = false; -export function setMarkdownOptions(optionsOverride?: MarkedOptions) { - marked.setOptions({ ...defaultMarkedOptions, ...optionsOverride }); +export interface RenderMarkdownOptions { + noSanitize?: boolean; } -export function renderMarkdown(str?: string): string { - return marked(str || ''); +export function renderMarkdown(str?: string, options?: RenderMarkdownOptions): string { + if (!hasInitialized) { + marked.setOptions({ + pedantic: false, + gfm: true, + smartLists: true, + smartypants: false, + xhtml: false, + }); + hasInitialized = true; + } + + const html = marked(str || ''); + if (options?.noSanitize) { + return html; + } + + return sanitize(html); } diff --git a/packages/grafana-data/src/transformations/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts index 1ab43c25693..7d715ebacf8 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.test.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -130,12 +130,13 @@ describe('Stats Calculators', () => { const stats = reduceField({ field: createField('x', info[0].data), - reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull], // uses standard path + reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull, ReducerID.diffperc], // uses standard path }); expect(stats[ReducerID.first]).toEqual(null); expect(stats[ReducerID.last]).toEqual(null); expect(stats[ReducerID.firstNotNull]).toEqual(200); expect(stats[ReducerID.lastNotNull]).toEqual(200); + expect(stats[ReducerID.diffperc]).toEqual(0); const reducers = [ReducerID.lastNotNull, ReducerID.firstNotNull]; for (const input of info) { diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index 3adc37a7411..604474f8691 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -15,6 +15,7 @@ export enum ReducerID { count = 'count', range = 'range', diff = 'diff', + diffperc = 'diffperc', delta = 'delta', step = 'step', @@ -223,6 +224,12 @@ export const fieldReducers = new Registry(() => [ standard: false, reduce: calculateDistinctCount, }, + { + id: ReducerID.diffperc, + name: 'Difference percent', + description: 'Percentage difference between first and last values', + standard: true, + }, ]); export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { @@ -244,6 +251,7 @@ export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: diff: null, delta: 0, step: Number.MAX_VALUE, + diffperc: 0, // Just used for calculations -- not exposed as a stat previousDeltaUp: true, @@ -353,6 +361,9 @@ export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: calcs.diff = calcs.lastNotNull - calcs.firstNotNull; } + if (isNumber(calcs.firstNotNull) && isNumber(calcs.diff)) { + calcs.diffperc = calcs.diff / calcs.firstNotNull; + } return calcs; } diff --git a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts index b636a0f2029..291239469c3 100644 --- a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts +++ b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts @@ -1,6 +1,11 @@ import { ComponentType } from 'react'; import { RegistryItem, Registry } from '../utils/Registry'; -import { NumberFieldConfigSettings, SelectFieldConfigSettings, StringFieldConfigSettings } from '../field'; +import { + NumberFieldConfigSettings, + SliderFieldConfigSettings, + SelectFieldConfigSettings, + StringFieldConfigSettings, +} from '../field'; /** * Option editor registry item @@ -71,6 +76,10 @@ export interface OptionsUIRegistryBuilderAPI< config: OptionEditorConfig ): this; + addSliderInput?( + config: OptionEditorConfig + ): this; + addTextInput?( config: OptionEditorConfig ): this; diff --git a/packages/grafana-data/src/types/appEvents.ts b/packages/grafana-data/src/types/appEvents.ts deleted file mode 100644 index 5cba61f42e7..00000000000 --- a/packages/grafana-data/src/types/appEvents.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { eventFactory } from './utils'; - -export interface AppEvent { - readonly name: string; - payload?: T; -} - -export type AlertPayload = [string, string?]; -export type AlertErrorPayload = [string, (string | Error)?]; - -export const alertSuccess = eventFactory('alert-success'); -export const alertWarning = eventFactory('alert-warning'); -export const alertError = eventFactory('alert-error'); diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index c41a35ae48a..d2cf27cc653 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -55,6 +55,8 @@ export interface LicenseInfo { expiry: number; licenseUrl: string; stateInfo: string; + hasValidLicense: boolean; + edition: string; } /** diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index be0d653f52d..0bdd1c1d129 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -6,6 +6,7 @@ export type KeyValue = Record; /** * Represent panel data loading state. + * @public */ export enum LoadingState { NotStarted = 'NotStarted', @@ -21,6 +22,9 @@ export enum DataTopic { export type PreferredVisualisationType = 'graph' | 'table' | 'logs' | 'trace'; +/** + * @public + */ export interface QueryResultMeta { /** DatasSource Specific Values */ custom?: Record; @@ -67,6 +71,7 @@ export interface QueryResultMetaStat extends FieldConfig { /** * QueryResultMetaNotice is a structure that provides user notices for query result data + * @public */ export interface QueryResultMetaNotice { /** @@ -91,6 +96,9 @@ export interface QueryResultMetaNotice { inspect?: 'meta' | 'error' | 'data' | 'stats'; } +/** + * @public + */ export interface QueryResultBase { /** * Matches the query target refId diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 8abd53ca26e..e38a5108a8c 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -114,6 +114,7 @@ export interface DataSourcePluginMeta extends PluginMet queryOptions?: PluginMetaQueryOptions; sort?: number; streaming?: boolean; + unlicensed?: boolean; } interface PluginMetaQueryOptions { @@ -499,6 +500,7 @@ export interface QueryHint { export interface MetricFindValue { text: string; + value?: string | number; expandable?: boolean; } @@ -599,6 +601,6 @@ export abstract class LanguageProvider { * 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. */ - abstract start: () => Promise; + abstract start: () => Promise>>; startTask?: Promise; } diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 75699c2d485..7a1ac746ed2 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -7,7 +7,6 @@ export * from './navModel'; export * from './select'; export * from './time'; export * from './thresholds'; -export * from './utils'; export * from './valueMapping'; export * from './displayValue'; export * from './graph'; @@ -27,12 +26,7 @@ export * from './orgs'; export * from './flot'; export * from './trace'; export * from './explore'; +export * from './legacyEvents'; export * from './live'; -import * as AppEvents from './appEvents'; -import { AppEvent } from './appEvents'; -export { AppEvent, AppEvents }; - -import * as PanelEvents from './panelEvents'; -export { PanelEvents }; export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config'; diff --git a/packages/grafana-data/src/types/legacyEvents.ts b/packages/grafana-data/src/types/legacyEvents.ts new file mode 100644 index 00000000000..9a2d97f35ca --- /dev/null +++ b/packages/grafana-data/src/types/legacyEvents.ts @@ -0,0 +1,47 @@ +import { DataQueryError, DataQueryResponseData } from './datasource'; +import { AngularPanelMenuItem } from './panel'; +import { DataFrame } from './dataFrame'; +import { eventFactory } from '../events/eventFactory'; +import { BusEventBase, BusEventWithPayload } from '../events/types'; + +export type AlertPayload = [string, string?]; +export type AlertErrorPayload = [string, (string | Error)?]; + +export const AppEvents = { + alertSuccess: eventFactory('alert-success'), + alertWarning: eventFactory('alert-warning'), + alertError: eventFactory('alert-error'), +}; + +export const PanelEvents = { + refresh: eventFactory('refresh'), + componentDidMount: eventFactory('component-did-mount'), + dataReceived: eventFactory('data-received'), + dataError: eventFactory('data-error'), + dataFramesReceived: eventFactory('data-frames-received'), + dataSnapshotLoad: eventFactory('data-snapshot-load'), + editModeInitialized: eventFactory('init-edit-mode'), + initPanelActions: eventFactory('init-panel-actions'), + panelInitialized: eventFactory('panel-initialized'), + panelSizeChanged: eventFactory('panel-size-changed'), + panelTeardown: eventFactory('panel-teardown'), + render: eventFactory('render'), +}; + +/** @public */ +export interface LegacyGraphHoverEventPayload { + pos: any; + panel: { + id: number; + }; +} + +/** @alpha */ +export class LegacyGraphHoverEvent extends BusEventWithPayload { + static type = 'graph-hover'; +} + +/** @alpha */ +export class LegacyGraphHoverClearEvent extends BusEventBase { + static type = 'graph-hover-clear'; +} diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index df0e3ea045b..a666b841641 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -4,6 +4,7 @@ import { ScopedVars } from './ScopedVars'; import { LoadingState } from './data'; import { DataFrame } from './dataFrame'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; +import { EventBus } from '../events'; import { FieldConfigSource } from './fieldOverrides'; import { Registry } from '../utils'; import { StandardEditorProps } from '../field'; @@ -77,6 +78,9 @@ export interface PanelProps { /** Panel title */ title: string; + /** EventBus */ + eventBus: EventBus; + /** Panel options change handler */ onOptionsChange: (options: T) => void; diff --git a/packages/grafana-data/src/types/panelEvents.ts b/packages/grafana-data/src/types/panelEvents.ts deleted file mode 100644 index 1c17c49f595..00000000000 --- a/packages/grafana-data/src/types/panelEvents.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { eventFactory } from './utils'; -import { DataQueryError, DataQueryResponseData } from './datasource'; -import { AngularPanelMenuItem } from './panel'; -import { DataFrame } from './dataFrame'; - -/** Payloads */ -export interface PanelChangeViewPayload { - fullscreen?: boolean; - edit?: boolean; - panelId?: number; - toggle?: boolean; -} - -/** Events */ -export const refresh = eventFactory('refresh'); -export const componentDidMount = eventFactory('component-did-mount'); -export const dataError = eventFactory('data-error'); -export const dataReceived = eventFactory('data-received'); -export const dataFramesReceived = eventFactory('data-frames-received'); -export const dataSnapshotLoad = eventFactory('data-snapshot-load'); -export const editModeInitialized = eventFactory('init-edit-mode'); -export const initPanelActions = eventFactory('init-panel-actions'); -export const panelChangeView = eventFactory('panel-change-view'); -export const panelInitialized = eventFactory('panel-initialized'); -export const panelSizeChanged = eventFactory('panel-size-changed'); -export const panelTeardown = eventFactory('panel-teardown'); -export const render = eventFactory('render'); -export const viewModeChanged = eventFactory('view-mode-changed'); diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 0aa96f68ed8..51a676e1970 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -2,12 +2,14 @@ import { ComponentClass } from 'react'; import { KeyValue } from './data'; import { LiveChannelSupport } from './live'; +/** Describes plugins life cycle status */ export enum PluginState { - alpha = 'alpha', // Only included it `enable_alpha` is true + alpha = 'alpha', // Only included if `enable_alpha` config option is true beta = 'beta', // Will show a warning banner deprecated = 'deprecated', // Will continue to work -- but not show up in the options to add } +/** Describes {@link https://grafana.com/docs/grafana/latest/plugins | type of plugin} */ export enum PluginType { panel = 'panel', datasource = 'datasource', @@ -15,12 +17,26 @@ export enum PluginType { renderer = 'renderer', } +/** Describes status of {@link https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/ | plugin signature} */ export enum PluginSignatureStatus { internal = 'internal', // core plugin, no signature valid = 'valid', // signed and accurate MANIFEST invalid = 'invalid', // invalid signature modified = 'modified', // valid signature, but content mismatch - unsigned = 'unsigned', // no MANIFEST file + missing = 'missing', // missing signature file +} + +/** Describes error code returned from Grafana plugins API call */ +export enum PluginErrorCode { + missingSignature = 'signatureMissing', + invalidSignature = 'signatureInvalid', + modifiedSignature = 'signatureModified', +} + +/** Describes error returned from Grafana plugins API call */ +export interface PluginError { + errorCode: PluginErrorCode; + pluginId: string; } export interface PluginMeta { diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index 98138e6b886..d708dd0e29c 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -12,12 +12,11 @@ import { StandardEditorProps, StringFieldConfigSettings, NumberFieldConfigSettings, - ColorFieldConfigSettings, + SliderFieldConfigSettings, identityOverrideProcessor, UnitFieldConfigSettings, unitOverrideProcessor, } from '../field'; -import { FieldColor } from '../types'; /** * Fluent API for declarative creation of field config option editors @@ -39,6 +38,18 @@ export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder }); } + addSliderInput(config: FieldConfigEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + override: standardEditorsRegistry.get('slider').editor as any, + editor: standardEditorsRegistry.get('slider').editor as any, + process: numberOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : field => field.type === FieldType.number, + settings: config.settings || {}, + }); + } + addTextInput(config: FieldConfigEditorConfig) { return this.addCustomEditor({ ...config, @@ -91,9 +102,7 @@ export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder }); } - addColorPicker( - config: FieldConfigEditorConfig - ) { + addColorPicker(config: FieldConfigEditorConfig) { return this.addCustomEditor({ ...config, id: config.path, @@ -136,6 +145,14 @@ export class PanelOptionsEditorBuilder extends OptionsUIRegistryBuilde }); } + addSliderInput(config: PanelOptionsEditorConfig) { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('slider').editor as any, + }); + } + addTextInput(config: PanelOptionsEditorConfig) { return this.addCustomEditor({ ...config, @@ -182,9 +199,7 @@ export class PanelOptionsEditorBuilder extends OptionsUIRegistryBuilde }); } - addColorPicker( - config: PanelOptionsEditorConfig - ): this { + addColorPicker(config: PanelOptionsEditorConfig): this { return this.addCustomEditor({ ...config, id: config.path, diff --git a/packages/grafana-data/src/valueFormats/categories.ts b/packages/grafana-data/src/valueFormats/categories.ts index 645d41db15b..d8e38f5338c 100644 --- a/packages/grafana-data/src/valueFormats/categories.ts +++ b/packages/grafana-data/src/valueFormats/categories.ts @@ -5,6 +5,7 @@ import { dateTimeAsUS, dateTimeAsUSNoDateIfToday, getDateTimeAsLocalFormat, + getDateTimeAsLocalFormatNoDateIfToday, dateTimeFromNow, toClockMilliseconds, toClockSeconds, @@ -127,6 +128,7 @@ export const getCategories = (): ValueFormatCategory[] => [ { name: 'Indian Rupee (₹)', id: 'currencyINR', fn: currency('₹') }, { name: 'South Korean Won (₩)', id: 'currencyKRW', fn: currency('₩') }, { name: 'Indonesian Rupiah (Rp)', id: 'currencyIDR', fn: currency('Rp') }, + { name: 'Philippine Peso (PHP)', id: 'currencyPHP', fn: currency('PHP') }, ], }, { @@ -186,6 +188,11 @@ export const getCategories = (): ValueFormatCategory[] => [ { name: 'Datetime US', id: 'dateTimeAsUS', fn: dateTimeAsUS }, { name: 'Datetime US (No date if today)', id: 'dateTimeAsUSNoDateIfToday', fn: dateTimeAsUSNoDateIfToday }, { name: 'Datetime local', id: 'dateTimeAsLocal', fn: getDateTimeAsLocalFormat() }, + { + name: 'Datetime local (No date if today)', + id: 'dateTimeAsLocalNoDateIfToday', + fn: getDateTimeAsLocalFormatNoDateIfToday(), + }, { name: 'Datetime default', id: 'dateTimeAsSystem', fn: dateTimeSystemFormatter }, { name: 'From Now', id: 'dateTimeFromNow', fn: dateTimeFromNow }, ], diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts index ea5aaff036b..c40d7dd3d7b 100644 --- a/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.test.ts @@ -3,6 +3,8 @@ import { dateTimeAsIsoNoDateIfToday, dateTimeAsUS, dateTimeAsUSNoDateIfToday, + getDateTimeAsLocalFormat, + getDateTimeAsLocalFormatNoDateIfToday, dateTimeFromNow, Interval, toClock, @@ -74,6 +76,36 @@ describe('date time formats', () => { expect(actual.text).toBe(expected); }); + it('should format as local date', () => { + const dateTimeObject = browserTime.toDate(); + const formattedDateText = getDateTimeAsLocalFormat()(epoch, 0, 0).text; + expect(formattedDateText).toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date and skip date when today', () => { + const now = dateTime(); + const dateTimeObject = now.toDate(); + const formattedDateText = getDateTimeAsLocalFormatNoDateIfToday()(now.valueOf(), 0, 0).text; + expect(formattedDateText).not.toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date (in UTC)', () => { + const dateTimeObject = utcTime.toDate(); + const formattedDateText = getDateTimeAsLocalFormat()(epoch, 0, 0, 'utc').text; + expect(formattedDateText).toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + + it('should format as local date (in UTC) and skip date when today', () => { + const now = toUtc(); + const dateTimeObject = now.toDate(); + const formattedDateText = getDateTimeAsLocalFormatNoDateIfToday()(now.valueOf(), 0, 0, 'utc').text; + expect(formattedDateText).not.toContain(dateTimeObject.getFullYear()); + expect(formattedDateText).toContain(dateTimeObject.getSeconds()); + }); + it('should format as from now with days', () => { const daysAgo = dateTime().add(-7, 'd'); const expected = '7 days ago'; diff --git a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts index 35e1fa9c344..b7b5bbf9bb7 100644 --- a/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts +++ b/packages/grafana-data/src/valueFormats/dateTimeFormatters.ts @@ -383,6 +383,24 @@ export function getDateTimeAsLocalFormat() { ); } +export function getDateTimeAsLocalFormatNoDateIfToday() { + return toDateTimeValueFormatter( + localTimeFormat({ + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + localTimeFormat({ + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + ); +} + export function dateTimeSystemFormatter( value: number, decimals: DecimalCount, diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 9d2558ecd46..4f7b571f4fc 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -135,4 +135,14 @@ export const Pages = { SoloPanel: { url: (page: string) => `/d-solo/${page}`, }, + PluginsList: { + page: 'Plugins list page', + list: 'Plugins list', + listItem: 'Plugins list item', + signatureErrorNotice: 'Unsigned plugins notice', + }, + PluginPage: { + page: 'Plugin page', + signatureInfo: 'Plugin signature info', + }, }; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 8f1cff5abbd..fa598ec6cd6 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -62,6 +62,7 @@ export class GrafanaBootConfig implements GrafanaConfig { rendererAvailable = false; http2Enabled = false; dateFormats?: SystemDateFormatSettings; + marketplaceUrl?: string; constructor(options: GrafanaBootConfig) { this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md index 30ccf4aa54b..e8854724d9f 100644 --- a/packages/grafana-toolkit/README.md +++ b/packages/grafana-toolkit/README.md @@ -105,6 +105,7 @@ This command creates a production-ready build of your plugin. Available options: - `--coverage` - Reports code coverage after the test step of the build. +- `--preserveConsole` - Preserves console statements in the code. ### Sign your plugin diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh index 91c7f4c199e..18452a2a255 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh @@ -4,4 +4,4 @@ ## Common variable declarations ## -DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.0.1-alpine" +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.1.0-alpine" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh index 4bfd4c0e3af..1265cff4c4b 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh @@ -18,16 +18,16 @@ apk add --no-cache curl nodejs npm yarn build-base openssh git-lfs perl-utils co # apk add --no-cache xvfb glib nss nspr gdk-pixbuf "gtk+3.0" pango atk cairo dbus-libs libxcomposite libxrender libxi libxtst libxrandr libxscrnsaver alsa-lib at-spi2-atk at-spi2-core cups-libs gcompat libc6-compat # Install Go -filename="go1.15.1.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "70ac0dbf60a8ee9236f337ed0daa7a4c3b98f6186d4497826f68e97c0c0413f6" +filename="go1.15.3.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "010a88df924a81ec21b293b5da8f9b11c176d27c0ee3962dc1738d2352d3c02d" untar_file "/tmp/$filename" # Install golangci-lint -GOLANGCILINT_VERSION=1.31.0 +GOLANGCILINT_VERSION=1.32.2 filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ "/tmp/${filename}.tar.gz" \ - "9a5d47b51442d68b718af4c7350f4406cdc087e2236a5b9ae52f37aebede6cb3" + "e7ab86d833bf9faed39801ab3b5cd294f026d26f9a7da63a42390943ead486cc" untar_file "/tmp/${filename}.tar.gz" ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint ln -s /usr/local/go/bin/go /usr/local/bin/go @@ -43,7 +43,7 @@ get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-l "b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32" chmod +x /usr/local/bin/cc-test-reporter -curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl" +curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl" apk add --no-cache git # Install Mage @@ -65,8 +65,8 @@ current_dir=$PWD cd /usr/local/grafana-toolkit && yarn install && cd $current_dir ln -s /usr/local/grafana-toolkit/bin/grafana-toolkit.js /usr/local/bin/grafana-toolkit -GOOGLE_SDK_VERSION=308.0.0 -GOOGLE_SDK_CHECKSUM=9e8e31d9503340fc912374311ac1fffbfc5b59748d20b681f9aca3de2b68deb5 +GOOGLE_SDK_VERSION=316.0.0 +GOOGLE_SDK_CHECKSUM=96a0b75474dbfa9f3d46dcdec7a4d68a664cb7d57fade5710fe88b1fdf6babb3 curl -fLO https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz echo "${GOOGLE_SDK_CHECKSUM} google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz" | sha256sum --check --status diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh index 7697ddd0383..1fd6ccb7abe 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh @@ -44,7 +44,7 @@ get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-l "b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32" chmod 755 /usr/local/bin/cc-test-reporter -wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl" +wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl" chmod +x /usr/local/bin/grabpl # Install Mage diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh index 9889b0103f6..7ce5d0c276c 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh @@ -4,4 +4,4 @@ ## Common variable declarations ## -DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.0.0" +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.1.0" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh index 33993258ee7..a794da750ec 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh @@ -2,16 +2,16 @@ source "./deploy-common.sh" # Install Go -filename="go1.15.1.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "70ac0dbf60a8ee9236f337ed0daa7a4c3b98f6186d4497826f68e97c0c0413f6" +filename="go1.15.3.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "010a88df924a81ec21b293b5da8f9b11c176d27c0ee3962dc1738d2352d3c02d" untar_file "/tmp/$filename" # Install golangci-lint -GOLANGCILINT_VERSION=1.31.0 +GOLANGCILINT_VERSION=1.32.2 filename="golangci-lint-${GOLANGCILINT_VERSION}-linux-amd64" get_file "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCILINT_VERSION}/$filename.tar.gz" \ "/tmp/${filename}.tar.gz" \ - "9a5d47b51442d68b718af4c7350f4406cdc087e2236a5b9ae52f37aebede6cb3" + "e7ab86d833bf9faed39801ab3b5cd294f026d26f9a7da63a42390943ead486cc" untar_file "/tmp/${filename}.tar.gz" ln -s /usr/local/${filename}/golangci-lint /usr/local/bin/golangci-lint ln -s /usr/local/go/bin/go /usr/local/bin/go @@ -27,7 +27,7 @@ get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-l "b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32" chmod +x /usr/local/bin/cc-test-reporter -wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.20/grabpl" +wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.24/grabpl" chmod +x /usr/local/bin/grabpl # Install Mage @@ -36,8 +36,8 @@ git clone https://github.com/magefile/mage.git /tmp/mage pushd /tmp/mage && go run bootstrap.go && popd mv $HOME/go/bin/mage /usr/local/bin -GOOGLE_SDK_VERSION=308.0.0 -GOOGLE_SDK_CHECKSUM=9e8e31d9503340fc912374311ac1fffbfc5b59748d20b681f9aca3de2b68deb5 +GOOGLE_SDK_VERSION=316.0.0 +GOOGLE_SDK_CHECKSUM=96a0b75474dbfa9f3d46dcdec7a4d68a664cb7d57fade5710fe88b1fdf6babb3 curl -fLO https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz echo "${GOOGLE_SDK_CHECKSUM} google-cloud-sdk-${GOOGLE_SDK_VERSION}-linux-x86_64.tar.gz" | sha256sum --check --status diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index 52a8574f55c..7541bd1c59b 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -29,7 +29,7 @@ "@babel/core": "7.9.0", "@babel/preset-env": "7.9.0", "@grafana/data": "next", - "@grafana/eslint-config": "2.0.4", + "@grafana/eslint-config": "2.0.5", "@grafana/tsconfig": "^1.0.0-rc1", "@grafana/ui": "next", "@types/command-exists": "^1.2.0", @@ -82,7 +82,6 @@ "lodash": "4.17.19", "md5-file": "^4.0.0", "mini-css-extract-plugin": "^0.7.0", - "node-sass": "^4.13.1", "optimize-css-assets-webpack-plugin": "^5.0.3", "ora": "^4.0.3", "pixelmatch": "^5.1.0", @@ -96,6 +95,7 @@ "replace-in-file": "^4.1.0", "replace-in-file-webpack-plugin": "^1.0.6", "rimraf": "^3.0.0", + "sass": "1.27.0", "sass-loader": "8.0.2", "semver": "^7.1.3", "simple-git": "^1.132.0", diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index b87f06191e5..d68e1d2da61 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -140,10 +140,16 @@ export const run = (includeInternalScripts = false) => { program .command('plugin:build') .option('--maxJestWorkers |', 'Limit number of Jest workers spawned') - .description('Prepares plugin dist package') .option('--coverage', 'Run code coverage', false) + .option('--preserveConsole', 'Preserves console calls', false) + .description('Prepares plugin dist package') .action(async cmd => { - await execTask(pluginBuildTask)({ coverage: cmd.coverage, silent: true, maxJestWorkers: cmd.maxJestWorkers }); + await execTask(pluginBuildTask)({ + coverage: cmd.coverage, + silent: true, + maxJestWorkers: cmd.maxJestWorkers, + preserveConsole: cmd.preserveConsole, + }); }); program diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts index af81d5c38e4..69dc46a44b2 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts @@ -17,6 +17,7 @@ const rimraf = promisify(rimrafCallback); interface PluginBuildOptions { coverage: boolean; maxJestWorkers?: string; + preserveConsole?: boolean; } interface Fixable { @@ -102,21 +103,28 @@ export const lintPlugin = ({ fix }: Fixable = {}) => } const { errorCount, results, warningCount } = report; + const formatter = cli.getFormatter(); if (errorCount > 0 || warningCount > 0) { - const formatter = cli.getFormatter(); console.log('\n'); console.log(formatter(results)); console.log('\n'); - throw new Error(`${errorCount + warningCount} linting errors found in ${results.length} files`); + } + + if (errorCount > 0) { + throw new Error(`${errorCount} linting errors found in ${results.length} files`); } }); -export const pluginBuildRunner: TaskRunner = async ({ coverage, maxJestWorkers }) => { +export const pluginBuildRunner: TaskRunner = async ({ + coverage, + maxJestWorkers, + preserveConsole, +}) => { await prepare(); await lintPlugin({ fix: false }); await testPlugin({ updateSnapshot: false, coverage, maxWorkers: maxJestWorkers, watch: false }); - await bundlePlugin({ watch: false, production: true }); + await bundlePlugin({ watch: false, production: true, preserveConsole }); }; export const pluginBuildTask = new Task('Build plugin', pluginBuildRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts index bb0bac436b3..85f61f362c5 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts @@ -7,14 +7,16 @@ export interface PluginBundleOptions { watch: boolean; production?: boolean; yarnlink?: boolean; + preserveConsole?: boolean; } // export const bundlePlugin = ({ watch, production }: PluginBundleOptions) => useSpinner('Bundle plugin', async () => { -export const bundlePlugin = async ({ watch, production }: PluginBundleOptions) => { +export const bundlePlugin = async ({ watch, production, preserveConsole }: PluginBundleOptions) => { const compiler = webpack( await loadWebpackConfig({ watch, production, + preserveConsole, }) ); diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index ae373082cc6..75cc3c7997e 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -18,8 +18,11 @@ import { getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack export interface WebpackConfigurationOptions { watch?: boolean; production?: boolean; + preserveConsole?: boolean; } + type WebpackConfigurationGetter = (options: WebpackConfigurationOptions) => Promise; + export type CustomWebpackConfigurationGetter = ( originalConfig: webpack.Configuration, options: WebpackConfigurationOptions @@ -97,6 +100,7 @@ const getCommonPlugins = (options: WebpackConfigurationOptions) => { { from: hasREADME ? 'README.md' : '../README.md', to: '.', force: true }, { from: 'plugin.json', to: '.' }, { from: '../LICENSE', to: '.' }, + { from: '../CHANGELOG.md', to: '.', force: true }, { from: '**/*.json', to: '.' }, { from: '**/*.svg', to: '.' }, { from: '**/*.png', to: '.' }, @@ -137,7 +141,11 @@ const getBaseWebpackConfig: WebpackConfigurationGetter = async options => { const optimization: { [key: string]: any } = {}; if (options.production) { - optimization.minimizer = [new TerserPlugin({ sourceMap: true }), new OptimizeCssAssetsPlugin()]; + const compressOptions = { drop_console: !options.preserveConsole, drop_debugger: true }; + optimization.minimizer = [ + new TerserPlugin({ sourceMap: true, terserOptions: { compress: compressOptions } }), + new OptimizeCssAssetsPlugin(), + ]; } else if (options.watch) { plugins.push(new HtmlWebpackPlugin()); } diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js index bfdf4d0f3cc..792759122a4 100644 --- a/packages/grafana-ui/.storybook/webpack.config.js +++ b/packages/grafana-ui/.storybook/webpack.config.js @@ -103,7 +103,7 @@ module.exports = ({ config, mode }) => { minimize: isProductionBuild, minimizer: isProductionBuild ? [ - new TerserPlugin({ cache: false, parallel: false, sourceMap: false, exclude: /monaco/ }), + new TerserPlugin({ cache: false, parallel: false, sourceMap: false, exclude: /monaco|bizcharts/ }), new OptimizeCSSAssetsPlugin({}), ] : [], diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 07ac5a3e8cd..391bf342a51 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -70,7 +70,7 @@ "react-transition-group": "4.3.0", "slate": "0.47.8", "tinycolor2": "1.4.1", - "uplot": "1.1.2" + "uplot": "1.2.2" }, "devDependencies": { "@rollup/plugin-commonjs": "11.0.2", diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index 3281824bba9..c4543d43abb 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -5,6 +5,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { useTheme } from '../../themes'; import { Icon } from '../Icon/Icon'; import { IconName } from '../../types/icon'; +import { getColorsFromSeverity } from '../../utils/colors'; export type AlertVariant = 'success' | 'warning' | 'error' | 'info'; @@ -51,51 +52,36 @@ export const Alert: FC = ({ const styles = getStyles(theme, severity, !!buttonContent); return ( -
-
-
- -
-
-
{title}
- {children &&
{children}
} -
- {/* If onRemove is specified, giving preference to onRemove */} - {onRemove ? ( - - ) : onButtonClick ? ( - - ) : null} +
+
+
+
+
{title}
+ {children &&
{children}
} +
+ {/* If onRemove is specified, giving preference to onRemove */} + {onRemove ? ( + + ) : onButtonClick ? ( + + ) : null}
); }; const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean) => { - const { redBase, redShade, greenBase, greenShade, blue80, blue77, white } = theme.palette; - const backgrounds = { - error: css` - background: linear-gradient(90deg, ${redBase}, ${redShade}); - `, - warning: css` - background: linear-gradient(90deg, ${redBase}, ${redShade}); - `, - info: css` - background: linear-gradient(100deg, ${blue80}, ${blue77}); - `, - success: css` - background: linear-gradient(100deg, ${greenBase}, ${greenShade}); - `, - }; + const { white } = theme.palette; + const severityColors = getColorsFromSeverity(severity, theme); + const background = css` + background: linear-gradient(90deg, ${severityColors[0]}, ${severityColors[0]}); + `; return { - container: css` - z-index: ${theme.zIndex.tooltip}; - `, alert: css` padding: 15px 20px; margin-bottom: ${theme.spacing.xs}; @@ -106,7 +92,7 @@ const getStyles = (theme: GrafanaTheme, severity: AlertVariant, outline: boolean display: flex; flex-direction: row; align-items: center; - ${backgrounds[severity]} + ${background} `, icon: css` padding: 0 ${theme.spacing.md} 0 0; diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx index c51f22e460a..d68c504bd26 100644 --- a/packages/grafana-ui/src/components/Badge/Badge.tsx +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { HTMLAttributes } from 'react'; import { Icon } from '../Icon/Icon'; import { useTheme } from '../../themes/ThemeContext'; import { stylesFactory } from '../../themes/stylesFactory'; @@ -6,23 +6,23 @@ import { IconName } from '../../types'; import { Tooltip } from '../Tooltip/Tooltip'; import { getColorForTheme, GrafanaTheme } from '@grafana/data'; import tinycolor from 'tinycolor2'; -import { css } from 'emotion'; -import { HorizontalGroup } from '..'; +import { css, cx } from 'emotion'; +import { HorizontalGroup } from '../Layout/Layout'; export type BadgeColor = 'blue' | 'red' | 'green' | 'orange' | 'purple'; -export interface BadgeProps { +export interface BadgeProps extends HTMLAttributes { text: string; color: BadgeColor; icon?: IconName; tooltip?: string; } -export const Badge = React.memo(({ icon, color, text, tooltip }) => { +export const Badge = React.memo(({ icon, color, text, tooltip, className, ...otherProps }) => { const theme = useTheme(); const styles = getStyles(theme, color); const badge = ( -
+
{icon && } {text} diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 1616cab9522..f051c6e862d 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -545,7 +545,7 @@ export function getBarGradient(props: Props, maxSize: number): string { return gradient + ')'; } - if (mode.colors) { + if (mode.isContinuous && mode.colors) { const scheme = mode.colors.map(item => getColorForTheme(item, theme)); for (let i = 0; i < scheme.length; i++) { const color = scheme[i]; diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx index a5df4c15cd2..95eea803376 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -1,6 +1,7 @@ // Libraries -import React, { CSSProperties, Suspense } from 'react'; +import React, { CSSProperties } from 'react'; import tinycolor from 'tinycolor2'; +import { Chart, Geom } from 'bizcharts'; // Utils import { formattedValueToString, DisplayValue, getColorForTheme } from '@grafana/data'; @@ -13,16 +14,6 @@ import { getTextColorForBackground } from '../../utils'; const LINE_HEIGHT = 1.2; const MAX_TITLE_SIZE = 30; -const Chart = React.lazy(async () => { - const { Chart } = await import(/* webpackChunkName: "bizcharts" */ 'bizcharts'); - return { default: Chart }; -}); - -const Geom = React.lazy(async () => { - const { Geom } = await import(/* webpackChunkName: "bizcharts" */ 'bizcharts'); - return { default: Geom }; -}); - export abstract class BigValueLayout { titleFontSize: number; valueFontSize: number; @@ -72,6 +63,8 @@ export abstract class BigValueLayout { fontSize: this.valueFontSize, fontWeight: 500, lineHeight: LINE_HEIGHT, + position: 'relative', + zIndex: 1, }; switch (this.props.colorMode) { @@ -175,19 +168,17 @@ export abstract class BigValueLayout { } return ( - Loading chart...
}> - - {this.renderGeom()} - - + + {this.renderGeom()} + ); } @@ -220,10 +211,10 @@ export abstract class BigValueLayout { lineStyle.stroke = lineColor; return ( - Loading chart...
}> + <> - + ); } diff --git a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap index cfaf1b3416b..3f57fd43340 100644 --- a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap +++ b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap @@ -30,10 +30,12 @@ exports[`BigValue Render with basic options should render 1`] = ` + +# Card + +## Usage + +### Basic +A basic Card component expects at least a heading to be used as title. Optionally a `metadata` prop is accepted, to provide some secondary information for the card. Multiple meta data elements can be provided as an array, in which case they will be separated by a horizontal line: `|`. +```jsx + +``` + + + + + +### Multiple metadata elements + +```jsx + +``` + + + console.log('clicked tag:', tag) } + /> + + +Metadata also accepts html elements, which could be links, for example. For elements, that are not strings, a `key` prop has to be manually specified. + +```jsx +https://ops-us-east4.grafana.net/api/prom, + ]} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + /> + + +### As a link +Card can be used as a clickable link item by specifying `href` prop. In this case the Card's content will be rendered inside `a`. + +```jsx + +``` + + + + +### Inside a list item + +To render cards in a list, it is possible to nest them inside `li` items. +```jsx +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+``` + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ +### With media elements + +Cards can also be rendered with media content such icons or images. + +```jsx +https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + /> + + +### Action Cards + +Cards also accept primary and secondary actions. Usually the primary actions are displayed as buttons while secondary actions are displayed as icon buttons. + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, + , + ]} + secondaryActions={[ + , + , + ]} + /> + + +### Disabled state + +Card can have a disabled state, effectively making it and its actions non-clickable. If there is more than one primary action, disabled state will disable them instead of the whole card. + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + disabled +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + disabled + /> + + +```jsx + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} + disabled +/> +``` + + + https://ops-us-east4.grafana.net/api/prom, + ]} + image={Grafana Logo} + actions={[, ]} + secondaryActions={[ + , + , + ]} + disabled + /> + + + +### Props + + diff --git a/packages/grafana-ui/src/components/Card/Card.story.internal.tsx b/packages/grafana-ui/src/components/Card/Card.story.internal.tsx new file mode 100644 index 00000000000..533c08da559 --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.story.internal.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { boolean } from '@storybook/addon-knobs'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { Card } from './Card'; +import mdx from './Card.mdx'; +import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; + +const logo = 'https://grafana.com/static/assets/img/apple-touch-icon.png'; + +export default { + title: 'General/Card', + component: Card, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const getKnobs = () => { + const disabled = boolean('Disabled', false, 'Style props'); + + return { disabled }; +}; + +export const Basic = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const AsLink = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithTooltip = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithTags = () => { + const { disabled } = getKnobs(); + return ( + + ); +}; + +export const WithMedia = () => { + const { disabled } = getKnobs(); + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + /> + ); +}; +export const WithActions = () => { + const { disabled } = getKnobs(); + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + actions={[ + , + , + ]} + secondaryActions={[ + , + , + ]} + /> + ); +}; + +export const Full = () => { + const { disabled } = getKnobs(); + + return ( + + https://ops-us-east4.grafana.net/api/prom + , + ]} + disabled={disabled} + image={Prometheus Logo} + tags={['firing', 'active', 'test', 'testdata', 'prometheus']} + description="Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + actions={[ + , + , + ]} + secondaryActions={[ + , + , + , + , + , + ]} + /> + ); +}; diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx new file mode 100644 index 00000000000..45bc6e43be8 --- /dev/null +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -0,0 +1,245 @@ +import React, { cloneElement, FC, HTMLAttributes, ReactElement, ReactNode, useCallback, useMemo } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { useTheme, styleMixins, stylesFactory } from '../../themes'; +import { Tooltip, PopoverContent } from '../Tooltip/Tooltip'; +import { OnTagClick } from '../Tags/Tag'; +import { TagList } from '../Tags/TagList'; + +/** + * @alpha + */ +export interface ContainerProps extends HTMLAttributes { + /** Content for the card's tooltip */ + tooltip?: PopoverContent; +} + +const CardContainer: FC = ({ children, tooltip, ...props }) => { + return tooltip ? ( + +
{children}
+
+ ) : ( +
{children}
+ ); +}; + +/** + * @alpha + */ +export interface CardInnerProps { + href?: string; +} + +const CardInner: FC = ({ children, href }) => { + const theme = useTheme(); + const styles = getCardStyles(theme); + return href ? ( + + {children} + + ) : ( + <>{children} + ); +}; + +/** + * @alpha + */ +export interface Props extends ContainerProps { + /** Main heading for the Card **/ + heading: ReactNode; + /** Additional data about the card. If array is supplied, elements will be rendered with vertical line separator */ + metadata?: ReactNode | ReactNode[]; + /** Card description text */ + description?: string; + /** List of tags to display in the card */ + tags?: string[]; + /** Optional callback for tag onclick event */ + onTagClick?: OnTagClick; + /** Indicates if the card and all its actions can be interacted with */ + disabled?: boolean; + /** Image or icon to be displayed on the let side of the card */ + image?: ReactNode; + /** Main card actions **/ + actions?: ReactElement[]; + /** Right-side actions */ + secondaryActions?: ReactElement[]; + /** Link to redirect to on card click. If provided, the Card inner content will be rendered inside `a` */ + href?: string; + /** On click handler for the Card */ + onClick?: () => void; +} + +/** + * Generic card component + * + * @alpha + */ +export const Card: FC = ({ + heading, + description, + metadata, + tags = [], + onTagClick, + disabled, + image, + actions = [], + tooltip, + secondaryActions = [], + href, + onClick, + className, + ...htmlProps +}) => { + const hasActions = Boolean(actions.length || secondaryActions.length); + const disableHover = disabled || actions.length > 1 || !onClick; + const disableEvents = disabled && !actions.length; + const theme = useTheme(); + const styles = getCardStyles(theme, disableEvents, disableHover); + // Join meta data elements by '|' + const meta = useMemo( + () => + Array.isArray(metadata) + ? (metadata as ReactNode[]).filter(Boolean).reduce((prev, curr, i) => [ + prev, + + | + , + curr, + ]) + : metadata, + [metadata, styles.separator] + ); + const onCardClick = useCallback(() => (disableHover ? () => {} : onClick), [disableHover, onClick]); + + return ( + + + {image &&
{image}
} +
+
{heading}
+ {meta &&
{meta}
} + {!!tags.length && } + {description &&

{description}

} + {hasActions && ( +
+ {!!actions.length && ( +
{actions.map(action => cloneElement(action, { disabled }))}
+ )} + {!!secondaryActions.length && ( +
+ {secondaryActions.map(action => cloneElement(action, { disabled }))} +
+ )} +
+ )} +
+
+
+ ); +}; + +/** + * @alpha + */ +export const getCardStyles = stylesFactory((theme: GrafanaTheme, disabled = false, disableHover = false) => { + return { + container: css` + display: flex; + width: 100%; + color: ${theme.colors.textStrong}; + background: ${theme.colors.bg2}; + border-radius: ${theme.border.radius.sm}; + padding: ${theme.spacing.md}; + position: relative; + pointer-events: ${disabled ? 'none' : 'auto'}; + margin-bottom: ${theme.spacing.sm}; + + &::after { + content: ''; + display: ${disabled ? 'block' : 'none'}; + position: absolute; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + background: linear-gradient(180deg, rgba(75, 79, 84, 0.5) 0%, rgba(82, 84, 92, 0.5) 100%); + width: calc(100% - 2px); + height: calc(100% - 2px); + border-radius: ${theme.border.radius.sm}; + } + + &:hover { + background: ${disableHover ? theme.colors.bg2 : styleMixins.hoverColor(theme.colors.bg2, theme)}; + cursor: ${disableHover ? 'default' : 'pointer'}; + } + + &:focus { + ${styleMixins.focusCss(theme)}; + } + `, + inner: css` + width: 100%; + `, + heading: css` + margin-bottom: 0; + font-size: ${theme.typography.size.md}; + line-height: ${theme.typography.lineHeight.xs}; + `, + metadata: css` + font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textSemiWeak}; + margin: ${theme.spacing.sm} 0 0; + line-height: ${theme.typography.lineHeight.xs}; + `, + description: css` + margin: ${theme.spacing.sm} 0 0; + color: ${theme.colors.textSemiWeak}; + line-height: ${theme.typography.lineHeight.md}; + `, + media: css` + margin-right: ${theme.spacing.md}; + max-width: 40px; + & > * { + width: 100%; + } + `, + actionRow: css` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: ${theme.spacing.md}; + `, + actions: css` + & > * { + margin-right: ${theme.spacing.sm}; + } + `, + secondaryActions: css` + display: flex; + align-items: center; + color: ${theme.colors.textSemiWeak}; + & > * { + margin-right: ${theme.spacing.sm} !important; + } + `, + separator: css` + margin: 0 ${theme.spacing.sm}; + `, + innerLink: css` + display: flex; + width: 100%; + `, + tagList: css` + margin-top: ${theme.spacing.sm}; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx new file mode 100644 index 00000000000..4f5176f5e80 --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.mdx @@ -0,0 +1,31 @@ +import { Props } from '@storybook/addon-docs/blocks'; +import { ContextMenu } from './ContextMenu'; +import { WithContextMenu } from "./WithContextMenu"; + +# ContextMenu + +A menu displaying additional options when it's not possible to show them at all times due to a space constraint. + +### Usage + +There are controlled and uncontrolled versions of the component available. With the controlled component (`ContextMenu`) the open/close logic needs to be handled separately. Uncontrolled component (`WithContextMenu`) handles this logic internally. + +#### Controlled component + +```jsx + {}} items={[{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]} /> +``` + +#### Uncontrolled component + +```jsx + [{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]}> + {({ openMenu }) => } + +``` + +### Props of ContextMenu + + +### Props of WithContextMenu + diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx new file mode 100644 index 00000000000..30eb28b7fab --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { IconButton } from '../IconButton/IconButton'; +import { ContextMenu } from './ContextMenu'; +import { WithContextMenu } from './WithContextMenu'; +import mdx from './ContextMenu.mdx'; + +export default { + title: 'General/ContextMenu', + component: ContextMenu, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const menuItems = [{ label: 'Test', items: [{ label: 'First' }, { label: 'Second' }] }]; + +export const Basic = () => { + return {}} items={menuItems} />; +}; + +export const WithState = () => { + return ( + menuItems}> + {({ openMenu }) => } + + ); +}; diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx index e9181f7b9e6..6079ac5d3a6 100644 --- a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -1,7 +1,7 @@ -import React, { useContext, useRef, useState, useLayoutEffect } from 'react'; +import React, { useRef, useState, useLayoutEffect } from 'react'; import { css, cx } from 'emotion'; import useClickAway from 'react-use/lib/useClickAway'; -import { selectThemeVariant, ThemeContext } from '../../index'; +import { useTheme } from '../../index'; import { GrafanaTheme } from '@grafana/data'; import { stylesFactory } from '../../themes/stylesFactory'; import { Portal, List } from '../index'; @@ -22,120 +22,86 @@ export interface ContextMenuGroup { label?: string; items: ContextMenuItem[]; } + export interface ContextMenuProps { + /** Starting horizontal position for the menu */ x: number; + /** Starting vertical position for the menu */ y: number; + /** Callback for closing the menu */ onClose: () => void; + /** List of the menu items to display */ items?: ContextMenuGroup[]; + /** A function that returns header element */ renderHeader?: () => React.ReactNode; } const getContextMenuStyles = stylesFactory((theme: GrafanaTheme) => { - const linkColor = selectThemeVariant( - { - light: theme.palette.dark2, - dark: theme.colors.text, - }, - theme.type - ); - const linkColorHover = selectThemeVariant( - { - light: theme.colors.link, - dark: theme.palette.white, - }, - theme.type - ); - const wrapperBg = selectThemeVariant( - { - light: theme.palette.gray7, - dark: theme.palette.dark2, - }, - theme.type - ); - const wrapperShadow = selectThemeVariant( - { - light: theme.palette.gray3, - dark: theme.palette.black, - }, - theme.type - ); - const itemColor = selectThemeVariant( - { - light: theme.palette.black, - dark: theme.palette.white, - }, - theme.type - ); + const { white, black, dark1, dark2, dark7, gray1, gray3, gray5, gray7 } = theme.palette; + const lightThemeStyles = { + linkColor: dark2, + linkColorHover: theme.colors.link, + wrapperBg: gray7, + wrapperShadow: gray3, + itemColor: black, + groupLabelColor: gray1, + itemBgHover: gray5, + headerBg: white, + headerSeparator: white, + }; + const darkThemeStyles = { + linkColor: theme.colors.text, + linkColorHover: white, + wrapperBg: dark2, + wrapperShadow: black, + itemColor: white, + groupLabelColor: theme.colors.textWeak, + itemBgHover: dark7, + headerBg: dark1, + headerSeparator: dark7, + }; - const groupLabelColor = selectThemeVariant( - { - light: theme.palette.gray1, - dark: theme.colors.textWeak, - }, - theme.type - ); - - const itemBgHover = selectThemeVariant( - { - light: theme.palette.gray5, - dark: theme.palette.dark7, - }, - theme.type - ); - const headerBg = selectThemeVariant( - { - light: theme.palette.white, - dark: theme.palette.dark1, - }, - theme.type - ); - const headerSeparator = selectThemeVariant( - { - light: theme.palette.white, - dark: theme.palette.dark7, - }, - theme.type - ); + const styles = theme.isDark ? darkThemeStyles : lightThemeStyles; return { header: css` padding: 4px; - border-bottom: 1px solid ${headerSeparator}; - background: ${headerBg}; + border-bottom: 1px solid ${styles.headerSeparator}; + background: ${styles.headerBg}; margin-bottom: ${theme.spacing.xs}; border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0; `, wrapper: css` - background: ${wrapperBg}; + background: ${styles.wrapperBg}; z-index: 1; - box-shadow: 0 2px 5px 0 ${wrapperShadow}; + box-shadow: 0 2px 5px 0 ${styles.wrapperShadow}; min-width: 200px; display: inline-block; border-radius: ${theme.border.radius.sm}; `, link: css` - color: ${linkColor}; + color: ${styles.linkColor}; display: flex; cursor: pointer; &:hover { - color: ${linkColorHover}; + color: ${styles.linkColorHover}; text-decoration: none; } `, item: css` background: none; padding: 4px 8px; - color: ${itemColor}; + color: ${styles.itemColor}; border-left: 2px solid transparent; cursor: pointer; &:hover { - background: ${itemBgHover}; + background: ${styles.itemBgHover}; border-image: linear-gradient(#f05a28 30%, #fbca0a 99%); border-image-slice: 1; } `, groupLabel: css` - color: ${groupLabelColor}; + color: ${styles.groupLabelColor}; font-size: ${theme.typography.size.sm}; line-height: ${theme.typography.lineHeight.md}; padding: ${theme.spacing.xs} ${theme.spacing.sm}; @@ -149,7 +115,7 @@ const getContextMenuStyles = stylesFactory((theme: GrafanaTheme) => { }); export const ContextMenu: React.FC = React.memo(({ x, y, onClose, items, renderHeader }) => { - const theme = useContext(ThemeContext); + const theme = useTheme(); const menuRef = useRef(null); const [positionStyles, setPositionStyles] = useState({}); @@ -186,11 +152,7 @@ export const ContextMenu: React.FC = React.memo(({ x, y, onClo { - return ( - <> - - - ); + return ; }} />
@@ -209,7 +171,7 @@ interface ContextMenuItemProps { const ContextMenuItemComponent: React.FC = React.memo( ({ url, icon, label, target, onClick, className }) => { - const theme = useContext(ThemeContext); + const theme = useTheme(); const styles = getContextMenuStyles(theme); return (
@@ -236,7 +198,7 @@ interface ContextMenuGroupProps { } const ContextMenuGroupComponent: React.FC = ({ group, onClick }) => { - const theme = useContext(ThemeContext); + const theme = useTheme(); const styles = getContextMenuStyles(theme); if (group.items.length === 0) { diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx index 344c017080c..5410ca95cbf 100644 --- a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -2,7 +2,9 @@ import React, { useState } from 'react'; import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu'; interface WithContextMenuProps { + /** Menu item trigger that accepts openMenu prop */ children: (props: { openMenu: React.MouseEventHandler }) => JSX.Element; + /** A function that returns an array of menu items */ getContextMenuItems: () => ContextMenuGroup[]; } diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 1987a7b0d10..7ad6add7c19 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -25,8 +25,6 @@ export interface Props extends Themeable { className?: string; } -const FONT_SCALE = 1; - export class Gauge extends PureComponent { canvasElement: any; @@ -95,13 +93,6 @@ export class Gauge extends PureComponent { return formatted; } - getFontScale(length: number): number { - if (length > 12) { - return FONT_SCALE - (length * 5) / 110; - } - return FONT_SCALE - (length * 5) / 101; - } - draw() { const { field, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props; @@ -112,7 +103,12 @@ export class Gauge extends PureComponent { const gaugeWidth = Math.min(dimension / 5.5, 40) / gaugeWidthReduceRatio; const thresholdMarkersWidth = gaugeWidth / 5; const text = formattedValueToString(value); - const fontSize = calculateFontSize(text, dimension - gaugeWidth * 3, dimension, 1, 48); + // This not 100% accurate as I am unsure of flot's calculations here + const valueWidthBase = Math.min(width, dimension * 1.3) * 0.9; + // remove gauge & marker width (on left and right side) + // and 10px is some padding that flot adds to the outer canvas + const valueWidth = valueWidthBase - ((gaugeWidth + (showThresholdMarkers ? thresholdMarkersWidth : 0)) * 2 + 10); + const fontSize = calculateFontSize(text, valueWidth, dimension, 1, 48); const thresholdLabelFontSize = fontSize / 2.5; let min = field.min!; diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 615761e0d54..108e16ba018 100644 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -15,47 +15,37 @@ import { UPlotChart } from '../uPlot/Plot'; import { AxisSide, GraphCustomFieldConfig, PlotProps } from '../uPlot/types'; import { useTheme } from '../../themes'; +const _ = null; + const timeStampsConfig = [ - [3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'], - [3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'], - [ - 3600 * 24, - `{${timeFormatToTemplate(systemDateFormats.interval.day)}`, - 7, - `${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`, - ], + // tick incr default year month day hour min sec mode + [3600 * 24 * 365, '{YYYY}', _, _, _, _, _, _, 1], + [3600 * 24 * 28, `${timeFormatToTemplate(systemDateFormats.interval.month)}`, _, _, _, _, _, _, 1], + [3600 * 24, `${timeFormatToTemplate(systemDateFormats.interval.day)}`, `\n{YYYY}`, _, _, _, _, _, 1], [ 3600, - `{${timeFormatToTemplate(systemDateFormats.interval.minute)}`, - 4, - `${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate( - systemDateFormats.interval.day - )}`, + `${timeFormatToTemplate(systemDateFormats.interval.minute)}`, + _, + _, + `\n${timeFormatToTemplate(systemDateFormats.interval.day)}`, + _, + _, + _, + 1, ], [ 60, - `{${timeFormatToTemplate(systemDateFormats.interval.second)}`, - 4, - `${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate( - systemDateFormats.interval.day - )}`, - ], - [ + `${timeFormatToTemplate(systemDateFormats.interval.minute)}`, + _, + _, + `\n${timeFormatToTemplate(systemDateFormats.interval.day)}`, + _, + _, + _, 1, - `:{ss}`, - 2, - `:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( - systemDateFormats.interval.minute - )}`, - ], - [ - 1e-3, - ':{ss}.{fff}', - 2, - `:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( - systemDateFormats.interval.minute - )}`, ], + [1, ':{ss}', _, _, _, _, `\n ${timeFormatToTemplate(systemDateFormats.interval.minute)}`, _, 1], + [1e-3, ':{ss}.{fff}', _, _, _, _, `\n ${timeFormatToTemplate(systemDateFormats.interval.minute)}`, _, 1], ]; const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.mdx b/packages/grafana-ui/src/components/InfoBox/InfoBox.mdx new file mode 100644 index 00000000000..5ef95cf86bc --- /dev/null +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.mdx @@ -0,0 +1,24 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { InfoBox } from './InfoBox'; + + + +# InfoBox +A component for displaying information in different levels of severity (Success, Warning, Error, Info). + + +### Example usage +This component can be found in the settings for various data sources. + + +```tsx + +``` + + + diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.story.tsx b/packages/grafana-ui/src/components/InfoBox/InfoBox.story.tsx index 10243280d9c..7a119466992 100644 --- a/packages/grafana-ui/src/components/InfoBox/InfoBox.story.tsx +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.story.tsx @@ -1,43 +1,44 @@ import React from 'react'; -import { number } from '@storybook/addon-knobs'; -import { InfoBox, FeatureInfoBox } from '@grafana/ui'; +import { number, select, text } from '@storybook/addon-knobs'; import { FeatureState } from '@grafana/data'; +import { InfoBox, FeatureInfoBox } from '@grafana/ui'; +import mdx from './InfoBox.mdx'; export default { title: 'Layout/InfoBox', component: InfoBox, decorators: [], parameters: { - docs: {}, + docs: { + page: mdx, + }, }, }; const getKnobs = () => { - const CONTAINER_GROUP = 'Container options'; - // --- - const containerWidth = number( - 'Container width', - 800, - { - range: true, - min: 100, - max: 1500, - step: 100, - }, - CONTAINER_GROUP - ); + const containerWidth = number('Container width', 800, { + range: true, + min: 100, + max: 1500, + step: 100, + }); - return { containerWidth }; + const title = text('Title', 'User permission'); + const url = text('Url', 'http://docs.grafana.org/features/datasources/mysql/'); + const severity = select('Severity', ['success', 'warning', 'error', 'info'], 'info'); + + return { containerWidth, severity, title, url }; }; export const basic = () => { - const { containerWidth } = getKnobs(); + const { containerWidth, severity, title, url } = getKnobs(); return (
{ alert('onDismiss clicked'); }} diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx index 0ecc6da2dfd..f1db3af3b7f 100644 --- a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx @@ -1,32 +1,39 @@ import React from 'react'; import { css, cx } from 'emotion'; import { GrafanaTheme } from '@grafana/data'; -import { stylesFactory, useTheme } from '../../themes'; import { Icon } from '../Icon/Icon'; import { IconButton } from '../IconButton/IconButton'; import { HorizontalGroup } from '../Layout/Layout'; +import { AlertVariant } from '../Alert/Alert'; import panelArtDark from './panelArt_dark.svg'; import panelArtLight from './panelArt_light.svg'; +import { stylesFactory, useTheme } from '../../themes'; +import { getColorsFromSeverity } from '../../utils/colors'; export interface InfoBoxProps extends Omit, 'title'> { children: React.ReactNode; + /** Title of the box */ title?: string | JSX.Element; + /** Url of the read more link */ url?: string; + /** Text of the read more link */ urlTitle?: string; + /** Indicates whether or not box should be rendered with Grafana branding background */ branded?: boolean; + /** Color variant of the box */ + severity?: AlertVariant; + /** Call back to be performed when box is dismissed */ onDismiss?: () => void; } /** - * This is a simple InfoBox component, the api is not considered stable yet and will likely see breaking changes - * in future minor releases. - * @Alpha + @public */ export const InfoBox = React.memo( React.forwardRef( - ({ title, className, children, branded, url, urlTitle, onDismiss, ...otherProps }, ref) => { + ({ title, className, children, branded, url, urlTitle, onDismiss, severity = 'info', ...otherProps }, ref) => { const theme = useTheme(); - const styles = getInfoBoxStyles(theme); + const styles = getInfoBoxStyles(theme, severity); const wrapperClassName = branded ? cx(styles.wrapperBranded, className) : cx(styles.wrapper, className); return ( @@ -49,18 +56,15 @@ export const InfoBox = React.memo( ) ); -const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme) => ({ +const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme, severity: AlertVariant) => ({ wrapper: css` position: relative; padding: ${theme.spacing.md}; background-color: ${theme.colors.bg2}; - border-top: 3px solid ${theme.palette.blue80}; + border-top: 3px solid ${getColorsFromSeverity(severity, theme)[0]}; margin-bottom: ${theme.spacing.md}; flex-grow: 1; - - ul { - padding-left: ${theme.spacing.lg}; - } + color: ${theme.colors.textSemiWeak}; code { @include font-family-monospace(); @@ -109,5 +113,6 @@ const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme) => ({ display: inline-block; margin-top: ${theme.spacing.md}; font-size: ${theme.typography.size.sm}; + color: ${theme.colors.textSemiWeak}; `, })); diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 8e94f06f5c6..3b01374215e 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -3,7 +3,7 @@ import { GrafanaTheme } from '@grafana/data'; import { css, cx } from 'emotion'; import { getFocusStyle, sharedInputStyle } from '../Forms/commonStyles'; import { stylesFactory, useTheme } from '../../themes'; -import { Icon } from '../Icon/Icon'; +import { Spinner } from '../Spinner/Spinner'; import { useClientRect } from '../../utils/useClientRect'; export interface Props extends Omit, 'prefix' | 'size'> { @@ -249,7 +249,7 @@ export const Input = React.forwardRef((props, ref) => { {(suffix || loading) && (
- {loading && } + {loading && } {suffix}
)} diff --git a/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.mdx b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.mdx new file mode 100644 index 00000000000..fbd10d9a685 --- /dev/null +++ b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.mdx @@ -0,0 +1,15 @@ +import { Props } from '@storybook/addon-docs/blocks'; +import { LoadingPlaceholder } from './LoadingPlaceholder'; + +# LoadingPlaceholder + +Loading indicator with a text. Used to alert a user to wait for an activity to complete. + +### Usage + +```jsx + +``` + +### Props + diff --git a/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.story.tsx b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.story.tsx new file mode 100644 index 00000000000..ad8215e0aaa --- /dev/null +++ b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.story.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { LoadingPlaceholder } from './LoadingPlaceholder'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './LoadingPlaceholder.mdx'; + +export default { + title: 'General/LoadingPlaceholder', + component: LoadingPlaceholder, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const basic = () => { + return ; +}; diff --git a/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx index 3194ff3ebb9..52e9146d79d 100644 --- a/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx +++ b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx @@ -1,12 +1,32 @@ -import React, { SFC } from 'react'; -import { Icon } from '../Icon/Icon'; +import React, { HTMLAttributes, SFC } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { Spinner } from '../Spinner/Spinner'; +import { useStyles } from '../../themes'; -interface LoadingPlaceholderProps { +/** + * @public + */ +export interface LoadingPlaceholderProps extends HTMLAttributes { text: string; } -export const LoadingPlaceholder: SFC = ({ text }) => ( -
- {text} -
-); +/** + * @public + */ +export const LoadingPlaceholder: SFC = ({ text, className, ...rest }) => { + const styles = useStyles(getStyles); + return ( +
+ {text} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + container: css` + margin-bottom: ${theme.spacing.xl}; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index ef213e47abe..0e4a19c2eb9 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -2,14 +2,14 @@ import React from 'react'; import { Portal } from '../Portal/Portal'; import { cx } from 'emotion'; import { withTheme } from '../../themes'; -import { IconName } from '../../types'; -import { Themeable } from '../../types'; +import { IconName, Themeable } from '../../types'; import { getModalStyles } from './getModalStyles'; import { ModalHeader } from './ModalHeader'; import { IconButton } from '../IconButton/IconButton'; export interface Props extends Themeable { icon?: IconName; + iconTooltip?: string; /** Title for the modal or custom header element */ title: string | JSX.Element; className?: string; @@ -33,9 +33,9 @@ export class UnthemedModal extends React.PureComponent { }; renderDefaultHeader(title: string) { - const { icon } = this.props; + const { icon, iconTooltip } = this.props; - return ; + return ; } render() { diff --git a/packages/grafana-ui/src/components/Modal/ModalHeader.tsx b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx index 73ee7e04aac..4a8d46e4f20 100644 --- a/packages/grafana-ui/src/components/Modal/ModalHeader.tsx +++ b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx @@ -3,20 +3,27 @@ import { getModalStyles } from './getModalStyles'; import { IconName } from '../../types'; import { ThemeContext } from '../../themes'; import { Icon } from '../Icon/Icon'; +import { Tooltip } from '..'; interface Props { title: string; icon?: IconName; + iconTooltip?: string; } -export const ModalHeader: React.FC = ({ icon, title, children }) => { +export const ModalHeader: React.FC = ({ icon, iconTooltip, title, children }) => { const theme = useContext(ThemeContext); const styles = getModalStyles(theme); return ( <>

- {icon && } + {icon && !iconTooltip && } + {icon && iconTooltip && ( + + + + )} {title}

{children} diff --git a/packages/grafana-ui/src/components/OptionsUI/color.tsx b/packages/grafana-ui/src/components/OptionsUI/color.tsx index 1b638a94193..eecd18dc4c6 100644 --- a/packages/grafana-ui/src/components/OptionsUI/color.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/color.tsx @@ -28,12 +28,6 @@ export const ColorValueEditor: React.FC = ({ value, onChange }) => { color={value ? getColorForTheme(value, theme) : theme.colors.formInputBorder} />
- {/*
- {value ?? settings?.textWhenUndefined ?? 'Pick Color'} -
- {value && settings?.allowUndefined && ( - onChange(undefined)} /> - )} */}
); }} diff --git a/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx b/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx index 7d64f8ef0df..5a59163a07d 100644 --- a/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx @@ -8,13 +8,14 @@ import { FieldColorMode, GrafanaTheme, getColorForTheme, + FieldColorConfigSettings, } from '@grafana/data'; import { Select } from '../Select/Select'; import { ColorValueEditor } from './color'; import { useStyles, useTheme } from '../../themes/ThemeContext'; import { css } from 'emotion'; -export const FieldColorEditor: React.FC> = ({ +export const FieldColorEditor: React.FC> = ({ value, onChange, item, @@ -22,7 +23,11 @@ export const FieldColorEditor: React.FC { + const availableOptions = item.settings?.byValueSupport + ? fieldColorModeRegistry.list() + : fieldColorModeRegistry.list().filter(m => !m.isByValue); + + const options = availableOptions.map(mode => { let suffix = mode.isByValue ? ' (by value)' : ''; return { diff --git a/packages/grafana-ui/src/components/OptionsUI/slider.tsx b/packages/grafana-ui/src/components/OptionsUI/slider.tsx new file mode 100644 index 00000000000..f5403f5b96a --- /dev/null +++ b/packages/grafana-ui/src/components/OptionsUI/slider.tsx @@ -0,0 +1,27 @@ +import React, { useCallback } from 'react'; +import { FieldConfigEditorProps, SliderFieldConfigSettings } from '@grafana/data'; +import { Slider } from '../Slider/Slider'; + +export const SliderValueEditor: React.FC> = ({ + value, + onChange, + item, +}) => { + const { settings } = item; + const onValueAfterChange = useCallback( + (value?: number) => { + onChange(value); + }, + [onChange] + ); + const initialValue = typeof value === 'number' ? value : typeof value === 'string' ? +value : 0; + return ( + + ); +}; diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index 5a90fcf31cc..28547cee6ba 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -9,6 +9,7 @@ import { default as ReactAsyncSelect } from '@torkelo/react-select/async'; import { default as AsyncCreatable } from '@torkelo/react-select/async-creatable'; import { Icon } from '../Icon/Icon'; +import { Spinner } from '../Spinner/Spinner'; import { css, cx } from 'emotion'; import resetSelectStyles from './resetSelectStyles'; import { SelectMenu, SelectMenuOptions } from './SelectMenu'; @@ -294,7 +295,7 @@ export function SelectBase({ ); }, LoadingIndicator: (props: any) => { - return ; + return ; }, LoadingMessage: (props: any) => { return
{loadingMessage}
; diff --git a/packages/grafana-ui/src/components/Select/getSelectStyles.ts b/packages/grafana-ui/src/components/Select/getSelectStyles.ts index b74b412eefe..397128ea51f 100644 --- a/packages/grafana-ui/src/components/Select/getSelectStyles.ts +++ b/packages/grafana-ui/src/components/Select/getSelectStyles.ts @@ -43,6 +43,7 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme) => { font-size: ${theme.typography.size.sm}; color: ${theme.colors.textWeak}; white-space: normal; + line-height: ${theme.typography.lineHeight.md}; `, optionBody: css` label: grafana-select-option-body; diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index a99cbdb6979..f9dfceac73c 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -20,7 +20,7 @@ export interface SelectCommonProps { filterOption?: (option: SelectableValue, searchQuery: string) => boolean; /** Function for formatting the text that is displayed when creating a new value*/ formatCreateLabel?: (input: string) => string; - getOptionLabel?: (item: SelectableValue) => string; + getOptionLabel?: (item: SelectableValue) => React.ReactNode; getOptionValue?: (item: SelectableValue) => string; inputValue?: string; invalid?: boolean; diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.mdx b/packages/grafana-ui/src/components/Slider/RangeSlider.mdx new file mode 100644 index 00000000000..2df51ccae28 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.mdx @@ -0,0 +1,12 @@ +import { Meta, Props } from '@storybook/addon-docs/blocks'; +import { RangeSliderProps } from './types'; + + + +# Range-slider + +The `Range-slider` component is an input element where users can manipulate two values on a one-dimensional axis. + +`Range-slider` can be implemented in horizontal or vertical orientation. You can set the default starting values for the slider with the `value` prop. + + diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx new file mode 100644 index 00000000000..f3bc6ba4e1a --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { RangeSlider } from '@grafana/ui'; +import { select, number, boolean } from '@storybook/addon-knobs'; + +export default { + title: 'Forms/Slider/Range', + component: RangeSlider, +}; + +const getKnobs = () => { + return { + min: number('min', 0), + max: number('max', 100), + step: boolean('enable step', false), + orientation: select('orientation', ['horizontal', 'vertical'], 'horizontal'), + reverse: boolean('reverse', false), + }; +}; + +const SliderWrapper = () => { + const { min, max, orientation, reverse, step } = getKnobs(); + const stepValue = step ? 10 : undefined; + return ( +
+ +
+ ); +}; + +export const basic = () => ; diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx new file mode 100644 index 00000000000..6011cbff161 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { RangeSlider } from './RangeSlider'; +import { RangeSliderProps } from './types'; +import { render } from '@testing-library/react'; + +const sliderProps: RangeSliderProps = { + min: 10, + max: 20, +}; + +describe('RangeSlider', () => { + it('renders without error', () => { + expect(() => { + render(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx new file mode 100644 index 00000000000..75b26a98b92 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx @@ -0,0 +1,53 @@ +import React, { FunctionComponent } from 'react'; +import { Range as RangeComponent, createSliderWithTooltip } from 'rc-slider'; +import { cx } from 'emotion'; +import { Global } from '@emotion/core'; +import { useTheme } from '../../themes/ThemeContext'; +import { getStyles } from './styles'; +import { RangeSliderProps } from './types'; + +/** + * @public + * + * RichHistoryQueriesTab uses this Range Component + */ +export const RangeSlider: FunctionComponent = ({ + min, + max, + onChange, + onAfterChange, + orientation = 'horizontal', + reverse, + step, + formatTooltipResult, + value, + tooltipAlwaysVisible = true, +}) => { + const isHorizontal = orientation === 'horizontal'; + const theme = useTheme(); + const styles = getStyles(theme, isHorizontal); + const RangeWithTooltip = createSliderWithTooltip(RangeComponent); + return ( +
+ {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} + + (formatTooltipResult ? formatTooltipResult(value) : value)} + onChange={onChange} + onAfterChange={onAfterChange} + vertical={!isHorizontal} + reverse={reverse} + /> +
+ ); +}; + +RangeSlider.displayName = 'Range'; diff --git a/packages/grafana-ui/src/components/Slider/Slider.mdx b/packages/grafana-ui/src/components/Slider/Slider.mdx index b1cf7820341..7a40f484391 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.mdx +++ b/packages/grafana-ui/src/components/Slider/Slider.mdx @@ -1,12 +1,12 @@ import { Meta, Props } from '@storybook/addon-docs/blocks'; -import { Slider } from './Slider'; +import { SliderProps } from './types'; # Slider -The `Slider` component is an input element where users can manipulate one or two values on a one-dimensional axis. +The `Slider` component is an input element where users can manipulate one value on a one-dimensional axis. -`Slider` can be implemented in horizontal or vertical orientation. You can set the default starting value(s) for the slider with the `value` prop. +`Slider` can be implemented in horizontal or vertical orientation. You can set the default starting value(s) for the slider with the `value` prop. - + diff --git a/packages/grafana-ui/src/components/Slider/Slider.story.tsx b/packages/grafana-ui/src/components/Slider/Slider.story.tsx index d659092e86b..931eba9f337 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.story.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.story.tsx @@ -13,24 +13,16 @@ const getKnobs = () => { max: number('max', 100), step: boolean('enable step', false), orientation: select('orientation', ['horizontal', 'vertical'], 'horizontal'), - reverse: boolean('reverse', true), - singleValue: boolean('single value', false), + reverse: boolean('reverse', false), }; }; const SliderWrapper = () => { - const { min, max, orientation, reverse, singleValue, step } = getKnobs(); + const { min, max, orientation, reverse, step } = getKnobs(); const stepValue = step ? 10 : undefined; return (
- +
); }; diff --git a/packages/grafana-ui/src/components/Slider/Slider.test.tsx b/packages/grafana-ui/src/components/Slider/Slider.test.tsx index 277a905c15f..f688f546508 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.test.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Slider, Props } from './Slider'; +import { Slider } from './Slider'; +import { SliderProps } from './types'; import { mount } from 'enzyme'; -const sliderProps: Props = { +const sliderProps: SliderProps = { min: 10, max: 20, }; @@ -17,11 +18,10 @@ describe('Slider', () => { expect(wrapper.html()).toContain('aria-valuemin="10"'); expect(wrapper.html()).toContain('aria-valuemax="20"'); expect(wrapper.html()).toContain('aria-valuenow="10"'); - expect(wrapper.html()).toContain('aria-valuenow="20"'); }); it('renders correct contents with a value', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.html()).toContain('aria-valuenow="15"'); expect(wrapper.html()).not.toContain('aria-valuenow="20"'); expect(wrapper.html()).not.toContain('aria-valuenow="10"'); diff --git a/packages/grafana-ui/src/components/Slider/Slider.tsx b/packages/grafana-ui/src/components/Slider/Slider.tsx index f0095930b3b..de9058010bd 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.tsx @@ -1,104 +1,15 @@ -import React, { FunctionComponent } from 'react'; -import { Range, createSliderWithTooltip } from 'rc-slider'; -import { cx, css } from 'emotion'; -import { Global, css as cssCore } from '@emotion/core'; -import { stylesFactory } from '../../themes'; -import { GrafanaTheme } from '@grafana/data'; +import React, { useState, useCallback, ChangeEvent, FunctionComponent } from 'react'; +import SliderComponent from 'rc-slider'; +import { cx } from 'emotion'; +import { Global } from '@emotion/core'; import { useTheme } from '../../themes/ThemeContext'; -import { Orientation } from '../../types/orientation'; +import { getStyles } from './styles'; +import { SliderProps } from './types'; -export interface Props { - min: number; - max: number; - orientation?: Orientation; - /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ - value?: number[]; - reverse?: boolean; - step?: number; - tooltipAlwaysVisible?: boolean; - formatTooltipResult?: (value: number) => number | string; - onChange?: (values: number[]) => void; - onAfterChange?: (values: number[]) => void; -} - -const getStyles = stylesFactory((theme: GrafanaTheme, isHorizontal: boolean) => { - const trackColor = theme.isLight ? theme.palette.gray5 : theme.palette.dark6; - const container = isHorizontal - ? css` - width: 100%; - margin: ${theme.spacing.lg} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm}; - ` - : css` - height: 100%; - margin: ${theme.spacing.sm} ${theme.spacing.lg} ${theme.spacing.sm} ${theme.spacing.sm}; - `; - - return { - container, - slider: css` - .rc-slider-vertical .rc-slider-handle { - margin-top: -10px; - } - .rc-slider-handle { - border: solid 2px ${theme.palette.blue77}; - background-color: ${theme.palette.blue77}; - } - .rc-slider-handle:hover { - border-color: ${theme.palette.blue77}; - } - .rc-slider-handle:focus { - border-color: ${theme.palette.blue77}; - box-shadow: none; - } - .rc-slider-handle:active { - border-color: ${theme.palette.blue77}; - box-shadow: none; - } - .rc-slider-handle-click-focused:focus { - border-color: ${theme.palette.blue77}; - } - .rc-slider-dot-active { - border-color: ${theme.palette.blue77}; - } - .rc-slider-track { - background-color: ${theme.palette.blue77}; - } - .rc-slider-rail { - background-color: ${trackColor}; - border: 1px solid ${trackColor}; - } - `, - /** Global component from @emotion/core doesn't accept computed classname string returned from css from emotion. - * It accepts object containing the computed name and flattened styles returned from css from @emotion/core - * */ - tooltip: cssCore` - body { - .rc-slider-tooltip { - cursor: grab; - user-select: none; - z-index: ${theme.zIndex.tooltip}; - } - - .rc-slider-tooltip-inner { - color: ${theme.colors.text}; - background-color: transparent !important; - border-radius: 0; - box-shadow: none; - } - - .rc-slider-tooltip-placement-top .rc-slider-tooltip-arrow { - display: none; - } - - .rc-slider-tooltip-placement-top { - padding: 0; - } - } - `, - }; -}); - -export const Slider: FunctionComponent = ({ +/** + * @public + */ +export const Slider: FunctionComponent = ({ min, max, onChange, @@ -106,33 +17,63 @@ export const Slider: FunctionComponent = ({ orientation = 'horizontal', reverse, step, - formatTooltipResult, value, - tooltipAlwaysVisible = true, }) => { const isHorizontal = orientation === 'horizontal'; const theme = useTheme(); const styles = getStyles(theme, isHorizontal); - const RangeWithTooltip = createSliderWithTooltip(Range); + const SliderWithTooltip = SliderComponent; + const [slidervalue, setSliderValue] = useState(value || min); + const onSliderChange = useCallback((v: number) => { + setSliderValue(v); + + if (onChange) { + onChange(v); + } + }, []); + const onSliderInputChange = useCallback((e: ChangeEvent) => { + let v = +e.target.value; + + v > max && (v = max); + v < min && (v = min); + + setSliderValue(v); + + if (onChange) { + onChange(v); + } + + if (onAfterChange) { + onAfterChange(v); + } + }, []); + const sliderInputClassNames = !isHorizontal ? [styles.sliderInputVertical] : []; + const sliderInputFieldClassNames = !isHorizontal ? [styles.sliderInputFieldVertical] : []; return (
{/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} - (formatTooltipResult ? formatTooltipResult(value) : value)} - onChange={onChange} - onAfterChange={onAfterChange} - vertical={!isHorizontal} - reverse={reverse} - /> +
); }; diff --git a/packages/grafana-ui/src/components/Slider/styles.ts b/packages/grafana-ui/src/components/Slider/styles.ts new file mode 100644 index 00000000000..b43399de633 --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/styles.ts @@ -0,0 +1,122 @@ +import { stylesFactory } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; +import { focusCss } from '../../themes/mixins'; +import { css as cssCore } from '@emotion/core'; +import { css } from 'emotion'; + +export const getFocusStyle = (theme: GrafanaTheme) => css` + &:focus { + ${focusCss(theme)} + } +`; + +export const getStyles = stylesFactory((theme: GrafanaTheme, isHorizontal: boolean) => { + const trackColor = theme.isLight ? theme.palette.gray5 : theme.palette.dark6; + const container = isHorizontal + ? css` + width: 100%; + ` + : css` + height: 100%; + margin: ${theme.spacing.sm} ${theme.spacing.lg} ${theme.spacing.sm} ${theme.spacing.sm}; + `; + + return { + container, + slider: css` + .rc-slider { + display: flex; + flex-grow: 1; + margin-left: 7px; // half the size of the handle to align handle to the left on 0 value + } + .rc-slider-vertical .rc-slider-handle { + margin-top: -10px; + } + .rc-slider-handle { + border: solid 2px ${theme.palette.blue77}; + background-color: ${theme.palette.blue77}; + } + .rc-slider-handle:hover { + border-color: ${theme.palette.blue77}; + } + .rc-slider-handle:focus { + border-color: ${theme.palette.blue77}; + box-shadow: none; + } + .rc-slider-handle:active { + border-color: ${theme.palette.blue77}; + box-shadow: none; + } + .rc-slider-handle-click-focused:focus { + border-color: ${theme.palette.blue77}; + } + .rc-slider-dot-active { + border-color: ${theme.palette.blue77}; + } + .rc-slider-track { + background-color: ${theme.palette.blue77}; + } + .rc-slider-rail { + background-color: ${trackColor}; + border: 1px solid ${trackColor}; + } + `, + /** Global component from @emotion/core doesn't accept computed classname string returned from css from emotion. + * It accepts object containing the computed name and flattened styles returned from css from @emotion/core + * */ + tooltip: cssCore` + body { + .rc-slider-tooltip { + cursor: grab; + user-select: none; + z-index: ${theme.zIndex.tooltip}; + } + + .rc-slider-tooltip-inner { + color: ${theme.colors.text}; + background-color: transparent !important; + border-radius: 0; + box-shadow: none; + } + + .rc-slider-tooltip-placement-top .rc-slider-tooltip-arrow { + display: none; + } + + .rc-slider-tooltip-placement-top { + padding: 0; + } + } + `, + sliderInput: css` + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + `, + sliderInputVertical: css` + flex-direction: column; + height: 100%; + + .rc-slider { + margin: 0; + order: 2; + } + `, + sliderInputField: css` + display: flex; + flex-grow: 0; + flex-basis: 50px; + margin-left: ${theme.spacing.lg}; + height: ${theme.spacing.formInputHeight}px; + text-align: center; + border-radius: ${theme.border.radius.sm}; + border: 1px solid ${theme.colors.formInputBorder}; + ${getFocusStyle(theme)}; + `, + sliderInputFieldVertical: css` + margin: 0 0 ${theme.spacing.lg} 0; + order: 1; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/Slider/types.ts b/packages/grafana-ui/src/components/Slider/types.ts new file mode 100644 index 00000000000..7838465b79c --- /dev/null +++ b/packages/grafana-ui/src/components/Slider/types.ts @@ -0,0 +1,29 @@ +import { Orientation } from '../../types/orientation'; + +export interface SliderProps { + min: number; + max: number; + orientation?: Orientation; + /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ + value?: number; + reverse?: boolean; + step?: number; + tooltipAlwaysVisible?: boolean; + formatTooltipResult?: (value: number) => number; + onChange?: (value: number) => void; + onAfterChange?: (value?: number) => void; +} + +export interface RangeSliderProps { + min: number; + max: number; + orientation?: Orientation; + /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ + value?: number[]; + reverse?: boolean; + step?: number; + tooltipAlwaysVisible?: boolean; + formatTooltipResult?: (value: number) => number | string; + onChange?: (value: number[]) => void; + onAfterChange?: (value: number[]) => void; +} diff --git a/packages/grafana-ui/src/components/Table/ImageCell.tsx b/packages/grafana-ui/src/components/Table/ImageCell.tsx index 6ddfb318d02..ea137715b11 100644 --- a/packages/grafana-ui/src/components/Table/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/ImageCell.tsx @@ -2,11 +2,13 @@ import React, { FC } from 'react'; import { TableCellProps } from './types'; export const ImageCell: FC = props => { - const { cell, tableStyles, cellProps } = props; + const { field, cell, tableStyles, cellProps } = props; + + const displayValue = field.display!(cell.value); return (
- +
); }; diff --git a/packages/grafana-ui/src/components/Table/utils.test.ts b/packages/grafana-ui/src/components/Table/utils.test.ts index 8885f32e412..0653a8498c5 100644 --- a/packages/grafana-ui/src/components/Table/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/utils.test.ts @@ -5,6 +5,7 @@ import { getColumns, getFilteredOptions, getTextAlign, + rowToFieldValue, sortOptions, valuesToOptions, } from './utils'; @@ -71,28 +72,88 @@ describe('Table utils', () => { }); describe('filterByValue', () => { - it.each` - rows | id | filterValues | expected - ${[]} | ${'0'} | ${[{ value: 'a' }]} | ${[]} - ${[{ values: { 0: 'a' } }]} | ${'0'} | ${null} | ${[{ values: { 0: 'a' } }]} - ${[{ values: { 0: 'a' } }]} | ${'0'} | ${undefined} | ${[{ values: { 0: 'a' } }]} - ${[{ values: { 0: 'a' } }]} | ${'1'} | ${[{ value: 'b' }]} | ${[]} - ${[{ values: { 0: 'a' } }]} | ${'0'} | ${[{ value: 'a' }]} | ${[{ values: { 0: 'a' } }]} - ${[{ values: { 0: 'a' } }, { values: { 1: 'a' } }]} | ${'0'} | ${[{ value: 'a' }]} | ${[{ values: { 0: 'a' } }]} - ${[{ values: { 0: 'a' } }, { values: { 0: 'b' } }, { values: { 0: 'c' } }]} | ${'0'} | ${[{ value: 'a' }, { value: 'b' }]} | ${[{ values: { 0: 'a' } }, { values: { 0: 'b' } }]} - `( - "when called with rows: '$rows.toString()', id: '$id' and filterValues: '$filterValues' then result should be '$expected'", - ({ rows, id, filterValues, expected }) => { - expect(filterByValue(rows, id, filterValues)).toEqual(expected); - } - ); + describe('happy path', () => { + const field: any = { values: new ArrayVector(['a', 'aa', 'ab', 'b', 'ba', 'bb', 'c']) }; + const rows: any = [ + { index: 0, values: { 0: 'a' } }, + { index: 1, values: { 0: 'aa' } }, + { index: 2, values: { 0: 'ab' } }, + { index: 3, values: { 0: 'b' } }, + { index: 4, values: { 0: 'ba' } }, + { index: 5, values: { 0: 'bb' } }, + { index: 6, values: { 0: 'c' } }, + ]; + const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }]; + + const result = filterByValue(field)(rows, '0', filterValues); + + expect(result).toEqual([ + { index: 0, values: { 0: 'a' } }, + { index: 3, values: { 0: 'b' } }, + { index: 6, values: { 0: 'c' } }, + ]); + }); + + describe('fast exit cases', () => { + describe('no rows', () => { + it('should return empty array', () => { + const field: any = { values: new ArrayVector(['a']) }; + const rows: any = []; + const filterValues = [{ value: 'a' }]; + + const result = filterByValue(field)(rows, '', filterValues); + + expect(result).toEqual([]); + }); + }); + + describe('no filterValues', () => { + it('should return rows', () => { + const field: any = { values: new ArrayVector(['a']) }; + const rows: any = [{}]; + const filterValues = undefined; + + const result = filterByValue(field)(rows, '', filterValues); + + expect(result).toEqual([{}]); + }); + }); + + describe('no field', () => { + it('should return rows', () => { + const field = undefined; + const rows: any = [{}]; + const filterValues = [{ value: 'a' }]; + + const result = filterByValue(field)(rows, '', filterValues); + + expect(result).toEqual([{}]); + }); + }); + + describe('missing id in values', () => { + it('should return rows', () => { + const field: any = { values: new ArrayVector(['a', 'b', 'c']) }; + const rows: any = [ + { index: 0, values: { 0: 'a' } }, + { index: 1, values: { 0: 'b' } }, + { index: 2, values: { 0: 'c' } }, + ]; + const filterValues = [{ value: 'a' }, { value: 'b' }, { value: 'c' }]; + + const result = filterByValue(field)(rows, '1', filterValues); + + expect(result).toEqual([]); + }); + }); + }); }); describe('calculateUniqueFieldValues', () => { describe('when called without field', () => { it('then it should return an empty object', () => { const field = undefined; - const rows = [{ id: 0 }]; + const rows = [{ index: 0 }]; const result = calculateUniqueFieldValues(rows, field); @@ -142,15 +203,15 @@ describe('Table utils', () => { text: `${value}.0`, })), }; - const rows: any[] = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const rows: any[] = [{ index: 0 }, { index: 1 }, { index: 2 }, { index: 3 }, { index: 4 }]; const result = calculateUniqueFieldValues(rows, field); expect(field.display).toHaveBeenCalledTimes(5); expect(result).toEqual({ - '1.0': 1, - '2.0': 2, - '3.0': 3, + '1.0': '1.0', + '2.0': '2.0', + '3.0': '3.0', }); }); }); @@ -163,7 +224,7 @@ describe('Table utils', () => { name: 'value', type: FieldType.number, }; - const rows: any[] = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const rows: any[] = [{ index: 0 }, { index: 1 }, { index: 2 }, { index: 3 }, { index: 4 }]; const result = calculateUniqueFieldValues(rows, field); @@ -182,7 +243,7 @@ describe('Table utils', () => { name: 'value', type: FieldType.number, }; - const rows: any[] = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const rows: any[] = [{ index: 0 }, { index: 1 }, { index: 2 }, { index: 3 }, { index: 4 }]; const result = calculateUniqueFieldValues(rows, field); @@ -196,6 +257,59 @@ describe('Table utils', () => { }); }); + describe('rowToFieldValue', () => { + describe('happy paths', () => { + describe('field without field display', () => { + const field: any = { values: new ArrayVector(['a', 'b', 'c']) }; + const row = { index: 1 }; + + const result = rowToFieldValue(row, field); + + expect(result).toEqual('b'); + }); + + describe('field with display processor', () => { + const field: Field = { + config: {}, + values: new ArrayVector([1, 2, 2, 1, 3, 5, 6]), + name: 'value', + type: FieldType.number, + display: jest.fn((value: any) => ({ + numeric: 1, + percent: 0.01, + color: '', + title: `${value}.0`, + text: `${value}.0`, + })), + }; + const row = { index: 4 }; + + const result = rowToFieldValue(row, field); + + expect(result).toEqual('3.0'); + }); + }); + + describe('quick exist paths', () => { + describe('field is missing', () => { + const field = undefined; + const row = { index: 0 }; + + const result = rowToFieldValue(row, field); + + expect(result).toEqual(''); + }); + describe('row is missing', () => { + const field: any = { values: new ArrayVector(['a', 'b', 'c']) }; + const row = undefined; + + const result = rowToFieldValue(row, field); + + expect(result).toEqual(''); + }); + }); + }); + describe('valuesToOptions', () => { describe('when called with a record object', () => { it('then it should return sorted options from that object', () => { diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index bf9686fcefa..63da460c9fa 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -78,7 +78,7 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid sortType: selectSortType(field.type), width: fieldTableOptions.width, minWidth: 50, - filter: memoizeOne(filterByValue), + filter: memoizeOne(filterByValue(field)), justifyContent: getTextAlign(field), }); } @@ -116,23 +116,28 @@ function getCellComponent(displayMode: TableCellDisplayMode, field: Field) { return DefaultCell; } -export function filterByValue(rows: Row[], id: string, filterValues?: SelectableValue[]) { - if (rows.length === 0) { - return rows; - } - - if (!filterValues) { - return rows; - } - - return rows.filter(row => { - if (!row.values.hasOwnProperty(id)) { - return false; +export function filterByValue(field?: Field) { + return function(rows: Row[], id: string, filterValues?: SelectableValue[]) { + if (rows.length === 0) { + return rows; } - const value = row.values[id]; - return filterValues.find(filter => filter.value === value) !== undefined; - }); + if (!filterValues) { + return rows; + } + + if (!field) { + return rows; + } + + return rows.filter(row => { + if (!row.values.hasOwnProperty(id)) { + return false; + } + const value = rowToFieldValue(row, field); + return filterValues.find(filter => filter.value === value) !== undefined; + }); + }; } export function calculateUniqueFieldValues(rows: any[], field?: Field) { @@ -143,16 +148,25 @@ export function calculateUniqueFieldValues(rows: any[], field?: Field) { const set: Record = {}; for (let index = 0; index < rows.length; index++) { - const fieldIndex = parseInt(rows[index].id, 10); - const fieldValue = field.values.get(fieldIndex); - const displayValue = field.display ? field.display(fieldValue) : fieldValue; - const value = field.display ? formattedValueToString(displayValue) : displayValue; - set[value || '(Blanks)'] = fieldValue; + const value = rowToFieldValue(rows[index], field); + set[value || '(Blanks)'] = value; } return set; } +export function rowToFieldValue(row: any, field?: Field): string { + if (!field || !row) { + return ''; + } + + const fieldValue = field.values.get(row.index); + const displayValue = field.display ? field.display(fieldValue) : fieldValue; + const value = field.display ? formattedValueToString(displayValue) : displayValue; + + return value; +} + export function valuesToOptions(unique: Record): SelectableValue[] { return Object.keys(unique) .reduce((all, key) => all.concat({ value: unique[key], label: key }), [] as SelectableValue[]) diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index e5aa26a2149..cb27db0006f 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -14,7 +14,7 @@ export interface TabProps extends HTMLProps { /** When provided, it is possible to use the tab as a hyperlink. Use in cases where the tabs update location. */ href?: string; icon?: IconName; - onChangeTab: (event?: React.MouseEvent) => void; + onChangeTab?: (event?: React.MouseEvent) => void; /** A number rendered next to the text. Usually used to display the number of items in a tab's view. */ counter?: number | null; } diff --git a/packages/grafana-ui/src/components/Tags/Tag.tsx b/packages/grafana-ui/src/components/Tags/Tag.tsx index 275d14d0764..9d18a88565a 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.tsx @@ -4,6 +4,9 @@ import { GrafanaTheme } from '@grafana/data'; import { useTheme } from '../../themes'; import { getTagColor, getTagColorsFromName } from '../../utils'; +/** + * @public + */ export type OnTagClick = (name: string, event: React.MouseEvent) => any; export interface Props extends Omit, 'onClick'> { diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index 844342ee18e..cb3ca0b486d 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -29,7 +29,9 @@ const getStyles = () => { flex-wrap: wrap; `, tag: css` - margin-left: 6px; + &:not(:first-child) { + margin-left: 6px; + } `, }; }; diff --git a/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx index ee1c6b06393..0d359490e8a 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.test.tsx @@ -191,4 +191,28 @@ describe('ThresholdsEditor', () => { ]); }); }); + + describe('on load with invalid steps', () => { + it('should exclude invalid steps and render a proper list', () => { + const { instance } = setup({ + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: '#7EB26D', key: 1 }, + { value: 75, color: '#6ED0E0', key: 2 }, + { color: '#7EB26D', key: 3 } as any, + { value: 78, color: '#EAB839', key: 4 }, + { value: null, color: '#7EB26D', key: 5 } as any, + { value: null, color: '#7EB26D', key: 6 } as any, + ], + }, + }); + + expect(getCurrentThresholds(instance).steps).toEqual([ + { value: -Infinity, color: '#7EB26D' }, + { value: 75, color: '#6ED0E0' }, + { value: 78, color: '#EAB839' }, + ]); + }); + }); }); diff --git a/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx index 54b093dcf24..2ef18af331f 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx @@ -18,6 +18,7 @@ import { RadioButtonGroup } from '../Forms/RadioButtonGroup/RadioButtonGroup'; import { Button } from '../Button'; import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer'; import { Label } from '../Forms/Label'; +import { isNumber } from 'lodash'; const modes: Array> = [ { value: ThresholdsMode.Absolute, label: 'Absolute', description: 'Pick thresholds based on the absolute values' }, @@ -249,13 +250,15 @@ function toThresholdsWithKey(steps?: Threshold[]): ThresholdWithKey[] { steps = [{ value: -Infinity, color: 'green' }]; } - return steps.map(t => { - return { - color: t.color, - value: t.value === null ? -Infinity : t.value, - key: counter++, - }; - }); + return steps + .filter((t, i) => isNumber(t.value) || i === 0) + .map(t => { + return { + color: t.color, + value: t.value === null ? -Infinity : t.value, + key: counter++, + }; + }); } export function thresholdsWithoutKey(thresholds: ThresholdsConfig, steps: ThresholdWithKey[]): ThresholdsConfig { diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index f8c8d5bf5ef..9679f43e35f 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -13,7 +13,7 @@ export interface PopoverContentProps { export type PopoverContent = string | React.ReactElement | ((props: PopoverContentProps) => JSX.Element); -export const Tooltip: FC = ({ children, theme, ...controllerProps }: TooltipProps) => { +export const Tooltip: FC = React.memo(({ children, theme, ...controllerProps }: TooltipProps) => { const tooltipTriggerRef = createRef(); const popperBackgroundClassName = 'popper__background' + (theme ? ' popper__background--' + theme : ''); @@ -52,4 +52,4 @@ export const Tooltip: FC = ({ children, theme, ...controllerProps }} ); -}; +}); diff --git a/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx b/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx index 235b1007c91..aa1f523542a 100644 --- a/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx +++ b/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react'; import { css, cx } from 'emotion'; import { CompletionItem, selectThemeVariant, ThemeContext } from '../..'; -import { GrafanaTheme, renderMarkdown, textUtil } from '@grafana/data'; +import { GrafanaTheme, renderMarkdown } from '@grafana/data'; const getStyles = (theme: GrafanaTheme, height: number, visible: boolean) => { return { @@ -41,7 +41,7 @@ interface Props { export const TypeaheadInfo: React.FC = ({ item, height }) => { const visible = item && !!item.documentation; const label = item ? item.label : ''; - const documentation = textUtil.sanitize(renderMarkdown(item?.documentation)); + const documentation = renderMarkdown(item?.documentation); const theme = useContext(ThemeContext); const styles = getStyles(theme, height, visible); diff --git a/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx b/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx new file mode 100644 index 00000000000..94d2cd8bb9b --- /dev/null +++ b/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { number } from '@storybook/addon-knobs'; +import { VizLayout } from './VizLayout'; + +export default { + title: 'Visualizations/VizLayout', + component: VizLayout, + decorators: [withCenteredStory], + parameters: { + docs: {}, + }, +}; + +const getKnobs = () => { + return { + legendWidth: number('legendWidth', 100), + legendItems: number('legendItems', 2), + }; +}; + +export const BottomLegend = () => { + const { legendItems } = getKnobs(); + const items = Array.from({ length: legendItems }, (_, i) => i + 1); + + const legend = ( + + {items.map((_, index) => ( +
+ Legend item {index} +
+ ))} +
+ ); + + return ( + + {(vizWidth: number, vizHeight: number) => { + return
; + }} + + ); +}; + +export const RightLegend = () => { + const { legendItems, legendWidth } = getKnobs(); + const items = Array.from({ length: legendItems }, (_, i) => i + 1); + + const legend = ( + + {items.map((_, index) => ( +
+ Legend item {index} +
+ ))} +
+ ); + + return ( + + {(vizWidth: number, vizHeight: number) => { + return
; + }} + + ); +}; diff --git a/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx new file mode 100644 index 00000000000..692757540b4 --- /dev/null +++ b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx @@ -0,0 +1,99 @@ +import React, { FC, CSSProperties, ComponentType } from 'react'; +import { useMeasure } from 'react-use'; +import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; + +/** + * @beta + */ +export interface VizLayoutProps { + width: number; + height: number; + legend?: React.ReactElement; + children: (width: number, height: number) => React.ReactNode; +} + +/** + * @beta + */ +export interface VizLayoutComponentType extends FC { + Legend: ComponentType; +} + +/** + * @beta + */ +export const VizLayout: VizLayoutComponentType = ({ width, height, legend, children }) => { + const containerStyle: CSSProperties = { + display: 'flex', + width: `${width}px`, + height: `${height}px`, + }; + + if (!legend) { + return
{children(width, height)}
; + } + + const { position, maxHeight, maxWidth } = legend.props; + const [legendRef, legendMeasure] = useMeasure(); + let size: VizSize | null = null; + + const vizStyle: CSSProperties = { + flexGrow: 1, + }; + + const legendStyle: CSSProperties = { + flexGrow: 1, + }; + + switch (position) { + case 'bottom': + containerStyle.flexDirection = 'column'; + legendStyle.maxHeight = maxHeight; + + if (legendMeasure) { + size = { width, height: height - legendMeasure.height }; + } + break; + case 'right': + containerStyle.flexDirection = 'row'; + legendStyle.maxWidth = maxWidth; + + if (legendMeasure) { + size = { width: width - legendMeasure.width, height }; + } + break; + } + + return ( +
+
{size && children(size.width, size.height)}
+
+ {legend} +
+
+ ); +}; + +interface VizSize { + width: number; + height: number; +} + +/** + * @beta + */ +export interface VizLayoutLegendProps { + position: 'bottom' | 'right'; + maxHeight?: string; + maxWidth?: string; + children: React.ReactNode; +} + +/** + * @beta + */ +export const VizLayoutLegend: FC = ({ children }) => { + return <>{children}; +}; + +VizLayout.Legend = VizLayoutLegend; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 88a7b8db4b0..1edeed625f7 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -13,7 +13,7 @@ export { ClipboardButton } from './ClipboardButton/ClipboardButton'; export { Cascader, CascaderOption } from './Cascader/Cascader'; export { ButtonCascader } from './ButtonCascader/ButtonCascader'; -export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder'; +export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholder/LoadingPlaceholder'; export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker'; export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover'; export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup'; @@ -29,7 +29,7 @@ export { TimeZonePicker } from './TimePicker/TimeZonePicker'; export { List } from './List/List'; export { TagsInput } from './TagsInput/TagsInput'; export { Pagination } from './Pagination/Pagination'; -export { Tag } from './Tags/Tag'; +export { Tag, OnTagClick } from './Tags/Tag'; export { TagList } from './Tags/TagList'; export { FilterPill } from './FilterPill/FilterPill'; @@ -78,6 +78,7 @@ export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge'; export { GraphTooltipOptions } from './Graph/GraphTooltip/types'; export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater'; export { graphTimeFormat, graphTickFormatter } from './Graph/utils'; +export { VizLayout, VizLayoutComponentType, VizLayoutLegendProps, VizLayoutProps } from './VizLayout/VizLayout'; export { LegendOptions, @@ -104,6 +105,7 @@ export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper'; export * from './SingleStatShared/index'; export { CallToActionCard } from './CallToActionCard/CallToActionCard'; export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu'; +export { WithContextMenu } from './ContextMenu/WithContextMenu'; export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor'; export { DataLinkInput } from './DataLinks/DataLinkInput'; export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu'; @@ -125,11 +127,13 @@ export { default as Chart } from './Chart'; export { TooltipContainer } from './Chart/TooltipContainer'; export { Drawer } from './Drawer/Drawer'; export { Slider } from './Slider/Slider'; +export { RangeSlider } from './Slider/RangeSlider'; // TODO: namespace!! export { StringValueEditor } from './OptionsUI/string'; export { StringArrayEditor } from './OptionsUI/strings'; export { NumberValueEditor } from './OptionsUI/number'; +export { SliderValueEditor } from './OptionsUI/slider'; export { SelectValueEditor } from './OptionsUI/select'; export { FieldConfigItemHeaderTitle } from './FieldConfigs/FieldConfigItemHeaderTitle'; @@ -168,6 +172,7 @@ export { Checkbox } from './Forms/Checkbox'; export { TextArea } from './TextArea/TextArea'; export { FileUpload } from './FileUpload/FileUpload'; export { TimeRangeInput } from './TimePicker/TimeRangeInput'; +export { Card, Props as CardProps, ContainerProps, CardInnerProps, getCardStyles } from './Card/Card'; // Legacy forms diff --git a/packages/grafana-ui/src/utils/colors.ts b/packages/grafana-ui/src/utils/colors.ts index 15c393c885b..9156ef5bab1 100644 --- a/packages/grafana-ui/src/utils/colors.ts +++ b/packages/grafana-ui/src/utils/colors.ts @@ -6,14 +6,39 @@ import zip from 'lodash/zip'; import tinycolor from 'tinycolor2'; import lightTheme from '../themes/light'; import darkTheme from '../themes/dark'; +import { GrafanaTheme } from '@grafana/data'; +import { AlertVariant } from '../components/Alert/Alert'; -export const PALETTE_ROWS = 4; -export const PALETTE_COLUMNS = 14; +const PALETTE_ROWS = 4; + +/** + * @alpha + */ export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)'; + +/** + * @alpha + */ export const OK_COLOR = 'rgba(11, 237, 50, 1)'; + +/** + * @alpha + */ export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)'; + +/** + * @alpha + */ export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)'; + +/** + * @alpha + */ export const PENDING_COLOR = 'rgba(247, 149, 32, 1)'; + +/** + * @alpha + */ export const REGION_FILL_ALPHA = 0.09; export const colors = [ '#7EB26D', // 0: pale green @@ -97,7 +122,25 @@ function hslToHex(color: any) { export function getTextColorForBackground(color: string) { const b = tinycolor(color).getBrightness(); - return b > 150 ? lightTheme.colors.textStrong : darkTheme.colors.textStrong; + return b > 180 ? lightTheme.colors.textStrong : darkTheme.colors.textStrong; } export let sortedColors = sortColorsByHue(colors); + +/** + * Returns colors used for severity color coding. Use for single color retrievel(0 index) or gradient definition + * @internal + **/ +export function getColorsFromSeverity(severity: AlertVariant, theme: GrafanaTheme): [string, string] { + switch (severity) { + case 'error': + case 'warning': + return [theme.palette.redBase, theme.palette.redShade]; + case 'info': + return [theme.palette.blue80, theme.palette.blue77]; + case 'success': + return [theme.palette.greenBase, theme.palette.greenShade]; + default: + return [theme.palette.blue80, theme.palette.blue77]; + } +} diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx index c70a279478a..85a2ee12a8a 100644 --- a/packages/grafana-ui/src/utils/standardEditors.tsx +++ b/packages/grafana-ui/src/utils/standardEditors.tsx @@ -20,11 +20,13 @@ import { identityOverrideProcessor, TimeZone, FieldColor, + FieldColorConfigSettings, } from '@grafana/data'; import { Switch } from '../components/Switch/Switch'; import { NumberValueEditor, + SliderValueEditor, RadioButtonGroup, StringValueEditor, StringArrayEditor, @@ -203,15 +205,18 @@ export const getStandardFieldConfigs = () => { getItemsCount: value => (value ? value.length : 0), }; - const color: FieldConfigPropertyItem = { + const color: FieldConfigPropertyItem = { id: 'color', path: 'color', name: 'Color scheme', - description: 'Select palette, gradient or single color', editor: standardEditorsRegistry.get('fieldColor').editor as any, override: standardEditorsRegistry.get('fieldColor').editor as any, process: identityOverrideProcessor, shouldApply: () => true, + settings: { + byValueSupport: true, + preferThresholdsMode: true, + }, category, }; @@ -229,6 +234,13 @@ export const getStandardOptionEditors = () => { editor: NumberValueEditor as any, }; + const slider: StandardEditorsRegistryItem = { + id: 'slider', + name: 'Slider', + description: 'Allows numeric values input', + editor: SliderValueEditor as any, + }; + const text: StandardEditorsRegistryItem = { id: 'text', name: 'Text', @@ -323,6 +335,7 @@ export const getStandardOptionEditors = () => { return [ text, number, + slider, boolean, radio, select, diff --git a/packaging/docker/custom/Dockerfile b/packaging/docker/custom/Dockerfile index 8ead865dedb..9a6fcf93e3b 100644 --- a/packaging/docker/custom/Dockerfile +++ b/packaging/docker/custom/Dockerfile @@ -6,10 +6,11 @@ USER root ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" +ARG GF_GID="0" ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" RUN mkdir -p "$GF_PATHS_PLUGINS" && \ - chown -R grafana:grafana "$GF_PATHS_PLUGINS" + chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \ diff --git a/packaging/docker/custom/ubuntu.Dockerfile b/packaging/docker/custom/ubuntu.Dockerfile index 88a2bf1b638..a6c7a8ce485 100644 --- a/packaging/docker/custom/ubuntu.Dockerfile +++ b/packaging/docker/custom/ubuntu.Dockerfile @@ -9,10 +9,11 @@ ARG DEBIAN_FRONTEND=noninteractive ARG GF_INSTALL_IMAGE_RENDERER_PLUGIN="false" +ARG GF_GID="0" ENV GF_PATHS_PLUGINS="/var/lib/grafana-plugins" RUN mkdir -p "$GF_PATHS_PLUGINS" && \ - chown -R grafana:grafana "$GF_PATHS_PLUGINS" + chown -R grafana:${GF_GID} "$GF_PATHS_PLUGINS" RUN if [ $GF_INSTALL_IMAGE_RENDERER_PLUGIN = "true" ]; then \ apt-get update && \ diff --git a/pkg/api/admin_provisioning.go b/pkg/api/admin_provisioning.go index 9a845c4654d..765daa97e67 100644 --- a/pkg/api/admin_provisioning.go +++ b/pkg/api/admin_provisioning.go @@ -6,32 +6,32 @@ import ( "github.com/grafana/grafana/pkg/models" ) -func (server *HTTPServer) AdminProvisioningReloadDashboards(c *models.ReqContext) Response { - err := server.ProvisioningService.ProvisionDashboards() +func (hs *HTTPServer) AdminProvisioningReloadDashboards(c *models.ReqContext) Response { + err := hs.ProvisioningService.ProvisionDashboards() if err != nil && err != context.Canceled { return Error(500, "", err) } return Success("Dashboards config reloaded") } -func (server *HTTPServer) AdminProvisioningReloadDatasources(c *models.ReqContext) Response { - err := server.ProvisioningService.ProvisionDatasources() +func (hs *HTTPServer) AdminProvisioningReloadDatasources(c *models.ReqContext) Response { + err := hs.ProvisioningService.ProvisionDatasources() if err != nil { return Error(500, "", err) } return Success("Datasources config reloaded") } -func (server *HTTPServer) AdminProvisioningReloadPlugins(c *models.ReqContext) Response { - err := server.ProvisioningService.ProvisionPlugins() +func (hs *HTTPServer) AdminProvisioningReloadPlugins(c *models.ReqContext) Response { + err := hs.ProvisioningService.ProvisionPlugins() if err != nil { return Error(500, "Failed to reload plugins config", err) } return Success("Plugins config reloaded") } -func (server *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext) Response { - err := server.ProvisioningService.ProvisionNotifications() +func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext) Response { + err := hs.ProvisioningService.ProvisionNotifications() if err != nil { return Error(500, "", err) } diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 8ab17970e96..ddd85d25b5f 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -121,7 +121,7 @@ func AdminDeleteUser(c *models.ReqContext) Response { } // POST /api/admin/users/:id/disable -func (server *HTTPServer) AdminDisableUser(c *models.ReqContext) Response { +func (hs *HTTPServer) AdminDisableUser(c *models.ReqContext) Response { userID := c.ParamsInt64(":id") // External users shouldn't be disabled from API @@ -138,7 +138,7 @@ func (server *HTTPServer) AdminDisableUser(c *models.ReqContext) Response { return Error(500, "Failed to disable user", err) } - err := server.AuthTokenService.RevokeAllUserTokens(c.Req.Context(), userID) + err := hs.AuthTokenService.RevokeAllUserTokens(c.Req.Context(), userID) if err != nil { return Error(500, "Failed to disable user", err) } @@ -168,24 +168,24 @@ func AdminEnableUser(c *models.ReqContext) Response { } // POST /api/admin/users/:id/logout -func (server *HTTPServer) AdminLogoutUser(c *models.ReqContext) Response { +func (hs *HTTPServer) AdminLogoutUser(c *models.ReqContext) Response { userID := c.ParamsInt64(":id") if c.UserId == userID { return Error(400, "You cannot logout yourself", nil) } - return server.logoutUserFromAllDevicesInternal(c.Req.Context(), userID) + return hs.logoutUserFromAllDevicesInternal(c.Req.Context(), userID) } // GET /api/admin/users/:id/auth-tokens -func (server *HTTPServer) AdminGetUserAuthTokens(c *models.ReqContext) Response { +func (hs *HTTPServer) AdminGetUserAuthTokens(c *models.ReqContext) Response { userID := c.ParamsInt64(":id") - return server.getUserAuthTokensInternal(c, userID) + return hs.getUserAuthTokensInternal(c, userID) } // POST /api/admin/users/:id/revoke-auth-token -func (server *HTTPServer) AdminRevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response { +func (hs *HTTPServer) AdminRevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response { userID := c.ParamsInt64(":id") - return server.revokeUserAuthTokenInternal(c, userID, cmd) + return hs.revokeUserAuthTokenInternal(c, userID, cmd) } diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 99416091f2f..8f5dd648c5b 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -111,7 +111,7 @@ func TestAdminApiEndpoint(t *testing.T) { respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) So(err, ShouldBeNil) - So(respJSON.Get("message").MustString(), ShouldEqual, "User not found") + So(respJSON.Get("message").MustString(), ShouldEqual, "user not found") So(userId, ShouldEqual, 42) So(isDisabled, ShouldEqual, false) @@ -124,7 +124,7 @@ func TestAdminApiEndpoint(t *testing.T) { respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) So(err, ShouldBeNil) - So(respJSON.Get("message").MustString(), ShouldEqual, "User not found") + So(respJSON.Get("message").MustString(), ShouldEqual, "user not found") So(userId, ShouldEqual, 42) So(isDisabled, ShouldEqual, true) @@ -175,7 +175,7 @@ func TestAdminApiEndpoint(t *testing.T) { respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) So(err, ShouldBeNil) - So(respJSON.Get("message").MustString(), ShouldEqual, "User not found") + So(respJSON.Get("message").MustString(), ShouldEqual, "user not found") So(userId, ShouldEqual, 42) }) @@ -252,7 +252,7 @@ func TestAdminApiEndpoint(t *testing.T) { respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) So(err, ShouldBeNil) - So(respJSON.Get("message").MustString(), ShouldEqual, "Organization not found") + So(respJSON.Get("message").MustString(), ShouldEqual, "organization not found") So(userLogin, ShouldEqual, TestLogin) So(orgId, ShouldEqual, 1000) @@ -276,7 +276,7 @@ func TestAdminApiEndpoint(t *testing.T) { respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) So(err, ShouldBeNil) - So(respJSON.Get("error").MustString(), ShouldEqual, "User already exists") + So(respJSON.Get("error").MustString(), ShouldEqual, "user already exists") }) }) } diff --git a/pkg/api/api.go b/pkg/api/api.go index ebd5ff713ed..7986b0be5bf 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/models" ) +// registerRoutes registers all API HTTP routes. func (hs *HTTPServer) registerRoutes() { reqSignedIn := middleware.ReqSignedIn reqGrafanaAdmin := middleware.ReqGrafanaAdmin @@ -435,11 +436,6 @@ func (hs *HTTPServer) registerRoutes() { avatarCacheServer := avatar.NewCacheServer() r.Get("/avatar/:hash", avatarCacheServer.Handler) - // Live streaming - if hs.Live != nil { - r.Any("/live/*", hs.Live.WebsocketHandler) - } - // Snapshots r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, bind(models.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", reqSignedIn, GetSharingOptions) diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 28e03e3b743..9eb99db8b3b 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -50,20 +50,20 @@ func New(hash string) *Avatar { } } -func (this *Avatar) Expired() bool { - return time.Since(this.timestamp) > (time.Minute * 10) +func (a *Avatar) Expired() bool { + return time.Since(a.timestamp) > (time.Minute * 10) } -func (this *Avatar) Encode(wr io.Writer) error { - _, err := wr.Write(this.data.Bytes()) +func (a *Avatar) Encode(wr io.Writer) error { + _, err := wr.Write(a.data.Bytes()) return err } -func (this *Avatar) Update() (err error) { +func (a *Avatar) Update() (err error) { select { case <-time.After(time.Second * 3): - err = fmt.Errorf("get gravatar image %s timeout", this.hash) - case err = <-thunder.GoFetch(gravatarSource+this.hash+"?"+this.reqParams, this): + err = fmt.Errorf("get gravatar image %s timeout", a.hash) + case err = <-thunder.GoFetch(gravatarSource+a.hash+"?"+a.reqParams, a): } return err } @@ -75,7 +75,7 @@ type CacheServer struct { var validMD5 = regexp.MustCompile("^[a-fA-F0-9]{32}$") -func (this *CacheServer) Handler(ctx *models.ReqContext) { +func (a *CacheServer) Handler(ctx *models.ReqContext) { hash := ctx.Params("hash") if len(hash) != 32 || !validMD5.MatchString(hash) { @@ -84,7 +84,7 @@ func (this *CacheServer) Handler(ctx *models.ReqContext) { } var avatar *Avatar - obj, exists := this.cache.Get(hash) + obj, exists := a.cache.Get(hash) if exists { avatar = obj.(*Avatar) } else { @@ -95,14 +95,14 @@ func (this *CacheServer) Handler(ctx *models.ReqContext) { // The cache item is either expired or newly created, update it from the server if err := avatar.Update(); err != nil { log.Tracef("avatar update error: %v", err) - avatar = this.notFound + avatar = a.notFound } } if avatar.notFound { - avatar = this.notFound + avatar = a.notFound } else if !exists { - if err := this.cache.Add(hash, avatar, gocache.DefaultExpiration); err != nil { + if err := a.cache.Add(hash, avatar, gocache.DefaultExpiration); err != nil { log.Tracef("Error adding avatar to cache: %s", err) } } @@ -195,9 +195,9 @@ type thunderTask struct { err error } -func (this *thunderTask) Fetch() { - this.err = this.fetch() - this.Done() +func (a *thunderTask) Fetch() { + a.err = a.fetch() + a.Done() } var client = &http.Client{ @@ -205,11 +205,11 @@ var client = &http.Client{ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, } -func (this *thunderTask) fetch() error { - this.Avatar.timestamp = time.Now() +func (a *thunderTask) fetch() error { + a.Avatar.timestamp = time.Now() - log.Debugf("avatar.fetch(fetch new avatar): %s", this.Url) - req, _ := http.NewRequest("GET", this.Url, nil) + log.Debugf("avatar.fetch(fetch new avatar): %s", a.Url) + req, _ := http.NewRequest("GET", a.Url, nil) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/jpeg,image/png,*/*;q=0.8") req.Header.Set("Accept-Encoding", "deflate,sdch") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8") @@ -217,19 +217,19 @@ func (this *thunderTask) fetch() error { req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.154 Safari/537.36") resp, err := client.Do(req) if err != nil { - this.Avatar.notFound = true + a.Avatar.notFound = true return fmt.Errorf("gravatar unreachable, %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { - this.Avatar.notFound = true + a.Avatar.notFound = true return fmt.Errorf("status code: %d", resp.StatusCode) } - this.Avatar.data = &bytes.Buffer{} - writer := bufio.NewWriter(this.Avatar.data) + a.Avatar.data = &bytes.Buffer{} + writer := bufio.NewWriter(a.Avatar.data) _, err = io.Copy(writer, resp.Body) return err diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 13abc19e7f4..5015c9ce3e7 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -262,7 +262,7 @@ func (hs *HTTPServer) PostDashboard(c *models.ReqContext, cmd models.SaveDashboa } // Tell everyone listening that the dashboard changed - if hs.Live != nil { + if hs.Live.IsEnabled() { err := hs.Live.GrafanaScope.Dashboards.DashboardSaved( dashboard.Uid, c.UserId, @@ -356,14 +356,20 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) Response { return Error(500, "Failed to load home dashboard", err) } - if c.HasUserRole(models.ROLE_ADMIN) && !c.HasHelpFlag(models.HelpFlagGettingStartedPanelDismissed) { - addGettingStartedPanelToHomeDashboard(dash.Dashboard) - } + hs.addGettingStartedPanelToHomeDashboard(c, dash.Dashboard) return JSON(200, &dash) } -func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) { +func (hs *HTTPServer) addGettingStartedPanelToHomeDashboard(c *models.ReqContext, dash *simplejson.Json) { + // We only add this getting started panel for Admins who have not dismissed it, + // and if a custom default home dashboard hasn't been configured + if !c.HasUserRole(models.ROLE_ADMIN) || + c.HasHelpFlag(models.HelpFlagGettingStartedPanelDismissed) || + hs.Cfg.DefaultHomeDashboardPath != "" { + return + } + panels := dash.Get("panels").MustArray() newpanel := simplejson.NewFromAny(map[string]interface{}{ diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index d657b98809a..d5e0a47dc2b 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -57,7 +57,7 @@ func createExternalDashboardSnapshot(cmd models.CreateDashboardSnapshotCommand) defer response.Body.Close() if response.StatusCode != 200 { - return nil, fmt.Errorf("Create external snapshot response status code %d", response.StatusCode) + return nil, fmt.Errorf("create external snapshot response status code %d", response.StatusCode) } if err := json.NewDecoder(response.Body).Decode(&createSnapshotResponse); err != nil { @@ -196,7 +196,7 @@ func deleteExternalDashboardSnapshot(externalUrl string) error { } } - return fmt.Errorf("Unexpected response when deleting external snapshot. Status code: %d", response.StatusCode) + return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", response.StatusCode) } // GET /api/snapshots-delete/:deleteKey diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index c1a7926fb89..8870c1601a0 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" @@ -1129,6 +1130,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d Bus: bus.GetBus(), Cfg: setting.NewCfg(), ProvisioningService: provisioning.NewProvisioningServiceMock(), + Live: &live.GrafanaLive{Cfg: setting.NewCfg()}, } sc := setupScenarioContext(url) @@ -1188,6 +1190,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin Cfg: setting.NewCfg(), Bus: bus.GetBus(), ProvisioningService: provisioning.NewProvisioningServiceMock(), + Live: &live.GrafanaLive{Cfg: setting.NewCfg()}, } sc := setupScenarioContext(url) @@ -1243,7 +1246,7 @@ func (m mockDashboardProvisioningService) GetProvisionedDashboardData(name strin panic("implement me") } -func (mock mockDashboardProvisioningService) GetProvisionedDashboardDataByDashboardID(dashboardId int64) (*models.DashboardProvisioning, error) { +func (m mockDashboardProvisioningService) GetProvisionedDashboardDataByDashboardID(dashboardId int64) (*models.DashboardProvisioning, error) { return &models.DashboardProvisioning{}, nil } diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index e67f39e8dc0..a692268082f 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -9,21 +9,6 @@ import ( "github.com/grafana/grafana/pkg/models" ) -type AlertRule struct { - Id int64 `json:"id"` - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - Name string `json:"name"` - Message string `json:"message"` - State models.AlertStateType `json:"state"` - NewStateDate time.Time `json:"newStateDate"` - EvalDate time.Time `json:"evalDate"` - EvalData *simplejson.Json `json:"evalData"` - ExecutionError string `json:"executionError"` - Url string `json:"url"` - CanEdit bool `json:"canEdit"` -} - func formatShort(interval time.Duration) string { var result string diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index f7e3f674072..139fccdeca3 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -230,14 +230,17 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "isEnterprise": hs.License.HasValidLicense(), }, "licenseInfo": map[string]interface{}{ - "hasLicense": hs.License.HasLicense(), - "expiry": hs.License.Expiry(), - "stateInfo": hs.License.StateInfo(), - "licenseUrl": hs.License.LicenseURL(c.SignedInUser), + "hasLicense": hs.License.HasLicense(), + "hasValidLicense": hs.License.HasValidLicense(), + "expiry": hs.License.Expiry(), + "stateInfo": hs.License.StateInfo(), + "licenseUrl": hs.License.LicenseURL(c.SignedInUser), + "edition": hs.License.Edition(), }, "featureToggles": hs.Cfg.FeatureToggles, "rendererAvailable": hs.RenderService.IsAvailable(), "http2Enabled": hs.Cfg.Protocol == setting.HTTP2Scheme, + "marketplaceUrl": hs.Cfg.MarketplaceURL, } return jsonObj, nil diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index b1076c0de0a..b5e6cf24b76 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -75,22 +75,13 @@ type HTTPServer struct { SearchService *search.SearchService `inject:""` AlertNG *eval.AlertNG `inject:""` ShortURLService *shorturls.ShortURLService `inject:""` - Live *live.GrafanaLive + Live *live.GrafanaLive `inject:""` Listener net.Listener } func (hs *HTTPServer) Init() error { hs.log = log.New("http.server") - // Set up a websocket broker - if hs.Cfg.IsLiveEnabled() { // feature flag - node, err := live.InitializeBroker() - if err != nil { - return err - } - hs.Live = node - } - hs.macaron = hs.newMacaron() hs.registerRoutes() @@ -208,11 +199,11 @@ func (hs *HTTPServer) configureHttps() error { } if _, err := os.Stat(setting.CertFile); os.IsNotExist(err) { - return fmt.Errorf(`Cannot find SSL cert_file at %v`, setting.CertFile) + return fmt.Errorf(`cannot find SSL cert_file at %q`, setting.CertFile) } if _, err := os.Stat(setting.KeyFile); os.IsNotExist(err) { - return fmt.Errorf(`Cannot find SSL key_file at %v`, setting.KeyFile) + return fmt.Errorf(`cannot find SSL key_file at %q`, setting.KeyFile) } tlsCfg := &tls.Config{ @@ -250,11 +241,11 @@ func (hs *HTTPServer) configureHttp2() error { } if _, err := os.Stat(setting.CertFile); os.IsNotExist(err) { - return fmt.Errorf(`Cannot find SSL cert_file at %v`, setting.CertFile) + return fmt.Errorf(`cannot find SSL cert_file at %q`, setting.CertFile) } if _, err := os.Stat(setting.KeyFile); os.IsNotExist(err) { - return fmt.Errorf(`Cannot find SSL key_file at %v`, setting.KeyFile) + return fmt.Errorf(`cannot find SSL key_file at %q`, setting.KeyFile) } tlsCfg := &tls.Config{ diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index 32bccdaa0f9..95eb82bd0c9 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -21,7 +21,7 @@ var ( logger = log.New("LDAP.debug") errOrganizationNotFound = func(orgId int64) error { - return fmt.Errorf("Unable to find organization with ID '%d'", orgId) + return fmt.Errorf("unable to find organization with ID '%d'", orgId) } ) @@ -97,7 +97,7 @@ func (user *LDAPUserDTO) FetchOrgs() error { } // ReloadLDAPCfg reloads the LDAP configuration -func (server *HTTPServer) ReloadLDAPCfg() Response { +func (hs *HTTPServer) ReloadLDAPCfg() Response { if !ldap.IsEnabled() { return Error(http.StatusBadRequest, "LDAP is not enabled", nil) } @@ -110,7 +110,7 @@ func (server *HTTPServer) ReloadLDAPCfg() Response { } // GetLDAPStatus attempts to connect to all the configured LDAP servers and returns information on whenever they're available or not. -func (server *HTTPServer) GetLDAPStatus(c *models.ReqContext) Response { +func (hs *HTTPServer) GetLDAPStatus(c *models.ReqContext) Response { if !ldap.IsEnabled() { return Error(http.StatusBadRequest, "LDAP is not enabled", nil) } @@ -152,7 +152,7 @@ func (server *HTTPServer) GetLDAPStatus(c *models.ReqContext) Response { } // PostSyncUserWithLDAP enables a single Grafana user to be synchronized against LDAP -func (server *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) Response { +func (hs *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) Response { if !ldap.IsEnabled() { return Error(http.StatusBadRequest, "LDAP is not enabled", nil) } @@ -203,7 +203,7 @@ func (server *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) Response { return Error(http.StatusInternalServerError, "Failed to disable the user", err) } - err = server.AuthTokenService.RevokeAllUserTokens(c.Req.Context(), userId) + err = hs.AuthTokenService.RevokeAllUserTokens(c.Req.Context(), userId) if err != nil { return Error(http.StatusInternalServerError, "Failed to remove session tokens for the user", err) } @@ -231,7 +231,7 @@ func (server *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) Response { } // GetUserFromLDAP finds an user based on a username in LDAP. This helps illustrate how would the particular user be mapped in Grafana when synced. -func (server *HTTPServer) GetUserFromLDAP(c *models.ReqContext) Response { +func (hs *HTTPServer) GetUserFromLDAP(c *models.ReqContext) Response { if !ldap.IsEnabled() { return Error(http.StatusBadRequest, "LDAP is not enabled", nil) } diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index 19e89616621..b3c912dafec 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -145,7 +145,7 @@ func TestGetUserFromLDAPApiEndpoint_OrgNotfound(t *testing.T) { expected := ` { - "error": "Unable to find organization with ID '2'", + "error": "unable to find organization with ID '2'", "message": "An organization was not found - Please verify your LDAP configuration" } ` @@ -467,7 +467,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenUserNotFound(t *testing.T) { expected := ` { - "message": "User not found" + "message": "user not found" } ` @@ -509,7 +509,7 @@ func TestPostSyncUserWithLDAPAPIEndpoint_WhenGrafanaAdmin(t *testing.T) { expected := ` { - "error": "Did not find a user", + "error": "did not find a user", "message": "Refusing to sync grafana super admin \"ldap-daniel\" - it would be disabled" } ` diff --git a/pkg/api/login.go b/pkg/api/login.go index 566c27aa321..3fb73e2cdeb 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -3,7 +3,6 @@ package api import ( "encoding/hex" "errors" - "fmt" "net/http" "net/url" "strings" @@ -168,7 +167,7 @@ func (hs *HTTPServer) LoginAPIPing(c *models.ReqContext) Response { } func (hs *HTTPServer) LoginPost(c *models.ReqContext, cmd dtos.LoginCommand) Response { - action := "login" + authModule := "" var user *models.User var response *NormalResponse @@ -177,14 +176,13 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext, cmd dtos.LoginCommand) Res if err == nil && response.errMessage != "" { err = errors.New(response.errMessage) } - hs.SendLoginLog(&models.SendLoginLogCommand{ - ReqContext: c, - LogAction: action, + hs.HooksService.RunLoginHook(&models.LoginInfo{ + AuthModule: authModule, User: user, LoginUsername: cmd.User, HTTPStatus: response.status, Error: err, - }) + }, c) }() if setting.DisableLoginForm { @@ -200,9 +198,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext, cmd dtos.LoginCommand) Res } err := bus.Dispatch(authQuery) - if authQuery.AuthModule != "" { - action += fmt.Sprintf("-%s", authQuery.AuthModule) - } + authModule = authQuery.AuthModule if err != nil { response = Error(401, "Invalid username or password", err) if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts || err == models.ErrUserNotFound { @@ -324,11 +320,3 @@ func (hs *HTTPServer) RedirectResponseWithError(ctx *models.ReqContext, err erro return Redirect(setting.AppSubUrl + "/login") } - -func (hs *HTTPServer) SendLoginLog(cmd *models.SendLoginLogCommand) { - if err := bus.Dispatch(cmd); err != nil { - if err != bus.ErrHandlerNotFound { - hs.log.Warn("Error while sending login log", "err", err) - } - } -} diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 96c80af745b..472985418ac 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -38,8 +38,8 @@ func GenStateString() (string, error) { } func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { - loginInfo := LoginInformation{ - Action: "login-oauth", + loginInfo := models.LoginInfo{ + AuthModule: "oauth", } if setting.OAuthService == nil { hs.handleOAuthLoginError(ctx, loginInfo, LoginError{ @@ -50,7 +50,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { } name := ctx.Params(":name") - loginInfo.Action += fmt.Sprintf("-%s", name) + loginInfo.AuthModule = name connect, ok := social.SocialMap[name] if !ok { hs.handleOAuthLoginError(ctx, loginInfo, LoginError{ @@ -172,8 +172,8 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { return } - loginInfo.ExtUserInfo = buildExternalUserInfo(token, userInfo, name) - loginInfo.User, err = syncUser(ctx, loginInfo.ExtUserInfo, connect) + loginInfo.ExternalUser = *buildExternalUserInfo(token, userInfo, name) + loginInfo.User, err = syncUser(ctx, &loginInfo.ExternalUser, connect) if err != nil { hs.handleOAuthLoginErrorWithRedirect(ctx, loginInfo, err) return @@ -185,13 +185,8 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { return } - hs.SendLoginLog(&models.SendLoginLogCommand{ - ReqContext: ctx, - LogAction: loginInfo.Action, - User: loginInfo.User, - ExternalUser: loginInfo.ExtUserInfo, - HTTPStatus: http.StatusOK, - }) + loginInfo.HTTPStatus = http.StatusOK + hs.HooksService.RunLoginHook(&loginInfo, ctx) metrics.MApiLoginOAuth.Inc() if redirectTo, err := url.QueryUnescape(ctx.GetCookie("redirect_to")); err == nil && len(redirectTo) > 0 { @@ -280,36 +275,21 @@ type LoginError struct { Err error } -type LoginInformation struct { - Action string - User *models.User - ExtUserInfo *models.ExternalUserInfo -} - -func (hs *HTTPServer) handleOAuthLoginError(ctx *models.ReqContext, info LoginInformation, err LoginError) { +func (hs *HTTPServer) handleOAuthLoginError(ctx *models.ReqContext, info models.LoginInfo, err LoginError) { ctx.Handle(err.HttpStatus, err.PublicMessage, err.Err) - logErr := err.Err - if logErr == nil { - logErr = errors.New(err.PublicMessage) + info.Error = err.Err + if info.Error == nil { + info.Error = errors.New(err.PublicMessage) } + info.HTTPStatus = err.HttpStatus - hs.SendLoginLog(&models.SendLoginLogCommand{ - ReqContext: ctx, - LogAction: info.Action, - HTTPStatus: err.HttpStatus, - Error: logErr, - }) + hs.HooksService.RunLoginHook(&info, ctx) } -func (hs *HTTPServer) handleOAuthLoginErrorWithRedirect(ctx *models.ReqContext, info LoginInformation, err error, v ...interface{}) { +func (hs *HTTPServer) handleOAuthLoginErrorWithRedirect(ctx *models.ReqContext, info models.LoginInfo, err error, v ...interface{}) { hs.redirectWithError(ctx, err, v...) - hs.SendLoginLog(&models.SendLoginLogCommand{ - ReqContext: ctx, - LogAction: info.Action, - User: info.User, - ExternalUser: info.ExtUserInfo, - Error: err, - }) + info.Error = err + hs.HooksService.RunLoginHook(&info, ctx) } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 8ecc506c83b..f15daa5652f 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/hex" "errors" "fmt" @@ -18,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -318,6 +318,7 @@ func TestLoginPostRedirect(t *testing.T) { hs := &HTTPServer{ log: &FakeLogger{}, Cfg: setting.NewCfg(), + HooksService: &hooks.HooksService{}, License: &licensing.OSSLicensingService{}, AuthTokenService: auth.NewFakeUserAuthTokenService(), } @@ -591,22 +592,23 @@ func setupAuthProxyLoginTest(enableLoginToken bool) *scenarioContext { return sc } -type loginLogTestReceiver struct { - cmd *models.SendLoginLogCommand +type loginHookTest struct { + info *models.LoginInfo } -func (r *loginLogTestReceiver) SaveLoginLog(ctx context.Context, cmd *models.SendLoginLogCommand) error { - r.cmd = cmd - return nil +func (r *loginHookTest) LoginHook(loginInfo *models.LoginInfo, req *models.ReqContext) { + r.info = loginInfo } -func TestLoginPostSendLoginLog(t *testing.T) { +func TestLoginPostRunLokingHook(t *testing.T) { sc := setupScenarioContext("/login") + hookService := &hooks.HooksService{} hs := &HTTPServer{ log: log.New("test"), Cfg: setting.NewCfg(), License: &licensing.OSSLicensingService{}, AuthTokenService: auth.NewFakeUserAuthTokenService(), + HooksService: hookService, } sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) Response { @@ -617,28 +619,26 @@ func TestLoginPostSendLoginLog(t *testing.T) { return hs.LoginPost(c, cmd) }) - testReceiver := loginLogTestReceiver{} - bus.AddHandlerCtx("login-log-receiver", testReceiver.SaveLoginLog) - - type sendLoginLogCase struct { - desc string - authUser *models.User - authModule string - authErr error - cmd models.SendLoginLogCommand - } + testHook := loginHookTest{} + hookService.AddLoginHook(testHook.LoginHook) testUser := &models.User{ Id: 42, Email: "", } - testCases := []sendLoginLogCase{ + testCases := []struct { + desc string + authUser *models.User + authModule string + authErr error + info models.LoginInfo + }{ { desc: "invalid credentials", authErr: login.ErrInvalidCredentials, - cmd: models.SendLoginLogCommand{ - LogAction: "login", + info: models.LoginInfo{ + AuthModule: "", HTTPStatus: 401, Error: login.ErrInvalidCredentials, }, @@ -646,8 +646,8 @@ func TestLoginPostSendLoginLog(t *testing.T) { { desc: "user disabled", authErr: login.ErrUserDisabled, - cmd: models.SendLoginLogCommand{ - LogAction: "login", + info: models.LoginInfo{ + AuthModule: "", HTTPStatus: 401, Error: login.ErrUserDisabled, }, @@ -656,8 +656,8 @@ func TestLoginPostSendLoginLog(t *testing.T) { desc: "valid Grafana user", authUser: testUser, authModule: "grafana", - cmd: models.SendLoginLogCommand{ - LogAction: "login-grafana", + info: models.LoginInfo{ + AuthModule: "grafana", User: testUser, HTTPStatus: 200, }, @@ -666,8 +666,8 @@ func TestLoginPostSendLoginLog(t *testing.T) { desc: "valid LDAP user", authUser: testUser, authModule: "ldap", - cmd: models.SendLoginLogCommand{ - LogAction: "login-ldap", + info: models.LoginInfo{ + AuthModule: "ldap", User: testUser, HTTPStatus: 200, }, @@ -685,15 +685,15 @@ func TestLoginPostSendLoginLog(t *testing.T) { sc.m.Post(sc.url, sc.defaultHandler) sc.fakeReqNoAssertions("POST", sc.url).exec() - cmd := testReceiver.cmd - assert.Equal(t, c.cmd.LogAction, cmd.LogAction) - assert.Equal(t, "admin", cmd.LoginUsername) - assert.Equal(t, c.cmd.HTTPStatus, cmd.HTTPStatus) - assert.Equal(t, c.cmd.Error, cmd.Error) + info := testHook.info + assert.Equal(t, c.info.AuthModule, info.AuthModule) + assert.Equal(t, "admin", info.LoginUsername) + assert.Equal(t, c.info.HTTPStatus, info.HTTPStatus) + assert.Equal(t, c.info.Error, info.Error) - if c.cmd.User != nil { - require.NotEmpty(t, cmd.User) - assert.Equal(t, c.cmd.User.Id, cmd.User.Id) + if c.info.User != nil { + require.NotEmpty(t, info.User) + assert.Equal(t, c.info.User.Id, info.User.Id) } }) } diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index aa020c3a810..c422d26bb0b 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -218,30 +218,30 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) { func (proxy *DataSourceProxy) validateRequest() error { if !checkWhiteList(proxy.ctx, proxy.targetUrl.Host) { - return errors.New("Target url is not a valid target") + return errors.New("target URL is not a valid target") } if proxy.ds.Type == models.DS_PROMETHEUS { if proxy.ctx.Req.Request.Method == "DELETE" { - return errors.New("Deletes not allowed on proxied Prometheus datasource") + return errors.New("deletes not allowed on proxied Prometheus datasource") } if proxy.ctx.Req.Request.Method == "PUT" { - return errors.New("Puts not allowed on proxied Prometheus datasource") + return errors.New("puts not allowed on proxied Prometheus datasource") } if proxy.ctx.Req.Request.Method == "POST" && !(proxy.proxyPath == "api/v1/query" || proxy.proxyPath == "api/v1/query_range") { - return errors.New("Posts not allowed on proxied Prometheus datasource except on /query and /query_range") + return errors.New("posts not allowed on proxied Prometheus datasource except on /query and /query_range") } } if proxy.ds.Type == models.DS_ES { if proxy.ctx.Req.Request.Method == "DELETE" { - return errors.New("Deletes not allowed on proxied Elasticsearch datasource") + return errors.New("deletes not allowed on proxied Elasticsearch datasource") } if proxy.ctx.Req.Request.Method == "PUT" { - return errors.New("Puts not allowed on proxied Elasticsearch datasource") + return errors.New("puts not allowed on proxied Elasticsearch datasource") } if proxy.ctx.Req.Request.Method == "POST" && proxy.proxyPath != "_msearch" { - return errors.New("Posts not allowed on proxied Elasticsearch datasource except on /_msearch") + return errors.New("posts not allowed on proxied Elasticsearch datasource except on /_msearch") } } @@ -255,7 +255,7 @@ func (proxy *DataSourceProxy) validateRequest() error { if route.ReqRole.IsValid() { if !proxy.ctx.HasUserRole(route.ReqRole) { - return errors.New("Plugin proxy route access denied") + return errors.New("plugin proxy route access denied") } } diff --git a/pkg/api/pluginproxy/utils.go b/pkg/api/pluginproxy/utils.go index e9fd7facde9..61dcc04b6b6 100644 --- a/pkg/api/pluginproxy/utils.go +++ b/pkg/api/pluginproxy/utils.go @@ -40,7 +40,7 @@ func InterpolateURL(anURL *url.URL, route *plugins.AppPluginRoute, orgID int64, if err == nil { result, err = url.Parse(interpolatedResult) if err != nil { - return nil, fmt.Errorf("Error parsing plugin route url %v", err) + return nil, fmt.Errorf("error parsing plugin route URL: %w", err) } } } diff --git a/pkg/api/render.go b/pkg/api/render.go index 898e7ff929d..86320efb7a2 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -24,25 +24,25 @@ func (hs *HTTPServer) RenderToPng(c *models.ReqContext) { width, err := strconv.Atoi(queryReader.Get("width", "800")) if err != nil { - c.Handle(400, "Render parameters error", fmt.Errorf("Cannot parse width as int: %s", err)) + c.Handle(400, "Render parameters error", fmt.Errorf("cannot parse width as int: %s", err)) return } height, err := strconv.Atoi(queryReader.Get("height", "400")) if err != nil { - c.Handle(400, "Render parameters error", fmt.Errorf("Cannot parse height as int: %s", err)) + c.Handle(400, "Render parameters error", fmt.Errorf("cannot parse height as int: %s", err)) return } timeout, err := strconv.Atoi(queryReader.Get("timeout", "60")) if err != nil { - c.Handle(400, "Render parameters error", fmt.Errorf("Cannot parse timeout as int: %s", err)) + c.Handle(400, "Render parameters error", fmt.Errorf("cannot parse timeout as int: %s", err)) return } scale, err := strconv.ParseFloat(queryReader.Get("scale", "1"), 64) if err != nil { - c.Handle(400, "Render parameters error", fmt.Errorf("Cannot parse scale as float: %s", err)) + c.Handle(400, "Render parameters error", fmt.Errorf("cannot parse scale as float: %s", err)) return } diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index 2a2f9413ccb..e5e4e72e4c2 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -57,6 +57,11 @@ func (hs *HTTPServer) redirectFromShortURL(c *models.ReqContext) { return } + // Failure to update LastSeenAt should still allow to redirect + if err := hs.ShortURLService.UpdateLastSeenAt(c.Req.Context(), shortURL); err != nil { + hs.log.Error("Failed to update short URL last seen at", "error", err) + } + hs.log.Debug("Redirecting short URL", "path", shortURL.Path) c.Redirect(setting.ToAbsUrl(shortURL.Path), 302) } diff --git a/pkg/api/static/static.go b/pkg/api/static/static.go index 2a35dd11fa6..0971a93c5a8 100644 --- a/pkg/api/static/static.go +++ b/pkg/api/static/static.go @@ -65,20 +65,6 @@ func (sm *staticMap) Set(dir *http.Dir) { sm.data[string(*dir)] = dir } -func (sm *staticMap) Get(name string) *http.Dir { - sm.lock.RLock() - defer sm.lock.RUnlock() - - return sm.data[name] -} - -func (sm *staticMap) Delete(name string) { - sm.lock.Lock() - defer sm.lock.Unlock() - - delete(sm.data, name) -} - var statics = staticMap{sync.RWMutex{}, map[string]*http.Dir{}} // staticFileSystem implements http.FileSystem interface. @@ -197,22 +183,3 @@ func Static(directory string, staticOpt ...StaticOptions) macaron.Handler { staticHandler(ctx, log, opt) } } - -// Statics registers multiple static middleware handlers all at once. -func Statics(opt StaticOptions, dirs ...string) macaron.Handler { - if len(dirs) == 0 { - panic("no static directory is given") - } - opts := make([]StaticOptions, len(dirs)) - for i := range dirs { - opts[i] = prepareStaticOption(dirs[i], opt) - } - - return func(ctx *macaron.Context, log *log.Logger) { - for i := range opts { - if staticHandler(ctx, log, opts[i]) { - return - } - } - } -} diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 4cf010e8ace..824f9f9bc79 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -12,16 +12,16 @@ import ( ) // GET /api/user/auth-tokens -func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response { - return server.getUserAuthTokensInternal(c, c.UserId) +func (hs *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response { + return hs.getUserAuthTokensInternal(c, c.UserId) } // POST /api/user/revoke-auth-token -func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response { - return server.revokeUserAuthTokenInternal(c, c.UserId, cmd) +func (hs *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response { + return hs.revokeUserAuthTokenInternal(c, c.UserId, cmd) } -func (server *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, userID int64) Response { +func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, userID int64) Response { userQuery := models.GetUserByIdQuery{Id: userID} if err := bus.Dispatch(&userQuery); err != nil { @@ -31,7 +31,7 @@ func (server *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, return Error(500, "Could not read user from database", err) } - err := server.AuthTokenService.RevokeAllUserTokens(ctx, userID) + err := hs.AuthTokenService.RevokeAllUserTokens(ctx, userID) if err != nil { return Error(500, "Failed to logout user", err) } @@ -41,7 +41,7 @@ func (server *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, }) } -func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response { +func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response { userQuery := models.GetUserByIdQuery{Id: userID} if err := bus.Dispatch(&userQuery); err != nil { @@ -51,7 +51,7 @@ func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID return Error(500, "Failed to get user", err) } - tokens, err := server.AuthTokenService.GetUserTokens(c.Req.Context(), userID) + tokens, err := hs.AuthTokenService.GetUserTokens(c.Req.Context(), userID) if err != nil { return Error(500, "Failed to get user auth tokens", err) } @@ -108,7 +108,7 @@ func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID return JSON(200, result) } -func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response { +func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response { userQuery := models.GetUserByIdQuery{Id: userID} if err := bus.Dispatch(&userQuery); err != nil { @@ -118,7 +118,7 @@ func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, user return Error(500, "Failed to get user", err) } - token, err := server.AuthTokenService.GetUserToken(c.Req.Context(), userID, cmd.AuthTokenId) + token, err := hs.AuthTokenService.GetUserToken(c.Req.Context(), userID, cmd.AuthTokenId) if err != nil { if err == models.ErrUserTokenNotFound { return Error(404, "User auth token not found", err) @@ -130,7 +130,7 @@ func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, user return Error(400, "Cannot revoke active user auth token", nil) } - err = server.AuthTokenService.RevokeToken(c.Req.Context(), token) + err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token) if err != nil { if err == models.ErrUserTokenNotFound { return Error(404, "User auth token not found", err) diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index dfe8be1eb4b..223b9199f3c 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -9,9 +9,6 @@ import ( // HandlerFunc defines a handler function interface. type HandlerFunc interface{} -// CtxHandlerFunc defines a context handler function. -type CtxHandlerFunc func() - // Msg defines a message interface. type Msg interface{} @@ -204,14 +201,6 @@ func Publish(msg Msg) error { return globalBus.Publish(msg) } -// InTransaction starts a transaction and store it in the context. -// The caller can then pass a function with multiple DispatchCtx calls that -// all will be executed in the same transaction. InTransaction will rollback if the -// callback returns an error. -func InTransaction(ctx context.Context, fn func(ctx context.Context) error) error { - return globalBus.InTransaction(ctx, fn) -} - func ClearBusHandlers() { globalBus = New() } diff --git a/pkg/cmd/grafana-cli/commands/commandstest/fake_ioutil.go b/pkg/cmd/grafana-cli/commands/commandstest/fake_ioutil.go index 6c8f6bdb57c..f7c0e561dcf 100644 --- a/pkg/cmd/grafana-cli/commands/commandstest/fake_ioutil.go +++ b/pkg/cmd/grafana-cli/commands/commandstest/fake_ioutil.go @@ -22,7 +22,7 @@ func (util *FakeIoUtil) ReadDir(path string) ([]os.FileInfo, error) { return util.FakeReadDir, nil } -func (i *FakeIoUtil) ReadFile(filename string) ([]byte, error) { +func (*FakeIoUtil) ReadFile(filename string) ([]byte, error) { return make([]byte, 0), nil } diff --git a/pkg/cmd/grafana-server/diagnostics.go b/pkg/cmd/grafana-server/diagnostics.go index a2d2cf95823..8e8b640c73d 100644 --- a/pkg/cmd/grafana-server/diagnostics.go +++ b/pkg/cmd/grafana-server/diagnostics.go @@ -30,7 +30,7 @@ func (pd *profilingDiagnostics) overrideWithEnv() error { if enabledEnv != "" { enabled, err := strconv.ParseBool(enabledEnv) if err != nil { - return fmt.Errorf("Failed to parse %s environment variable as bool", profilingEnabledEnvName) + return fmt.Errorf("failed to parse %s environment variable as bool", profilingEnabledEnvName) } pd.enabled = enabled } @@ -39,7 +39,7 @@ func (pd *profilingDiagnostics) overrideWithEnv() error { if portEnv != "" { port, parseErr := strconv.ParseUint(portEnv, 0, 64) if parseErr != nil { - return fmt.Errorf("Failed to parse %s environment variable to unsigned integer", profilingPortEnvName) + return fmt.Errorf("failed to parse %s environment variable to unsigned integer", profilingPortEnvName) } pd.port = port } @@ -64,7 +64,7 @@ func (td *tracingDiagnostics) overrideWithEnv() error { if enabledEnv != "" { enabled, err := strconv.ParseBool(enabledEnv) if err != nil { - return fmt.Errorf("Failed to parse %s environment variable as bool", tracingEnabledEnvName) + return fmt.Errorf("failed to parse %s environment variable as bool", tracingEnabledEnvName) } td.enabled = enabled } diff --git a/pkg/components/apikeygen/apikeygen.go b/pkg/components/apikeygen/apikeygen.go index 591f5e0a85d..f907573bf95 100644 --- a/pkg/components/apikeygen/apikeygen.go +++ b/pkg/components/apikeygen/apikeygen.go @@ -8,7 +8,7 @@ import ( "github.com/grafana/grafana/pkg/util" ) -var ErrInvalidApiKey = errors.New("Invalid Api Key") +var ErrInvalidApiKey = errors.New("invalid API key") type KeyGenResult struct { HashedKey string diff --git a/pkg/components/dashdiffs/formatter_json.go b/pkg/components/dashdiffs/formatter_json.go index 498fbef938e..955d58088f8 100644 --- a/pkg/components/dashdiffs/formatter_json.go +++ b/pkg/components/dashdiffs/formatter_json.go @@ -245,7 +245,7 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi case map[string]interface{}: // ok default: - return errors.New("Type mismatch") + return errors.New("type mismatch") } o := value.(map[string]interface{}) @@ -270,7 +270,7 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi case []interface{}: // ok default: - return errors.New("Type mismatch") + return errors.New("type mismatch") } a := value.([]interface{}) @@ -310,7 +310,7 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi f.printRecursive(positionStr, matchedDelta.Value, ChangeDeleted) default: - return errors.New("Unknown Delta type detected") + return errors.New("unknown Delta type detected") } } } else { diff --git a/pkg/components/gtime/gtime.go b/pkg/components/gtime/gtime.go index e1e00d965c9..444e6cc388e 100644 --- a/pkg/components/gtime/gtime.go +++ b/pkg/components/gtime/gtime.go @@ -10,17 +10,19 @@ import ( var dateUnitPattern = regexp.MustCompile(`^(\d+)([dwMy])$`) // ParseInterval parses an interval with support for all units that Grafana uses. -func ParseInterval(interval string) (time.Duration, error) { - result := dateUnitPattern.FindSubmatch([]byte(interval)) - - if len(result) != 3 { - return time.ParseDuration(interval) +// An interval is relative the current wall time. +func ParseInterval(inp string) (time.Duration, error) { + dur, period, err := parse(inp) + if err != nil { + return 0, err + } + if period == "" { + return dur, nil } - num, _ := strconv.Atoi(string(result[1])) - period := string(result[2]) - now := time.Now() + num := int(dur) + now := time.Now() switch period { case "d": return now.Sub(now.AddDate(0, 0, -num)), nil @@ -32,5 +34,52 @@ func ParseInterval(interval string) (time.Duration, error) { return now.Sub(now.AddDate(-num, 0, 0)), nil } - return 0, fmt.Errorf("ParseInterval: invalid duration %q", interval) + return 0, fmt.Errorf("invalid interval %q", inp) +} + +// ParseDuration parses a duration with support for all units that Grafana uses. +// Durations are independent of wall time. +func ParseDuration(inp string) (time.Duration, error) { + dur, period, err := parse(inp) + if err != nil { + return 0, err + } + if period == "" { + return dur, nil + } + + // The average number of days in a year, using the Julian calendar + const daysInAYear = 365.25 + const day = 24 * time.Hour + const week = 7 * day + const year = time.Duration(float64(day) * daysInAYear) + const month = time.Duration(float64(year) / 12) + + switch period { + case "d": + return dur * day, nil + case "w": + return dur * week, nil + case "M": + return dur * month, nil + case "y": + return dur * year, nil + } + + return 0, fmt.Errorf("invalid duration %q", inp) +} + +func parse(inp string) (time.Duration, string, error) { + result := dateUnitPattern.FindSubmatch([]byte(inp)) + if len(result) != 3 { + dur, err := time.ParseDuration(inp) + return dur, "", err + } + + num, err := strconv.Atoi(string(result[1])) + if err != nil { + return 0, "", err + } + + return time.Duration(num), string(result[2]), nil } diff --git a/pkg/components/gtime/gtime_test.go b/pkg/components/gtime/gtime_test.go index 7ee4c6a0f80..3b21cc334ce 100644 --- a/pkg/components/gtime/gtime_test.go +++ b/pkg/components/gtime/gtime_test.go @@ -13,27 +13,57 @@ func TestParseInterval(t *testing.T) { now := time.Now() tcs := []struct { - interval string + inp string duration time.Duration err *regexp.Regexp }{ - {interval: "1d", duration: now.Sub(now.AddDate(0, 0, -1))}, - {interval: "1w", duration: now.Sub(now.AddDate(0, 0, -7))}, - {interval: "2w", duration: now.Sub(now.AddDate(0, 0, -14))}, - {interval: "1M", duration: now.Sub(now.AddDate(0, -1, 0))}, - {interval: "1y", duration: now.Sub(now.AddDate(-1, 0, 0))}, - {interval: "5y", duration: now.Sub(now.AddDate(-5, 0, 0))}, - {interval: "invalid-duration", err: regexp.MustCompile(`^time: invalid duration "?invalid-duration"?$`)}, + {inp: "1d", duration: now.Sub(now.AddDate(0, 0, -1))}, + {inp: "1w", duration: now.Sub(now.AddDate(0, 0, -7))}, + {inp: "2w", duration: now.Sub(now.AddDate(0, 0, -14))}, + {inp: "1M", duration: now.Sub(now.AddDate(0, -1, 0))}, + {inp: "1y", duration: now.Sub(now.AddDate(-1, 0, 0))}, + {inp: "5y", duration: now.Sub(now.AddDate(-5, 0, 0))}, + {inp: "invalid-duration", err: regexp.MustCompile(`^time: invalid duration "?invalid-duration"?$`)}, } - for i, tc := range tcs { t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) { - res, err := ParseInterval(tc.interval) + res, err := ParseInterval(tc.inp) if tc.err == nil { - require.NoError(t, err, "interval %q", tc.interval) - require.Equal(t, tc.duration, res, "interval %q", tc.interval) + require.NoError(t, err, "input %q", tc.inp) + require.Equal(t, tc.duration, res, "input %q", tc.inp) } else { - require.Error(t, err, "interval %q", tc.interval) + require.Error(t, err, "input %q", tc.inp) + require.Regexp(t, tc.err, err.Error()) + } + }) + } +} + +func TestParseDuration(t *testing.T) { + tcs := []struct { + inp string + duration time.Duration + err *regexp.Regexp + }{ + {inp: "1s", duration: time.Second}, + {inp: "1m", duration: time.Minute}, + {inp: "1h", duration: time.Hour}, + {inp: "1d", duration: 24 * time.Hour}, + {inp: "1w", duration: 7 * 24 * time.Hour}, + {inp: "2w", duration: 2 * 7 * 24 * time.Hour}, + {inp: "1M", duration: time.Duration(730.5 * float64(time.Hour))}, + {inp: "1y", duration: 365.25 * 24 * time.Hour}, + {inp: "5y", duration: 5 * 365.25 * 24 * time.Hour}, + {inp: "invalid-duration", err: regexp.MustCompile(`^time: invalid duration "?invalid-duration"?$`)}, + } + for i, tc := range tcs { + t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) { + res, err := ParseDuration(tc.inp) + if tc.err == nil { + require.NoError(t, err, "input %q", tc.inp) + require.Equal(t, tc.duration, res, "input %q", tc.inp) + } else { + require.Error(t, err, "input %q", tc.inp) require.Regexp(t, tc.err, err.Error()) } }) diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index eec34b1c054..8f865f435e0 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -70,7 +70,7 @@ func NewImageUploader() (ImageUploader, error) { url := webdavSec.Key("url").String() if url == "" { - return nil, fmt.Errorf("Could not find url key for image.uploader.webdav") + return nil, fmt.Errorf("could not find URL key for image.uploader.webdav") } public_url := webdavSec.Key("public_url").String() @@ -153,5 +153,5 @@ func getRegionAndBucketFromUrl(url string) (*s3Info, error) { return info, nil } - return nil, fmt.Errorf("Could not find bucket setting for image.uploader.s3") + return nil, fmt.Errorf("could not find bucket setting for image.uploader.s3") } diff --git a/pkg/components/imguploader/webdavuploader.go b/pkg/components/imguploader/webdavuploader.go index a6627ebc7a1..f0a157a8291 100644 --- a/pkg/components/imguploader/webdavuploader.go +++ b/pkg/components/imguploader/webdavuploader.go @@ -79,7 +79,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) if res.StatusCode != http.StatusCreated { body, _ := ioutil.ReadAll(res.Body) - return "", fmt.Errorf("Failed to upload image. Returned statuscode %v body %s", res.StatusCode, body) + return "", fmt.Errorf("failed to upload image, statuscode: %d, body: %s", res.StatusCode, body) } if u.public_url != "" { diff --git a/pkg/infra/log/file.go b/pkg/infra/log/file.go index 231cafc7d9b..8f696cd6d9b 100644 --- a/pkg/infra/log/file.go +++ b/pkg/infra/log/file.go @@ -193,12 +193,12 @@ func (w *FileLogWriter) DoRotate() error { // close fd before rename // Rename the file to its newfound home if err = os.Rename(w.Filename, fname); err != nil { - return fmt.Errorf("Rotate: %s", err) + return fmt.Errorf("rotate: %s", err) } // re-start logger if err = w.StartLogger(); err != nil { - return fmt.Errorf("Rotate StartLogger: %s", err) + return fmt.Errorf("rotate StartLogger: %s", err) } go w.deleteOldLog() @@ -212,7 +212,7 @@ func (w *FileLogWriter) deleteOldLog() { err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) (returnErr error) { defer func() { if r := recover(); r != nil { - returnErr = fmt.Errorf("Unable to delete old log '%s', error: %+v", path, r) + returnErr = fmt.Errorf("unable to delete old log '%s', error: %+v", path, r) } }() diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index cf25b1064d8..737d48941c3 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -36,13 +36,6 @@ func New(logger string, ctx ...interface{}) Logger { return Root.New(params...) } -// NewWithLevel returns a new logger with a certain level. -func NewWithLevel(name string, level log15.Lvl) Logger { - logger := Root.New("logger", name) - logger.SetHandler(log15.LvlFilterHandler(level, log15.StreamHandler(os.Stdout, getLogFormat("console")))) - return logger -} - func Tracef(format string, v ...interface{}) { var message string if len(v) > 0 { @@ -91,10 +84,6 @@ func Errorf(skip int, format string, v ...interface{}) { Root.Error(fmt.Sprintf(format, v...)) } -func Criticalf(skip int, format string, v ...interface{}) { - Root.Crit(fmt.Sprintf(format, v...)) -} - func Fatalf(skip int, format string, v ...interface{}) { Root.Crit(fmt.Sprintf(format, v...)) Close() @@ -114,25 +103,6 @@ func Reload() { } } -func GetLogLevelFor(name string) Lvl { - if level, ok := filters[name]; ok { - switch level { - case log15.LvlWarn: - return LvlWarn - case log15.LvlInfo: - return LvlInfo - case log15.LvlError: - return LvlError - case log15.LvlCrit: - return LvlCrit - default: - return LvlDebug - } - } - - return LvlInfo -} - var logLevels = map[string]log15.Lvl{ "trace": log15.LvlDebug, "debug": log15.LvlDebug, diff --git a/pkg/infra/log/log_writer.go b/pkg/infra/log/log_writer.go deleted file mode 100644 index 2ff401a7f0a..00000000000 --- a/pkg/infra/log/log_writer.go +++ /dev/null @@ -1,39 +0,0 @@ -package log - -import ( - "io" - "strings" -) - -type logWriterImpl struct { - log Logger - level Lvl - prefix string -} - -func NewLogWriter(log Logger, level Lvl, prefix string) io.Writer { - return &logWriterImpl{ - log: log, - level: level, - prefix: prefix, - } -} - -func (l *logWriterImpl) Write(p []byte) (n int, err error) { - message := l.prefix + strings.TrimSpace(string(p)) - - switch l.level { - case LvlCrit: - l.log.Crit(message) - case LvlError: - l.log.Error(message) - case LvlWarn: - l.log.Warn(message) - case LvlInfo: - l.log.Info(message) - default: - l.log.Debug(message) - } - - return len(p), nil -} diff --git a/pkg/infra/log/log_writer_test.go b/pkg/infra/log/log_writer_test.go deleted file mode 100644 index 8d1429323ce..00000000000 --- a/pkg/infra/log/log_writer_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package log - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/inconshreveable/log15" -) - -type FakeLogger struct { - m map[string]string -} - -func (f *FakeLogger) New(ctx ...interface{}) log15.Logger { - return nil -} - -func (f *FakeLogger) Debug(msg string, ctx ...interface{}) { - f.m["debug"] = msg -} - -func (f *FakeLogger) Info(msg string, ctx ...interface{}) { - f.m["info"] = msg -} - -func (f *FakeLogger) Warn(msg string, ctx ...interface{}) { - f.m["warn"] = msg -} - -func (f *FakeLogger) Error(msg string, ctx ...interface{}) { - f.m["err"] = msg -} - -func (f *FakeLogger) Crit(msg string, ctx ...interface{}) { - f.m["crit"] = msg -} - -func (f *FakeLogger) GetHandler() log15.Handler { - return nil -} - -func (f *FakeLogger) SetHandler(l log15.Handler) {} - -func TestLogWriter_level(t *testing.T) { - tests := []struct { - description string - logger string - prefix string - level Lvl - input []byte - expectedConsumed int - expectedOutput string - }{ - { - description: "level crit", - logger: "crit", - input: []byte("crit"), - level: LvlCrit, - expectedConsumed: 4, - expectedOutput: "crit", - }, - { - description: "level error", - logger: "err", - input: []byte("error"), - level: LvlError, - expectedConsumed: 5, - expectedOutput: "error", - }, - { - description: "level warn", - logger: "warn", - input: []byte("warn"), - level: LvlWarn, - expectedConsumed: 4, - expectedOutput: "warn", - }, - { - description: "level info", - logger: "info", - input: []byte("info"), - level: LvlInfo, - expectedConsumed: 4, - expectedOutput: "info", - }, - { - description: "level debug", - logger: "debug", - input: []byte("debug"), - level: LvlDebug, - expectedConsumed: 5, - expectedOutput: "debug", - }, - { - description: "prefix", - logger: "debug", - input: []byte("debug"), - prefix: "prefix", - level: LvlDebug, - expectedConsumed: 5, - expectedOutput: "prefixdebug", - }, - } - - for _, tc := range tests { - tc := tc // to avoid timing issues - - t.Run(tc.description, func(t *testing.T) { - t.Parallel() - fake := &FakeLogger{m: map[string]string{}} - - w := NewLogWriter(fake, tc.level, tc.prefix) - n, err := w.Write(tc.input) - require.NoError(t, err) - - assert.Equal(t, tc.expectedConsumed, n) - assert.Equal(t, tc.expectedOutput, fake.m[tc.logger]) - }) - } -} diff --git a/pkg/infra/metrics/settings.go b/pkg/infra/metrics/settings.go index ca37c937398..4317ef358d8 100644 --- a/pkg/infra/metrics/settings.go +++ b/pkg/infra/metrics/settings.go @@ -13,13 +13,13 @@ import ( func (im *InternalMetricsService) readSettings() error { var section, err = im.Cfg.Raw.GetSection("metrics") if err != nil { - return fmt.Errorf("Unable to find metrics config section %v", err) + return fmt.Errorf("unable to find metrics config section: %w", err) } im.intervalSeconds = section.Key("interval_seconds").MustInt64(10) if err := im.parseGraphiteSettings(); err != nil { - return fmt.Errorf("Unable to parse metrics graphite section, %v", err) + return fmt.Errorf("unable to parse metrics graphite section: %w", err) } return nil diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 314e964c832..88c2b1da0f8 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -10,17 +10,17 @@ import ( ) var ( - ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") - ErrInvalidCredentials = errors.New("Invalid Username or Password") - ErrNoEmail = errors.New("Login provider didn't return an email address") - ErrProviderDeniedRequest = errors.New("Login provider denied login request") - ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") - ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") - ErrPasswordEmpty = errors.New("No password provided") - ErrUserDisabled = errors.New("User is disabled") - ErrAbsoluteRedirectTo = errors.New("Absolute urls are not allowed for redirect_to cookie value") - ErrInvalidRedirectTo = errors.New("Invalid redirect_to cookie value") - ErrForbiddenRedirectTo = errors.New("Forbidden redirect_to cookie value") + ErrEmailNotAllowed = errors.New("required email domain not fulfilled") + ErrInvalidCredentials = errors.New("invalid username or password") + ErrNoEmail = errors.New("login provider didn't return an email address") + ErrProviderDeniedRequest = errors.New("login provider denied login request") + ErrSignUpNotAllowed = errors.New("signup is not allowed for this adapter") + ErrTooManyLoginAttempts = errors.New("too many consecutive incorrect login attempts for user - login for user temporarily blocked") + ErrPasswordEmpty = errors.New("no password provided") + ErrUserDisabled = errors.New("user is disabled") + ErrAbsoluteRedirectTo = errors.New("absolute URLs are not allowed for redirect_to cookie value") + ErrInvalidRedirectTo = errors.New("invalid redirect_to cookie value") + ErrForbiddenRedirectTo = errors.New("forbidden redirect_to cookie value") ) var loginLogger = log.New("login") diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go index fe7ed5cf6b2..63fdbb68e7a 100644 --- a/pkg/login/ldap_login_test.go +++ b/pkg/login/ldap_login_test.go @@ -11,7 +11,7 @@ import ( . "github.com/smartystreets/goconvey/convey" ) -var errTest = errors.New("Test error") +var errTest = errors.New("test error") func TestLDAPLogin(t *testing.T) { Convey("Login using ldap", t, func() { diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index 0832e04c899..618fa67898d 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "time" "github.com/inconshreveable/log15" + "github.com/mattn/go-isatty" "github.com/stretchr/testify/require" "testing" @@ -15,11 +17,24 @@ import ( "golang.org/x/oauth2" ) +func getLogFormat() log15.Format { + if isatty.IsTerminal(os.Stdout.Fd()) { + return log15.TerminalFormat() + } + return log15.LogfmtFormat() +} + +func newLogger(name string, level log15.Lvl) log.Logger { + logger := log.Root.New("logger", name) + logger.SetHandler(log15.LvlFilterHandler(level, log15.StreamHandler(os.Stdout, getLogFormat()))) + return logger +} + func TestSearchJSONForEmail(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { provider := SocialGenericOAuth{ SocialBase: &SocialBase{ - log: log.NewWithLevel("generic_oauth_test", log15.LvlDebug), + log: newLogger("generic_oauth_test", log15.LvlDebug), }, } @@ -107,7 +122,7 @@ func TestSearchJSONForRole(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { provider := SocialGenericOAuth{ SocialBase: &SocialBase{ - log: log.NewWithLevel("generic_oauth_test", log15.LvlDebug), + log: newLogger("generic_oauth_test", log15.LvlDebug), }, } @@ -170,7 +185,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { provider := SocialGenericOAuth{ SocialBase: &SocialBase{ - log: log.NewWithLevel("generic_oauth_test", log15.LvlDebug), + log: newLogger("generic_oauth_test", log15.LvlDebug), }, emailAttributePath: "email", } @@ -337,7 +352,7 @@ func TestUserInfoSearchesForLogin(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { provider := SocialGenericOAuth{ SocialBase: &SocialBase{ - log: log.NewWithLevel("generic_oauth_test", log15.LvlDebug), + log: newLogger("generic_oauth_test", log15.LvlDebug), }, loginAttributePath: "login", } @@ -432,7 +447,7 @@ func TestUserInfoSearchesForName(t *testing.T) { t.Run("Given a generic OAuth provider", func(t *testing.T) { provider := SocialGenericOAuth{ SocialBase: &SocialBase{ - log: log.NewWithLevel("generic_oauth_test", log15.LvlDebug), + log: newLogger("generic_oauth_test", log15.LvlDebug), }, nameAttributePath: "name", } @@ -529,7 +544,7 @@ func TestUserInfoSearchesForName(t *testing.T) { func TestPayloadCompression(t *testing.T) { provider := SocialGenericOAuth{ SocialBase: &SocialBase{ - log: log.NewWithLevel("generic_oauth_test", log15.LvlDebug), + log: newLogger("generic_oauth_test", log15.LvlDebug), }, emailAttributePath: "email", } diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index a2705f9d227..d6be068c2e5 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -105,11 +105,11 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi err = json.Unmarshal(response.Body, &data) if err != nil { - return nil, fmt.Errorf("Error getting user info: %s", err) + return nil, fmt.Errorf("error getting user info: %s", err) } if data.State != "active" { - return nil, fmt.Errorf("User %s is inactive", data.Username) + return nil, fmt.Errorf("user %s is inactive", data.Username) } groups := s.GetGroups(client) diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index a9b44897aa0..528920ae0f1 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -243,7 +243,7 @@ func GetOAuthHttpClient(name string) (*http.Client, error) { name = strings.TrimPrefix(name, "oauth_") info, ok := setting.OAuthService.OAuthInfos[name] if !ok { - return nil, fmt.Errorf("Could not find %s in OAuth Settings", name) + return nil, fmt.Errorf("could not find %q in OAuth Settings", name) } // handle call back @@ -261,7 +261,7 @@ func GetOAuthHttpClient(name string) (*http.Client, error) { cert, err := tls.LoadX509KeyPair(info.TlsClientCert, info.TlsClientKey) if err != nil { logger.Error("Failed to setup TlsClientCert", "oauth", name, "error", err) - return nil, fmt.Errorf("Failed to setup TlsClientCert") + return nil, fmt.Errorf("failed to setup TlsClientCert: %w", err) } tr.TLSClientConfig.Certificates = append(tr.TLSClientConfig.Certificates, cert) @@ -271,7 +271,7 @@ func GetOAuthHttpClient(name string) (*http.Client, error) { caCert, err := ioutil.ReadFile(info.TlsClientCa) if err != nil { logger.Error("Failed to setup TlsClientCa", "oauth", name, "error", err) - return nil, fmt.Errorf("Failed to setup TlsClientCa") + return nil, fmt.Errorf("failed to setup TlsClientCa: %w", err) } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) @@ -285,7 +285,7 @@ func GetConnector(name string) (SocialConnector, error) { provider := strings.TrimPrefix(name, "oauth_") connector, ok := SocialMap[provider] if !ok { - return nil, fmt.Errorf("Failed to find oauth provider for %s", name) + return nil, fmt.Errorf("failed to find oauth provider for %q", name) } return connector, nil } diff --git a/pkg/middleware/auth_proxy/auth_proxy.go b/pkg/middleware/auth_proxy/auth_proxy.go index 08009f8eea7..626e0464e9b 100644 --- a/pkg/middleware/auth_proxy/auth_proxy.go +++ b/pkg/middleware/auth_proxy/auth_proxy.go @@ -140,7 +140,7 @@ func (auth *AuthProxy) IsAllowedIP() (bool, *Error) { } err := fmt.Errorf( - "Request for user (%s) from %s is not from the authentication proxy", auth.header, + "request for user (%s) from %s is not from the authentication proxy", auth.header, sourceIP, ) diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index 8befc7df4aa..5c80335b37b 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -33,11 +33,11 @@ func Logger() macaron.Handler { rw := res.(macaron.ResponseWriter) c.Next() - timeTakenMs := time.Since(start) / time.Millisecond + timeTaken := time.Since(start) / time.Millisecond if timer, ok := c.Data["perfmon.timer"]; ok { timerTyped := timer.(prometheus.Summary) - timerTyped.Observe(float64(timeTakenMs)) + timerTyped.Observe(float64(timeTaken)) } status := rw.Status() @@ -50,9 +50,11 @@ func Logger() macaron.Handler { if ctx, ok := c.Data["ctx"]; ok { ctxTyped := ctx.(*models.ReqContext) if status == 500 { - ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size(), "referer", req.Referer()) + ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, + "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTaken), "size", rw.Size(), "referer", req.Referer()) } else { - ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size(), "referer", req.Referer()) + ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, + "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTaken), "size", rw.Size(), "referer", req.Referer()) } } } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 7291e8222f6..73f01538714 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -546,7 +546,9 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) { defer bus.ClearBusHandlers() setting.LoginCookieName = "grafana_session" - setting.LoginMaxLifetime, _ = gtime.ParseInterval("30d") + var err error + setting.LoginMaxLifetime, err = gtime.ParseDuration("30d") + require.NoError(t, err) sc := &scenarioContext{} @@ -637,7 +639,11 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) { func initTokenRotationTest(ctx context.Context) (*models.ReqContext, *httptest.ResponseRecorder, error) { setting.LoginCookieName = "login_token" - setting.LoginMaxLifetime, _ = gtime.ParseInterval("7d") + var err error + setting.LoginMaxLifetime, err = gtime.ParseDuration("7d") + if err != nil { + return nil, nil, err + } rr := httptest.NewRecorder() req, err := http.NewRequestWithContext(ctx, "", "", nil) diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go index 8a55933a606..0e24cc3e7cc 100644 --- a/pkg/middleware/testing.go +++ b/pkg/middleware/testing.go @@ -5,13 +5,13 @@ import ( "net/http" "net/http/httptest" - . "github.com/smartystreets/goconvey/convey" "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/setting" + "github.com/smartystreets/goconvey/convey" ) type scenarioContext struct { @@ -49,7 +49,7 @@ func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioC func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { sc.resp = httptest.NewRecorder() req, err := http.NewRequest(method, url, nil) - So(err, ShouldBeNil) + convey.So(err, convey.ShouldBeNil) sc.req = req return sc @@ -63,7 +63,7 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map q.Add(k, v) } req.URL.RawQuery = q.Encode() - So(err, ShouldBeNil) + convey.So(err, convey.ShouldBeNil) sc.req = req return sc @@ -94,7 +94,7 @@ func (sc *scenarioContext) exec() { if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" { err := json.NewDecoder(sc.resp.Body).Decode(&sc.respJson) - So(err, ShouldBeNil) + convey.So(err, convey.ShouldBeNil) } } diff --git a/pkg/models/alert.go b/pkg/models/alert.go index 79b9191a6e2..8911d15007e 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -34,7 +34,7 @@ const ( ) var ( - ErrCannotChangeStateOnPausedAlert = fmt.Errorf("Cannot change state on pause alert") + ErrCannotChangeStateOnPausedAlert = fmt.Errorf("cannot change state on pause alert") ErrRequiresNewState = fmt.Errorf("update alert state requires a new state") ) @@ -89,21 +89,21 @@ type Alert struct { Settings *simplejson.Json } -func (alert *Alert) ValidToSave() bool { - return alert.DashboardId != 0 && alert.OrgId != 0 && alert.PanelId != 0 +func (a *Alert) ValidToSave() bool { + return a.DashboardId != 0 && a.OrgId != 0 && a.PanelId != 0 } -func (alert *Alert) ShouldUpdateState(newState AlertStateType) bool { - return alert.State != newState +func (a *Alert) ShouldUpdateState(newState AlertStateType) bool { + return a.State != newState } -func (this *Alert) ContainsUpdates(other *Alert) bool { +func (a *Alert) ContainsUpdates(other *Alert) bool { result := false - result = result || this.Name != other.Name - result = result || this.Message != other.Message + result = result || a.Name != other.Name + result = result || a.Message != other.Message - if this.Settings != nil && other.Settings != nil { - json1, err1 := this.Settings.Encode() + if a.Settings != nil && other.Settings != nil { + json1, err1 := a.Settings.Encode() json2, err2 := other.Settings.Encode() if err1 != nil || err2 != nil { @@ -117,10 +117,10 @@ func (this *Alert) ContainsUpdates(other *Alert) bool { return result } -func (alert *Alert) GetTagsFromSettings() []*Tag { +func (a *Alert) GetTagsFromSettings() []*Tag { tags := []*Tag{} - if alert.Settings != nil { - if data, ok := alert.Settings.CheckGet("alertRuleTags"); ok { + if a.Settings != nil { + if data, ok := a.Settings.CheckGet("alertRuleTags"); ok { for tagNameString, tagValue := range data.MustMap() { // MustMap() already guarantees the return of a `map[string]interface{}`. // Therefore we only need to verify that tagValue is a String. diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go index 6e65814ae97..1aa24fb1e90 100644 --- a/pkg/models/alert_notifications.go +++ b/pkg/models/alert_notifications.go @@ -9,13 +9,13 @@ import ( ) var ( - ErrAlertNotificationNotFound = errors.New("Alert notification not found") - ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified") + ErrAlertNotificationNotFound = errors.New("alert notification not found") + ErrNotificationFrequencyNotFound = errors.New("notification frequency not specified") ErrAlertNotificationStateNotFound = errors.New("alert notification state not found") ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict") ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists") - ErrAlertNotificationFailedGenerateUniqueUid = errors.New("Failed to generate unique alert notification uid") - ErrAlertNotificationFailedTranslateUniqueID = errors.New("Failed to translate Notification Id to Uid") + ErrAlertNotificationFailedGenerateUniqueUid = errors.New("failed to generate unique alert notification uid") + ErrAlertNotificationFailedTranslateUniqueID = errors.New("failed to translate Notification Id to Uid") ErrAlertNotificationWithSameNameExists = errors.New("alert notification with same name already exists") ErrAlertNotificationWithSameUIDExists = errors.New("alert notification with same uid already exists") ) diff --git a/pkg/models/apikey.go b/pkg/models/apikey.go index 8b18a0185b1..a480a822058 100644 --- a/pkg/models/apikey.go +++ b/pkg/models/apikey.go @@ -5,9 +5,9 @@ import ( "time" ) -var ErrInvalidApiKey = errors.New("Invalid API Key") -var ErrInvalidApiKeyExpiration = errors.New("Negative value for SecondsToLive") -var ErrDuplicateApiKey = errors.New("API Key Organization ID And Name Must Be Unique") +var ErrInvalidApiKey = errors.New("invalid API key") +var ErrInvalidApiKeyExpiration = errors.New("negative value for SecondsToLive") +var ErrDuplicateApiKey = errors.New("API key, organization ID and name must be unique") type ApiKey struct { Id int64 diff --git a/pkg/models/dashboard_version.go b/pkg/models/dashboard_version.go index 304a81e41c8..23c80c38a62 100644 --- a/pkg/models/dashboard_version.go +++ b/pkg/models/dashboard_version.go @@ -8,8 +8,8 @@ import ( ) var ( - ErrDashboardVersionNotFound = errors.New("Dashboard version not found") - ErrNoVersionsForDashboardId = errors.New("No dashboard versions found for the given DashboardId") + ErrDashboardVersionNotFound = errors.New("dashboard version not found") + ErrNoVersionsForDashboardId = errors.New("no dashboard versions found for the given DashboardId") ) // A DashboardVersion represents the comparable data in a dashboard, allowing diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 30afd0b900d..f216a78bcc6 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -272,11 +272,6 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { return dash } -// GetString a -func (d *Dashboard) GetString(prop string, defaultValue string) string { - return d.Data.Get(prop).MustString(defaultValue) -} - // UpdateSlug updates the slug func (d *Dashboard) UpdateSlug() { title := d.Data.Get("title").MustString() @@ -299,8 +294,8 @@ func SlugifyTitle(title string) string { } // GetUrl return the html url for a folder if it's folder, otherwise for a dashboard -func (dash *Dashboard) GetUrl() string { - return GetDashboardFolderUrl(dash.IsFolder, dash.Uid, dash.Slug) +func (d *Dashboard) GetUrl() string { + return GetDashboardFolderUrl(d.IsFolder, d.Uid, d.Slug) } // Return the html url for a dashboard diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 4af1ece71de..3e0f343f29a 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -30,13 +30,13 @@ const ( ) var ( - ErrDataSourceNotFound = errors.New("Data source not found") - ErrDataSourceNameExists = errors.New("Data source with the same name already exists") - ErrDataSourceUidExists = errors.New("Data source with the same uid already exists") - ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource") - ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration") - ErrDataSourceAccessDenied = errors.New("Data source access denied") - ErrDataSourceFailedGenerateUniqueUid = errors.New("Failed to generate unique datasource id") + ErrDataSourceNotFound = errors.New("data source not found") + ErrDataSourceNameExists = errors.New("data source with the same name already exists") + ErrDataSourceUidExists = errors.New("data source with the same uid already exists") + ErrDataSourceUpdatingOldVersion = errors.New("trying to update old version of datasource") + ErrDatasourceIsReadOnly = errors.New("data source is readonly, can only be updated from configuration") + ErrDataSourceAccessDenied = errors.New("data source access denied") + ErrDataSourceFailedGenerateUniqueUid = errors.New("failed to generate unique datasource ID") ) type DsAccess string diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go index 39b8434ae3b..6b822116a3b 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -224,7 +224,7 @@ func (ds *DataSource) GetTLSConfig() (*tls.Config, error) { caPool := x509.NewCertPool() ok := caPool.AppendCertsFromPEM([]byte(decrypted["tlsCACert"])) if !ok { - return nil, errors.New("Failed to parse TLS CA PEM certificate") + return nil, errors.New("failed to parse TLS CA PEM certificate") } tlsConfig.RootCAs = caPool } diff --git a/pkg/models/folders.go b/pkg/models/folders.go index b48b7f9e83f..f3aac27055e 100644 --- a/pkg/models/folders.go +++ b/pkg/models/folders.go @@ -8,13 +8,13 @@ import ( // Typed errors var ( - ErrFolderNotFound = errors.New("Folder not found") - ErrFolderVersionMismatch = errors.New("The folder has been changed by someone else") - ErrFolderTitleEmpty = errors.New("Folder title cannot be empty") - ErrFolderWithSameUIDExists = errors.New("A folder/dashboard with the same uid already exists") - ErrFolderSameNameExists = errors.New("A folder or dashboard in the general folder with the same name already exists") - ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id") - ErrFolderAccessDenied = errors.New("Access denied to folder") + ErrFolderNotFound = errors.New("folder not found") + ErrFolderVersionMismatch = errors.New("the folder has been changed by someone else") + ErrFolderTitleEmpty = errors.New("folder title cannot be empty") + ErrFolderWithSameUIDExists = errors.New("a folder/dashboard with the same uid already exists") + ErrFolderSameNameExists = errors.New("a folder or dashboard in the general folder with the same name already exists") + ErrFolderFailedGenerateUniqueUid = errors.New("failed to generate unique folder ID") + ErrFolderAccessDenied = errors.New("access denied to folder") ) type Folder struct { diff --git a/pkg/models/live.go b/pkg/models/live.go index c42f15ce7b5..ebae807069a 100644 --- a/pkg/models/live.go +++ b/pkg/models/live.go @@ -7,14 +7,11 @@ type ChannelPublisher func(channel string, data []byte) error // ChannelHandler defines the core channel behavior type ChannelHandler interface { - // This is called fast and often -- it must be synchrnozed - GetChannelOptions(id string) centrifuge.ChannelOptions + // OnSubscribe is called when a client wants to subscribe to a channel + OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) - // Called when a client wants to subscribe to a channel - OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error - - // Called when something writes into the channel. The returned value will be broadcast if len() > 0 - OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) + // OnPublish is called when a client writes a message to the channel websocket. + OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) } // ChannelHandlerFactory should be implemented by all core features. diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 0a577afa525..4ac0a473823 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -2,7 +2,7 @@ package models import "errors" -var ErrInvalidEmailCode = errors.New("Invalid or expired email code") +var ErrInvalidEmailCode = errors.New("invalid or expired email code") var ErrSmtpNotEnabled = errors.New("SMTP not configured, check your grafana.ini config file's [smtp] section") // SendEmailAttachFile is a definition of the attached files without path diff --git a/pkg/models/org.go b/pkg/models/org.go index ff45890f558..6417cc334a6 100644 --- a/pkg/models/org.go +++ b/pkg/models/org.go @@ -7,8 +7,8 @@ import ( // Typed errors var ( - ErrOrgNotFound = errors.New("Organization not found") - ErrOrgNameTaken = errors.New("Organization name is taken") + ErrOrgNotFound = errors.New("organization not found") + ErrOrgNameTaken = errors.New("organization name is taken") ) type Org struct { diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index afec8acfaa2..53bb8c659ee 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -9,10 +9,10 @@ import ( // Typed errors var ( - ErrInvalidRoleType = errors.New("Invalid role type") - ErrLastOrgAdmin = errors.New("Cannot remove last organization admin") - ErrOrgUserNotFound = errors.New("Cannot find the organization user") - ErrOrgUserAlreadyAdded = errors.New("User is already added to organization") + ErrInvalidRoleType = errors.New("invalid role type") + ErrLastOrgAdmin = errors.New("cannot remove last organization admin") + ErrOrgUserNotFound = errors.New("cannot find the organization user") + ErrOrgUserAlreadyAdded = errors.New("user is already added to organization") ) type RoleType string diff --git a/pkg/models/playlist.go b/pkg/models/playlist.go index c52da202293..b5f0081269d 100644 --- a/pkg/models/playlist.go +++ b/pkg/models/playlist.go @@ -6,8 +6,8 @@ import ( // Typed errors var ( - ErrPlaylistNotFound = errors.New("Playlist not found") - ErrPlaylistWithSameNameExists = errors.New("A playlist with the same name already exists") + ErrPlaylistNotFound = errors.New("playlist not found") + ErrPlaylistWithSameNameExists = errors.New("a playlist with the same name already exists") ) // Playlist model @@ -50,7 +50,7 @@ type PlaylistItem struct { Title string } -func (this PlaylistDashboard) TableName() string { +func (p PlaylistDashboard) TableName() string { return "dashboard" } diff --git a/pkg/models/plugin_settings.go b/pkg/models/plugin_settings.go index bbfbaabd8c6..9307612ccba 100644 --- a/pkg/models/plugin_settings.go +++ b/pkg/models/plugin_settings.go @@ -8,7 +8,7 @@ import ( ) var ( - ErrPluginSettingNotFound = errors.New("Plugin setting not found") + ErrPluginSettingNotFound = errors.New("plugin setting not found") ) type PluginSetting struct { diff --git a/pkg/models/quotas.go b/pkg/models/quotas.go index 919fa900bd2..a2296b12d51 100644 --- a/pkg/models/quotas.go +++ b/pkg/models/quotas.go @@ -7,7 +7,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -var ErrInvalidQuotaTarget = errors.New("Invalid quota target") +var ErrInvalidQuotaTarget = errors.New("invalid quota target") type Quota struct { Id int64 diff --git a/pkg/models/star.go b/pkg/models/star.go index 5d1a503cb1c..08e864d6c82 100644 --- a/pkg/models/star.go +++ b/pkg/models/star.go @@ -2,7 +2,7 @@ package models import "errors" -var ErrCommandValidationFailed = errors.New("Command missing required fields") +var ErrCommandValidationFailed = errors.New("command missing required fields") type Star struct { Id int64 diff --git a/pkg/models/team.go b/pkg/models/team.go index 5c5a19f8db4..fc6e20d4668 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -7,12 +7,12 @@ import ( // Typed errors var ( - ErrTeamNotFound = errors.New("Team not found") - ErrTeamNameTaken = errors.New("Team name is taken") - ErrTeamMemberNotFound = errors.New("Team member not found") - ErrLastTeamAdmin = errors.New("Not allowed to remove last admin") - ErrNotAllowedToUpdateTeam = errors.New("User not allowed to update team") - ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org") + ErrTeamNotFound = errors.New("team not found") + ErrTeamNameTaken = errors.New("team name is taken") + ErrTeamMemberNotFound = errors.New("team member not found") + ErrLastTeamAdmin = errors.New("not allowed to remove last admin") + ErrNotAllowedToUpdateTeam = errors.New("user not allowed to update team") + ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("user not allowed to update team in another org") ) // Team model diff --git a/pkg/models/user.go b/pkg/models/user.go index ce71ebc668e..2a549a44813 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -7,9 +7,9 @@ import ( // Typed errors var ( - ErrUserNotFound = errors.New("User not found") - ErrUserAlreadyExists = errors.New("User already exists") - ErrLastGrafanaAdmin = errors.New("Cannot remove last grafana admin") + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExists = errors.New("user already exists") + ErrLastGrafanaAdmin = errors.New("cannot remove last grafana admin") ) type Password string @@ -204,16 +204,16 @@ type UpdateUserLastSeenAtCommand struct { UserId int64 } -func (user *SignedInUser) HasRole(role RoleType) bool { - if user.IsGrafanaAdmin { +func (u *SignedInUser) HasRole(role RoleType) bool { + if u.IsGrafanaAdmin { return true } - return user.OrgRole.Includes(role) + return u.OrgRole.Includes(role) } -func (user *SignedInUser) IsRealUser() bool { - return user.UserId != 0 +func (u *SignedInUser) IsRealUser() bool { + return u.UserId != 0 } type UserProfileDTO struct { diff --git a/pkg/models/user_auth.go b/pkg/models/user_auth.go index 9ac44ce8612..3810de46b08 100644 --- a/pkg/models/user_auth.go +++ b/pkg/models/user_auth.go @@ -36,6 +36,15 @@ type ExternalUserInfo struct { IsDisabled bool } +type LoginInfo struct { + AuthModule string + User *User + ExternalUser ExternalUserInfo + LoginUsername string + HTTPStatus int + Error error +} + // --------------------- // COMMANDS @@ -65,16 +74,6 @@ type DeleteAuthInfoCommand struct { UserAuth *UserAuth } -type SendLoginLogCommand struct { - ReqContext *ReqContext - LogAction string - User *User - ExternalUser *ExternalUserInfo - LoginUsername string - HTTPStatus int - Error error -} - // ---------------------- // QUERIES diff --git a/pkg/plugins/backendplugin/coreplugin/core_plugin.go b/pkg/plugins/backendplugin/coreplugin/core_plugin.go deleted file mode 100644 index e3168539e96..00000000000 --- a/pkg/plugins/backendplugin/coreplugin/core_plugin.go +++ /dev/null @@ -1,81 +0,0 @@ -package coreplugin - -import ( - "context" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana/pkg/tsdb" -) - -type corePlugin struct { - pluginID string - logger log.Logger - backend.CheckHealthHandler - backend.CallResourceHandler - backend.QueryDataHandler -} - -// New returns a new backendplugin.PluginFactoryFunc for creating a core (built-in) backendplugin.Plugin. -func New(opts backend.ServeOpts) backendplugin.PluginFactoryFunc { - return backendplugin.PluginFactoryFunc(func(pluginID string, logger log.Logger, env []string) (backendplugin.Plugin, error) { - return &corePlugin{ - pluginID: pluginID, - logger: logger, - CheckHealthHandler: opts.CheckHealthHandler, - CallResourceHandler: opts.CallResourceHandler, - QueryDataHandler: opts.QueryDataHandler, - }, nil - }) -} - -func (cp *corePlugin) PluginID() string { - return cp.pluginID -} - -func (cp *corePlugin) Logger() log.Logger { - return cp.logger -} - -func (cp *corePlugin) Start(ctx context.Context) error { - if cp.QueryDataHandler != nil { - tsdb.RegisterTsdbQueryEndpoint(cp.pluginID, func(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - return newQueryEndpointAdapter(cp.pluginID, cp.logger, backendplugin.InstrumentQueryDataHandler(cp.QueryDataHandler)), nil - }) - } - return nil -} - -func (cp *corePlugin) Stop(ctx context.Context) error { - return nil -} - -func (cp *corePlugin) IsManaged() bool { - return false -} - -func (cp *corePlugin) Exited() bool { - return false -} - -func (cp *corePlugin) CollectMetrics(ctx context.Context) (*backend.CollectMetricsResult, error) { - return nil, backendplugin.ErrMethodNotImplemented -} - -func (cp *corePlugin) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - if cp.CheckHealthHandler != nil { - return cp.CheckHealthHandler.CheckHealth(ctx, req) - } - - return nil, backendplugin.ErrMethodNotImplemented -} - -func (cp *corePlugin) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { - if cp.CallResourceHandler != nil { - return cp.CallResourceHandler.CallResource(ctx, req, sender) - } - - return backendplugin.ErrMethodNotImplemented -} diff --git a/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go b/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go deleted file mode 100644 index d9fa8be7f2a..00000000000 --- a/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package coreplugin_test - -import ( - "context" - "testing" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - "github.com/stretchr/testify/require" -) - -func TestCorePlugin(t *testing.T) { - t.Run("New core plugin with empty opts should return expected values", func(t *testing.T) { - factory := coreplugin.New(backend.ServeOpts{}) - p, err := factory("plugin", log.New("test"), nil) - require.NoError(t, err) - require.NotNil(t, p) - require.NoError(t, p.Start(context.Background())) - require.NoError(t, p.Stop(context.Background())) - require.False(t, p.IsManaged()) - require.False(t, p.Exited()) - - _, err = p.CollectMetrics(context.Background()) - require.Equal(t, backendplugin.ErrMethodNotImplemented, err) - - _, err = p.CheckHealth(context.Background(), nil) - require.Equal(t, backendplugin.ErrMethodNotImplemented, err) - - err = p.CallResource(context.Background(), nil, nil) - require.Equal(t, backendplugin.ErrMethodNotImplemented, err) - }) - - t.Run("New core plugin with handlers set in opts should return expected values", func(t *testing.T) { - checkHealthCalled := false - callResourceCalled := false - factory := coreplugin.New(backend.ServeOpts{ - CheckHealthHandler: backend.CheckHealthHandlerFunc(func(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - checkHealthCalled = true - return nil, nil - }), - CallResourceHandler: backend.CallResourceHandlerFunc(func(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { - callResourceCalled = true - return nil - }), - }) - p, err := factory("plugin", log.New("test"), nil) - require.NoError(t, err) - require.NotNil(t, p) - require.NoError(t, p.Start(context.Background())) - require.NoError(t, p.Stop(context.Background())) - require.False(t, p.IsManaged()) - require.False(t, p.Exited()) - - _, err = p.CollectMetrics(context.Background()) - require.Equal(t, backendplugin.ErrMethodNotImplemented, err) - - _, err = p.CheckHealth(context.Background(), &backend.CheckHealthRequest{}) - require.NoError(t, err) - require.True(t, checkHealthCalled) - - err = p.CallResource(context.Background(), &backend.CallResourceRequest{}, nil) - require.NoError(t, err) - require.True(t, callResourceCalled) - }) -} diff --git a/pkg/plugins/backendplugin/coreplugin/query_endpoint_adapter.go b/pkg/plugins/backendplugin/coreplugin/query_endpoint_adapter.go deleted file mode 100644 index 311d06cbf4b..00000000000 --- a/pkg/plugins/backendplugin/coreplugin/query_endpoint_adapter.go +++ /dev/null @@ -1,113 +0,0 @@ -package coreplugin - -import ( - "context" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/plugins/datasource/wrapper" - "github.com/grafana/grafana/pkg/tsdb" -) - -func newQueryEndpointAdapter(pluginID string, logger log.Logger, handler backend.QueryDataHandler) tsdb.TsdbQueryEndpoint { - return &queryEndpointAdapter{ - pluginID: pluginID, - logger: logger, - handler: handler, - } -} - -type queryEndpointAdapter struct { - pluginID string - logger log.Logger - handler backend.QueryDataHandler -} - -func modelToInstanceSettings(ds *models.DataSource) (*backend.DataSourceInstanceSettings, error) { - jsonDataBytes, err := ds.JsonData.MarshalJSON() - if err != nil { - return nil, err - } - - return &backend.DataSourceInstanceSettings{ - ID: ds.Id, - Name: ds.Name, - URL: ds.Url, - Database: ds.Database, - User: ds.User, - BasicAuthEnabled: ds.BasicAuth, - BasicAuthUser: ds.BasicAuthUser, - JSONData: jsonDataBytes, - DecryptedSecureJSONData: ds.DecryptedValues(), - Updated: ds.Updated, - }, nil -} - -func (a *queryEndpointAdapter) Query(ctx context.Context, ds *models.DataSource, query *tsdb.TsdbQuery) (*tsdb.Response, error) { - instanceSettings, err := modelToInstanceSettings(ds) - if err != nil { - return nil, err - } - - req := &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ - OrgID: ds.OrgId, - PluginID: a.pluginID, - User: wrapper.BackendUserFromSignedInUser(query.User), - DataSourceInstanceSettings: instanceSettings, - }, - Queries: []backend.DataQuery{}, - Headers: query.Headers, - } - - for _, q := range query.Queries { - modelJSON, err := q.Model.MarshalJSON() - if err != nil { - return nil, err - } - req.Queries = append(req.Queries, backend.DataQuery{ - RefID: q.RefId, - Interval: time.Duration(q.IntervalMs) * time.Millisecond, - MaxDataPoints: q.MaxDataPoints, - TimeRange: backend.TimeRange{ - From: query.TimeRange.GetFromAsTimeUTC(), - To: query.TimeRange.GetToAsTimeUTC(), - }, - QueryType: q.QueryType, - JSON: modelJSON, - }) - } - - resp, err := a.handler.QueryData(ctx, req) - if err != nil { - return nil, err - } - - tR := &tsdb.Response{ - Results: make(map[string]*tsdb.QueryResult, len(resp.Responses)), - } - - for refID, r := range resp.Responses { - qr := &tsdb.QueryResult{ - RefId: refID, - } - - for _, f := range r.Frames { - if f.RefID == "" { - f.RefID = refID - } - } - - qr.Dataframes = tsdb.NewDecodedDataFrames(r.Frames) - - if r.Error != nil { - qr.Error = r.Error - } - - tR.Results[refID] = qr - } - - return tR, nil -} diff --git a/pkg/plugins/backendplugin/grpcplugin/client.go b/pkg/plugins/backendplugin/grpcplugin/client.go index dc8897dcc34..fcda0bbbdf1 100644 --- a/pkg/plugins/backendplugin/grpcplugin/client.go +++ b/pkg/plugins/backendplugin/grpcplugin/client.go @@ -6,7 +6,6 @@ import ( datasourceV1 "github.com/grafana/grafana-plugin-model/go/datasource" rendererV1 "github.com/grafana/grafana-plugin-model/go/renderer" "github.com/grafana/grafana-plugin-sdk-go/backend/grpcplugin" - sdkgrpcplugin "github.com/grafana/grafana-plugin-sdk-go/backend/grpcplugin" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" @@ -28,8 +27,8 @@ var handshake = goplugin.HandshakeConfig{ ProtocolVersion: DefaultProtocolVersion, // The magic cookie values should NEVER be changed. - MagicCookieKey: sdkgrpcplugin.MagicCookieKey, - MagicCookieValue: sdkgrpcplugin.MagicCookieValue, + MagicCookieKey: grpcplugin.MagicCookieKey, + MagicCookieValue: grpcplugin.MagicCookieValue, } func newClientConfig(executablePath string, env []string, logger log.Logger, versionedPlugins map[int]goplugin.PluginSet) *goplugin.ClientConfig { @@ -66,18 +65,13 @@ type PluginDescriptor struct { startFns PluginStartFuncs } -// PluginID returns the plugin ID. -func (pd PluginDescriptor) PluginID() string { - return pd.pluginID -} - // getV2PluginSet returns list of plugins supported on v2. func getV2PluginSet() goplugin.PluginSet { return goplugin.PluginSet{ - "diagnostics": &sdkgrpcplugin.DiagnosticsGRPCPlugin{}, - "resource": &sdkgrpcplugin.ResourceGRPCPlugin{}, - "data": &sdkgrpcplugin.DataGRPCPlugin{}, - "transform": &sdkgrpcplugin.TransformGRPCPlugin{}, + "diagnostics": &grpcplugin.DiagnosticsGRPCPlugin{}, + "resource": &grpcplugin.ResourceGRPCPlugin{}, + "data": &grpcplugin.DataGRPCPlugin{}, + "transform": &grpcplugin.TransformGRPCPlugin{}, "renderer": &pluginextensionv2.RendererGRPCPlugin{}, } } @@ -92,7 +86,7 @@ func NewBackendPlugin(pluginID, executablePath string, startFns PluginStartFuncs DefaultProtocolVersion: { pluginID: &datasourceV1.DatasourcePluginImpl{}, }, - sdkgrpcplugin.ProtocolVersion: getV2PluginSet(), + grpcplugin.ProtocolVersion: getV2PluginSet(), }, startFns: startFns, }) @@ -108,7 +102,7 @@ func NewRendererPlugin(pluginID, executablePath string, startFns PluginStartFunc DefaultProtocolVersion: { pluginID: &rendererV1.RendererPluginImpl{}, }, - sdkgrpcplugin.ProtocolVersion: getV2PluginSet(), + grpcplugin.ProtocolVersion: getV2PluginSet(), }, startFns: startFns, }) diff --git a/pkg/plugins/backendplugin/manager.go b/pkg/plugins/backendplugin/manager.go index 1637db0527a..ac5af55f8eb 100644 --- a/pkg/plugins/backendplugin/manager.go +++ b/pkg/plugins/backendplugin/manager.go @@ -23,11 +23,11 @@ import ( var ( // ErrPluginNotRegistered error returned when plugin not registered. - ErrPluginNotRegistered = errors.New("Plugin not registered") + ErrPluginNotRegistered = errors.New("plugin not registered") // ErrHealthCheckFailed error returned when health check failed. - ErrHealthCheckFailed = errors.New("Health check failed") + ErrHealthCheckFailed = errors.New("health check failed") // ErrPluginUnavailable error returned when plugin is unavailable. - ErrPluginUnavailable = errors.New("Plugin unavailable") + ErrPluginUnavailable = errors.New("plugin unavailable") // ErrMethodNotImplemented error returned when plugin method not implemented. ErrMethodNotImplemented = errors.New("method not implemented") ) @@ -81,7 +81,7 @@ func (m *manager) Register(pluginID string, factory PluginFactoryFunc) error { defer m.pluginsMu.Unlock() if _, exists := m.plugins[pluginID]; exists { - return errors.New("Backend plugin already registered") + return fmt.Errorf("backend plugin %s already registered", pluginID) } pluginSettings := pluginSettings{} @@ -141,7 +141,7 @@ func (m *manager) StartPlugin(ctx context.Context, pluginID string) error { } if p.IsManaged() { - return errors.New("Backend plugin is managed and cannot be manually started") + return errors.New("backend plugin is managed and cannot be manually started") } return startPluginAndRestartKilledProcesses(ctx, p) @@ -241,7 +241,7 @@ func (m *manager) callResourceInternal(w http.ResponseWriter, req *http.Request, body, err := ioutil.ReadAll(req.Body) if err != nil { - return errors.New("Failed to read request body") + return fmt.Errorf("failed to read request body: %w", err) } crReq := &backend.CallResourceRequest{ @@ -315,13 +315,13 @@ func flushStream(plugin Plugin, stream CallResourceClientResponseStream, w http. resp, err := stream.Recv() if err == io.EOF { if processedStreams == 0 { - return errors.New("Received empty resource response") + return errors.New("received empty resource response") } return nil } if err != nil { if processedStreams == 0 { - return errutil.Wrap("Failed to receive response from resource call", err) + return errutil.Wrap("failed to receive response from resource call", err) } plugin.Logger().Error("Failed to receive response from resource call", "error", err) diff --git a/pkg/plugins/backendplugin/resource_response_stream.go b/pkg/plugins/backendplugin/resource_response_stream.go index 3b6617c9d1a..48567de109b 100644 --- a/pkg/plugins/backendplugin/resource_response_stream.go +++ b/pkg/plugins/backendplugin/resource_response_stream.go @@ -23,7 +23,7 @@ type callResourceResponseStream struct { func (s *callResourceResponseStream) Send(res *backend.CallResourceResponse) error { if s.closed { - return errors.New("Cannot send to a closed stream") + return errors.New("cannot send to a closed stream") } select { @@ -48,7 +48,7 @@ func (s *callResourceResponseStream) Recv() (*backend.CallResourceResponse, erro func (s *callResourceResponseStream) Close() error { if s.closed { - return errors.New("Cannot close a closed stream") + return errors.New("cannot close a closed stream") } close(s.stream) diff --git a/pkg/plugins/dashboard_importer.go b/pkg/plugins/dashboard_importer.go index a0d065aaf6c..ad565fdfa4d 100644 --- a/pkg/plugins/dashboard_importer.go +++ b/pkg/plugins/dashboard_importer.go @@ -113,8 +113,8 @@ type DashTemplateEvaluator struct { result *simplejson.Json } -func (this *DashTemplateEvaluator) findInput(varName string, varType string) *ImportDashboardInput { - for _, input := range this.inputs { +func (e *DashTemplateEvaluator) findInput(varName string, varType string) *ImportDashboardInput { + for _, input := range e.inputs { if varType == input.Type && (input.Name == varName || input.Name == "*") { return &input } @@ -123,34 +123,34 @@ func (this *DashTemplateEvaluator) findInput(varName string, varType string) *Im return nil } -func (this *DashTemplateEvaluator) Eval() (*simplejson.Json, error) { - this.result = simplejson.New() - this.variables = make(map[string]string) +func (e *DashTemplateEvaluator) Eval() (*simplejson.Json, error) { + e.result = simplejson.New() + e.variables = make(map[string]string) // check that we have all inputs we need - for _, inputDef := range this.template.Get("__inputs").MustArray() { + for _, inputDef := range e.template.Get("__inputs").MustArray() { inputDefJson := simplejson.NewFromAny(inputDef) inputName := inputDefJson.Get("name").MustString() inputType := inputDefJson.Get("type").MustString() - input := this.findInput(inputName, inputType) + input := e.findInput(inputName, inputType) if input == nil { return nil, &DashboardInputMissingError{VariableName: inputName} } - this.variables["${"+inputName+"}"] = input.Value + e.variables["${"+inputName+"}"] = input.Value } - return simplejson.NewFromAny(this.evalObject(this.template)), nil + return simplejson.NewFromAny(e.evalObject(e.template)), nil } -func (this *DashTemplateEvaluator) evalValue(source *simplejson.Json) interface{} { +func (e *DashTemplateEvaluator) evalValue(source *simplejson.Json) interface{} { sourceValue := source.Interface() switch v := sourceValue.(type) { case string: interpolated := varRegex.ReplaceAllStringFunc(v, func(match string) string { - replacement, exists := this.variables[match] + replacement, exists := e.variables[match] if exists { return replacement } @@ -162,11 +162,11 @@ func (this *DashTemplateEvaluator) evalValue(source *simplejson.Json) interface{ case json.Number: return v case map[string]interface{}: - return this.evalObject(source) + return e.evalObject(source) case []interface{}: array := make([]interface{}, 0) for _, item := range v { - array = append(array, this.evalValue(simplejson.NewFromAny(item))) + array = append(array, e.evalValue(simplejson.NewFromAny(item))) } return array } @@ -174,14 +174,14 @@ func (this *DashTemplateEvaluator) evalValue(source *simplejson.Json) interface{ return nil } -func (this *DashTemplateEvaluator) evalObject(source *simplejson.Json) interface{} { +func (e *DashTemplateEvaluator) evalObject(source *simplejson.Json) interface{} { result := make(map[string]interface{}) for key, value := range source.MustMap() { if key == "__inputs" { continue } - result[key] = this.evalValue(simplejson.NewFromAny(value)) + result[key] = e.evalValue(simplejson.NewFromAny(value)) } return result diff --git a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go index cc88f90f273..3ca6f5b6ee6 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go @@ -165,6 +165,6 @@ func (tw *DatasourcePluginWrapper) mapRowValue(rv *datasource.RowValue) (interfa case datasource.RowValue_TYPE_BYTES: return rv.BytesValue, nil default: - return nil, fmt.Errorf("Unsupported row value %v from plugin", rv.Kind) + return nil, fmt.Errorf("unsupported row value %v from plugin", rv.Kind) } } diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 0cffde11c10..898a08b66dd 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -40,14 +40,17 @@ var ( pluginScanningErrors map[string]*PluginError ) +type unsignedPluginConditionFunc = func(plugin *PluginBase) bool + type PluginScanner struct { - pluginPath string - errors []error - backendPluginManager backendplugin.Manager - cfg *setting.Cfg - requireSigned bool - log log.Logger - plugins map[string]*PluginBase + pluginPath string + errors []error + backendPluginManager backendplugin.Manager + cfg *setting.Cfg + requireSigned bool + log log.Logger + plugins map[string]*PluginBase + allowUnsignedPluginsCondition unsignedPluginConditionFunc } type PluginManager struct { @@ -55,6 +58,10 @@ type PluginManager struct { Cfg *setting.Cfg `inject:""` log log.Logger scanningErrors []error + + // AllowUnsignedPluginsCondition changes the policy for allowing unsigned plugins. Signature validation only runs when plugins are starting + // and running plugins will not be terminated if they violate the new policy. + AllowUnsignedPluginsCondition unsignedPluginConditionFunc } func init() { @@ -187,12 +194,13 @@ func (pm *PluginManager) scanPluginPaths() error { // scan a directory for plugins. func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error { scanner := &PluginScanner{ - pluginPath: pluginDir, - backendPluginManager: pm.BackendPluginManager, - cfg: pm.Cfg, - requireSigned: requireSigned, - log: pm.log, - plugins: map[string]*PluginBase{}, + pluginPath: pluginDir, + backendPluginManager: pm.BackendPluginManager, + cfg: pm.Cfg, + requireSigned: requireSigned, + log: pm.log, + plugins: map[string]*PluginBase{}, + allowUnsignedPluginsCondition: pm.AllowUnsignedPluginsCondition, } // 1st pass: Scan plugins, also mapping plugins to their respective directories @@ -302,7 +310,7 @@ func (pm *PluginManager) GetDatasource(pluginID string) (*DataSourcePlugin, bool return ds, exist } -func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error { +func (s *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error { // We scan all the subfolders for plugin.json (with some exceptions) so that we also load embedded plugins, for // example https://github.com/raintank/worldping-app/tree/master/dist/grafana-worldmap-panel worldmap panel plugin // is embedded in worldping app. @@ -322,9 +330,9 @@ func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err erro return nil } - if err := scanner.loadPlugin(currentPath); err != nil { - scanner.log.Error("Failed to load plugin", "error", err, "pluginPath", filepath.Dir(currentPath)) - scanner.errors = append(scanner.errors, err) + if err := s.loadPlugin(currentPath); err != nil { + s.log.Error("Failed to load plugin", "error", err, "pluginPath", filepath.Dir(currentPath)) + s.errors = append(s.errors, err) } return nil @@ -367,18 +375,12 @@ func (s *PluginScanner) loadPlugin(pluginJSONFilePath string) error { return nil } -func (scanner *PluginScanner) IsBackendOnlyPlugin(pluginType string) bool { +func (*PluginScanner) IsBackendOnlyPlugin(pluginType string) bool { return pluginType == "renderer" || pluginType == "transform" } // validateSignature validates a plugin's signature. func (s *PluginScanner) validateSignature(plugin *PluginBase) *PluginError { - // For the time being, we choose to only require back-end plugins to be signed - // NOTE: the state is calculated again when setting metadata on the object - if !plugin.Backend || !s.requireSigned { - return nil - } - if plugin.Signature == PluginSignatureValid { s.log.Debug("Plugin has valid signature", "id", plugin.Id) return nil @@ -403,23 +405,21 @@ func (s *PluginScanner) validateSignature(plugin *PluginBase) *PluginError { "state", plugin.Signature) } + // For the time being, we choose to only require back-end plugins to be signed + // NOTE: the state is calculated again when setting metadata on the object + if !plugin.Backend || !s.requireSigned { + return nil + } + switch plugin.Signature { case PluginSignatureUnsigned: - allowUnsigned := false - for _, plug := range s.cfg.PluginsAllowUnsigned { - if plug == plugin.Id { - allowUnsigned = true - break - } - } - if setting.Env != setting.Dev && !allowUnsigned { + if allowed := s.allowUnsigned(plugin); !allowed { s.log.Debug("Plugin is unsigned", "id", plugin.Id) s.errors = append(s.errors, fmt.Errorf("plugin %q is unsigned", plugin.Id)) return &PluginError{ ErrorCode: signatureMissing, } } - s.log.Warn("Running an unsigned backend plugin", "pluginID", plugin.Id, "pluginDir", plugin.PluginDir) return nil @@ -440,6 +440,24 @@ func (s *PluginScanner) validateSignature(plugin *PluginBase) *PluginError { } } +func (s *PluginScanner) allowUnsigned(plugin *PluginBase) bool { + if s.allowUnsignedPluginsCondition != nil { + return s.allowUnsignedPluginsCondition(plugin) + } + + if setting.Env == setting.Dev { + return true + } + + for _, plug := range s.cfg.PluginsAllowUnsigned { + if plug == plugin.Id { + return true + } + } + + return false +} + func ScanningErrors() []PluginError { scanningErrs := make([]PluginError, 0) for id, e := range pluginScanningErrors { diff --git a/pkg/plugins/transform_plugin.go b/pkg/plugins/transform_plugin.go index 02a085c0efd..452f2f2698b 100644 --- a/pkg/plugins/transform_plugin.go +++ b/pkg/plugins/transform_plugin.go @@ -149,7 +149,7 @@ func (s *transformCallback) QueryData(ctx context.Context, req *pluginv2.QueryDa } if err := bus.Dispatch(getDsInfo); err != nil { - return nil, fmt.Errorf("Could not find datasource %v", err) + return nil, fmt.Errorf("could not find datasource: %w", err) } // Convert plugin-model (datasource) queries to tsdb queries diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 5894a657286..be3497f7ad8 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -70,7 +70,6 @@ func getServicesWithOverrides() []*Descriptor { // Service interface is the lowest common shape that services // are expected to fulfill to be started within Grafana. type Service interface { - // Init is called by Grafana main process which gives the service // the possibility do some initial work before its started. Things // like adding routes, bus handlers should be done in the Init function @@ -82,7 +81,6 @@ type Service interface { // that might not always be started, ex alerting. // This will be called after `Init()`. type CanBeDisabled interface { - // IsDisabled should return a bool saying if it can be started or not. IsDisabled() bool } @@ -99,13 +97,12 @@ type BackgroundService interface { // DatabaseMigrator allows the caller to add migrations to // the migrator passed as argument type DatabaseMigrator interface { - // AddMigrations allows the service to add migrations to // the database migrator. AddMigration(mg *migrator.Migrator) } -// IsDisabled takes an service and return true if its disabled +// IsDisabled returns whether a service is disabled. func IsDisabled(srv Service) bool { canBeDisabled, ok := srv.(CanBeDisabled) return ok && canBeDisabled.IsDisabled() diff --git a/pkg/server/server.go b/pkg/server/server.go index 269197a5b91..c8c4d060ae2 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -23,7 +23,6 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" - _ "github.com/grafana/grafana/pkg/infra/metrics" _ "github.com/grafana/grafana/pkg/infra/remotecache" _ "github.com/grafana/grafana/pkg/infra/serverlock" _ "github.com/grafana/grafana/pkg/infra/tracing" @@ -293,7 +292,7 @@ func (s *Server) buildServiceGraph(services []*registry.Descriptor) error { // Resolve services and their dependencies. if err := serviceGraph.Populate(); err != nil { - return errutil.Wrapf(err, "Failed to populate service dependency") + return errutil.Wrapf(err, "Failed to populate service dependencies") } return nil diff --git a/pkg/services/alerting/alerting_usage.go b/pkg/services/alerting/alerting_usage.go index 102eeeb6e5e..bba75358688 100644 --- a/pkg/services/alerting/alerting_usage.go +++ b/pkg/services/alerting/alerting_usage.go @@ -25,14 +25,14 @@ type UsageStatsQuerier interface { // QueryUsageStats returns usage stats about alert rules // configured in Grafana. -func (ae *AlertEngine) QueryUsageStats() (*UsageStats, error) { +func (e *AlertEngine) QueryUsageStats() (*UsageStats, error) { cmd := &models.GetAllAlertsQuery{} - err := ae.Bus.Dispatch(cmd) + err := e.Bus.Dispatch(cmd) if err != nil { return nil, err } - dsUsage, err := ae.mapRulesToUsageStats(cmd.Result) + dsUsage, err := e.mapRulesToUsageStats(cmd.Result) if err != nil { return nil, err } @@ -42,13 +42,13 @@ func (ae *AlertEngine) QueryUsageStats() (*UsageStats, error) { }, nil } -func (ae *AlertEngine) mapRulesToUsageStats(rules []*models.Alert) (DatasourceAlertUsage, error) { +func (e *AlertEngine) mapRulesToUsageStats(rules []*models.Alert) (DatasourceAlertUsage, error) { // map of datasourceId type and frequency typeCount := map[int64]int{} for _, a := range rules { - dss, err := ae.parseAlertRuleModel(a.Settings) + dss, err := e.parseAlertRuleModel(a.Settings) if err != nil { - ae.log.Debug("could not parse settings for alert rule", "id", a.Id) + e.log.Debug("could not parse settings for alert rule", "id", a.Id) continue } @@ -62,7 +62,7 @@ func (ae *AlertEngine) mapRulesToUsageStats(rules []*models.Alert) (DatasourceAl result := map[string]int{} for k, v := range typeCount { query := &models.GetDataSourceByIdQuery{Id: k} - err := ae.Bus.Dispatch(query) + err := e.Bus.Dispatch(query) if err != nil { return map[string]int{}, nil } @@ -74,7 +74,7 @@ func (ae *AlertEngine) mapRulesToUsageStats(rules []*models.Alert) (DatasourceAl return result, nil } -func (ae *AlertEngine) parseAlertRuleModel(settings json.Marshaler) ([]int64, error) { +func (e *AlertEngine) parseAlertRuleModel(settings json.Marshaler) ([]int64, error) { datasourceIDs := []int64{} model := alertJSONModel{} diff --git a/pkg/services/alerting/conditions/evaluator.go b/pkg/services/alerting/conditions/evaluator.go index 0466ceff6c7..76afe8919fb 100644 --- a/pkg/services/alerting/conditions/evaluator.go +++ b/pkg/services/alerting/conditions/evaluator.go @@ -35,12 +35,12 @@ type thresholdEvaluator struct { func newThresholdEvaluator(typ string, model *simplejson.Json) (*thresholdEvaluator, error) { params := model.Get("params").MustArray() if len(params) == 0 || params[0] == nil { - return nil, fmt.Errorf("Evaluator '%v' is missing the threshold parameter", HumanThresholdType(typ)) + return nil, fmt.Errorf("evaluator '%v' is missing the threshold parameter", HumanThresholdType(typ)) } firstParam, ok := params[0].(json.Number) if !ok { - return nil, fmt.Errorf("Evaluator has invalid parameter") + return nil, fmt.Errorf("evaluator has invalid parameter") } defaultEval := &thresholdEvaluator{Type: typ} @@ -113,7 +113,7 @@ func (e *rangedEvaluator) Eval(reducedValue null.Float) bool { func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) { typ := model.Get("type").MustString() if typ == "" { - return nil, fmt.Errorf("Evaluator missing type property") + return nil, fmt.Errorf("evaluator missing type property") } if inSlice(typ, defaultTypes) { @@ -128,7 +128,7 @@ func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) { return &noValueEvaluator{}, nil } - return nil, fmt.Errorf("Evaluator invalid evaluator type: %s", typ) + return nil, fmt.Errorf("evaluator invalid evaluator type: %s", typ) } func inSlice(a string, list []string) bool { diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index 9a24246f5ef..80f7c425414 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -113,7 +113,7 @@ func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange * } if err := bus.Dispatch(getDsInfo); err != nil { - return nil, fmt.Errorf("Could not find datasource %v", err) + return nil, fmt.Errorf("could not find datasource: %w", err) } req := c.getRequestForAlertRule(getDsInfo.Result, timeRange, context.IsDebug) @@ -159,7 +159,7 @@ func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange * resp, err := c.HandleRequest(context.Ctx, getDsInfo.Result, req) if err != nil { if err == gocontext.DeadlineExceeded { - return nil, fmt.Errorf("Alert execution exceeded the timeout") + return nil, fmt.Errorf("alert execution exceeded the timeout") } return nil, fmt.Errorf("tsdb.HandleRequest() error %v", err) diff --git a/pkg/services/alerting/conditions/query_test.go b/pkg/services/alerting/conditions/query_test.go index 54765e7328d..bd1a2b75b23 100644 --- a/pkg/services/alerting/conditions/query_test.go +++ b/pkg/services/alerting/conditions/query_test.go @@ -20,6 +20,16 @@ import ( "github.com/xorcare/pointer" ) +func newTimeSeriesPointsFromArgs(values ...float64) tsdb.TimeSeriesPoints { + points := make(tsdb.TimeSeriesPoints, 0) + + for i := 0; i < len(values); i += 2 { + points = append(points, tsdb.NewTimePoint(null.FloatFrom(values[i]), values[i+1])) + } + + return points +} + func TestQueryCondition(t *testing.T) { Convey("when evaluating query condition", t, func() { queryConditionScenario("Given avg() and > 100", func(ctx *queryConditionTestContext) { @@ -47,7 +57,7 @@ func TestQueryCondition(t *testing.T) { }) Convey("should fire when avg is above 100", func() { - points := tsdb.NewTimeSeriesPointsFromArgs(120, 0) + points := newTimeSeriesPointsFromArgs(120, 0) ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", points)} cr, err := ctx.exec() @@ -67,7 +77,7 @@ func TestQueryCondition(t *testing.T) { }) Convey("Should not fire when avg is below 100", func() { - points := tsdb.NewTimeSeriesPointsFromArgs(90, 0) + points := newTimeSeriesPointsFromArgs(90, 0) ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", points)} cr, err := ctx.exec() @@ -88,8 +98,8 @@ func TestQueryCondition(t *testing.T) { Convey("Should fire if only first series matches", func() { ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs(120, 0)), - tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs(0, 0)), + tsdb.NewTimeSeries("test1", newTimeSeriesPointsFromArgs(120, 0)), + tsdb.NewTimeSeries("test2", newTimeSeriesPointsFromArgs(0, 0)), } cr, err := ctx.exec() @@ -121,7 +131,7 @@ func TestQueryCondition(t *testing.T) { Convey("Should set Firing if eval match", func() { ctx.evaluator = `{"type": "no_value", "params": []}` ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), + tsdb.NewTimeSeries("test1", newTimeSeriesPointsFromArgs()), } cr, err := ctx.exec() @@ -131,8 +141,8 @@ func TestQueryCondition(t *testing.T) { Convey("Should set NoDataFound both series are empty", func() { ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), - tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs()), + tsdb.NewTimeSeries("test1", newTimeSeriesPointsFromArgs()), + tsdb.NewTimeSeries("test2", newTimeSeriesPointsFromArgs()), } cr, err := ctx.exec() @@ -153,8 +163,8 @@ func TestQueryCondition(t *testing.T) { Convey("Should not set NoDataFound if one series is empty", func() { ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), - tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs(120, 0)), + tsdb.NewTimeSeries("test1", newTimeSeriesPointsFromArgs()), + tsdb.NewTimeSeries("test2", newTimeSeriesPointsFromArgs(120, 0)), } cr, err := ctx.exec() diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 8603aa232c4..585124278b3 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -2,7 +2,6 @@ package alerting import ( "context" - "errors" "fmt" "time" @@ -281,7 +280,7 @@ func (n *notificationService) getNeededNotifiers(orgID int64, notificationUids [ func InitNotifier(model *models.AlertNotification) (Notifier, error) { notifierPlugin, found := notifierFactories[model.Type] if !found { - return nil, errors.New("Unsupported notification type") + return nil, fmt.Errorf("unsupported notification type %q", model.Type) } return notifierPlugin.Factory(model) diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index 0618f7273c0..dd1d7043e1f 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -89,7 +89,7 @@ func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext { } ctx.IsTestRun = true ctx.Firing = true - ctx.Error = fmt.Errorf("This is only a test") + ctx.Error = fmt.Errorf("this is only a test") ctx.EvalMatches = evalMatchesBasedOnState() return ctx diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go index b91619b7889..c276572c261 100644 --- a/pkg/services/alerting/test_rule.go +++ b/pkg/services/alerting/test_rule.go @@ -45,7 +45,7 @@ func handleAlertTestCommand(cmd *AlertTestCommand) error { } } - return fmt.Errorf("Could not find alert with panel id %d", cmd.PanelID) + return fmt.Errorf("could not find alert with panel ID %d", cmd.PanelID) } func testAlertRule(rule *Rule) *EvalContext { diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index ceb19d5f5cc..cc25567e53e 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -35,15 +35,6 @@ type ItemQuery struct { Limit int64 `json:"limit"` } -type PostParams struct { - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - Epoch int64 `json:"epoch"` - Title string `json:"title"` - Text string `json:"text"` - Icon string `json:"icon"` -} - type DeleteParams struct { OrgId int64 Id int64 diff --git a/pkg/services/auth/token_cleanup.go b/pkg/services/auth/token_cleanup.go index 9658eff9ab4..bf291fc1bc9 100644 --- a/pkg/services/auth/token_cleanup.go +++ b/pkg/services/auth/token_cleanup.go @@ -7,31 +7,31 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" ) -func (srv *UserAuthTokenService) Run(ctx context.Context) error { +func (s *UserAuthTokenService) Run(ctx context.Context) error { var err error ticker := time.NewTicker(time.Hour) - maxInactiveLifetime := srv.Cfg.LoginMaxInactiveLifetime - maxLifetime := srv.Cfg.LoginMaxLifetime + maxInactiveLifetime := s.Cfg.LoginMaxInactiveLifetime + maxLifetime := s.Cfg.LoginMaxLifetime - err = srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { - if _, err := srv.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { - srv.log.Error("An error occurred while deleting expired tokens", "err", err) + err = s.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { + if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { + s.log.Error("An error occurred while deleting expired tokens", "err", err) } }) if err != nil { - srv.log.Error("failed to lock and execute cleanup of expired auth token", "error", err) + s.log.Error("failed to lock and execute cleanup of expired auth token", "error", err) } for { select { case <-ticker.C: - err = srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { - if _, err := srv.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { - srv.log.Error("An error occurred while deleting expired tokens", "err", err) + err = s.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() { + if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { + s.log.Error("An error occurred while deleting expired tokens", "err", err) } }) if err != nil { - srv.log.Error("failed to lock and execute cleanup of expired auth token", "error", err) + s.log.Error("failed to lock and execute cleanup of expired auth token", "error", err) } case <-ctx.Done(): @@ -40,14 +40,14 @@ func (srv *UserAuthTokenService) Run(ctx context.Context) error { } } -func (srv *UserAuthTokenService) deleteExpiredTokens(ctx context.Context, maxInactiveLifetime, maxLifetime time.Duration) (int64, error) { +func (s *UserAuthTokenService) deleteExpiredTokens(ctx context.Context, maxInactiveLifetime, maxLifetime time.Duration) (int64, error) { createdBefore := getTime().Add(-maxLifetime) rotatedBefore := getTime().Add(-maxInactiveLifetime) - srv.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore) + s.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore) var affected int64 - err := srv.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + err := s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { sql := `DELETE from user_auth_token WHERE created_at <= ? OR rotated_at <= ?` res, err := dbSession.Exec(sql, createdBefore.Unix(), rotatedBefore.Unix()) if err != nil { @@ -56,11 +56,11 @@ func (srv *UserAuthTokenService) deleteExpiredTokens(ctx context.Context, maxIna affected, err = res.RowsAffected() if err != nil { - srv.log.Error("failed to cleanup expired auth tokens", "error", err) + s.log.Error("failed to cleanup expired auth tokens", "error", err) return nil } - srv.log.Debug("cleanup of expired auth tokens done", "count", affected) + s.log.Debug("cleanup of expired auth tokens done", "count", affected) return nil }) diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index 13611b49be8..4d0559a3469 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -190,11 +190,11 @@ func validateDashboardRefreshInterval(dash *models.Dashboard) error { return nil } - minRefreshInterval, err := gtime.ParseInterval(setting.MinRefreshInterval) + minRefreshInterval, err := gtime.ParseDuration(setting.MinRefreshInterval) if err != nil { return err } - d, err := gtime.ParseInterval(refresh) + d, err := gtime.ParseDuration(refresh) if err != nil { return err } diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 1d2036759ad..7674c8c9b43 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -10,8 +10,8 @@ import ( ) var ( - ErrGuardianPermissionExists = errors.New("Permission already exists") - ErrGuardianOverride = errors.New("You can only override a permission to be higher") + ErrGuardianPermissionExists = errors.New("permission already exists") + ErrGuardianOverride = errors.New("you can only override a permission to be higher") ) // DashboardGuardian to be used for guard against operations without access on dashboard and acl diff --git a/pkg/services/guardian/guardian_util_test.go b/pkg/services/guardian/guardian_util_test.go index 830ed354a24..8bbca7339ea 100644 --- a/pkg/services/guardian/guardian_util_test.go +++ b/pkg/services/guardian/guardian_util_test.go @@ -147,24 +147,24 @@ const ( VIEWER_ACCESS = CAN_VIEW ) -func (flag permissionFlags) canAdmin() bool { - return flag&CAN_ADMIN != 0 +func (f permissionFlags) canAdmin() bool { + return f&CAN_ADMIN != 0 } -func (flag permissionFlags) canEdit() bool { - return flag&CAN_EDIT != 0 +func (f permissionFlags) canEdit() bool { + return f&CAN_EDIT != 0 } -func (flag permissionFlags) canSave() bool { - return flag&CAN_SAVE != 0 +func (f permissionFlags) canSave() bool { + return f&CAN_SAVE != 0 } -func (flag permissionFlags) canView() bool { - return flag&CAN_VIEW != 0 +func (f permissionFlags) canView() bool { + return f&CAN_VIEW != 0 } -func (flag permissionFlags) noAccess() bool { - return flag&(CAN_ADMIN|CAN_EDIT|CAN_SAVE|CAN_VIEW) == 0 +func (f permissionFlags) noAccess() bool { + return f&(CAN_ADMIN|CAN_EDIT|CAN_SAVE|CAN_VIEW) == 0 } func (f permissionFlags) String() string { diff --git a/pkg/services/hooks/hooks.go b/pkg/services/hooks/hooks.go index 384ffd3367d..92d08db01aa 100644 --- a/pkg/services/hooks/hooks.go +++ b/pkg/services/hooks/hooks.go @@ -8,8 +8,11 @@ import ( type IndexDataHook func(indexData *dtos.IndexViewData, req *models.ReqContext) +type LoginHook func(loginInfo *models.LoginInfo, req *models.ReqContext) + type HooksService struct { indexDataHooks []IndexDataHook + loginHooks []LoginHook } func init() { @@ -29,3 +32,13 @@ func (srv *HooksService) RunIndexDataHooks(indexData *dtos.IndexViewData, req *m hook(indexData, req) } } + +func (srv *HooksService) AddLoginHook(hook LoginHook) { + srv.loginHooks = append(srv.loginHooks, hook) +} + +func (srv *HooksService) RunLoginHook(loginInfo *models.LoginInfo, req *models.ReqContext) { + for _, hook := range srv.loginHooks { + hook(loginInfo, req) + } +} diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index 3c89311954f..f494b315073 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -73,10 +73,10 @@ const UsersMaxRequest = 500 var ( // ErrInvalidCredentials is returned if username and password do not match - ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrInvalidCredentials = errors.New("invalid username or password") // ErrCouldNotFindUser is returned when username hasn't been found (not username+password) - ErrCouldNotFindUser = errors.New("Can't find user in LDAP") + ErrCouldNotFindUser = errors.New("can't find user in LDAP") ) // New creates the new LDAP connection diff --git a/pkg/services/licensing/oss.go b/pkg/services/licensing/oss.go index 0e967840036..423903d59d2 100644 --- a/pkg/services/licensing/oss.go +++ b/pkg/services/licensing/oss.go @@ -7,6 +7,10 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +const ( + openSource = "Open Source" +) + type OSSLicensingService struct { Cfg *setting.Cfg `inject:""` HooksService *hooks.HooksService `inject:""` @@ -21,7 +25,7 @@ func (*OSSLicensingService) Expiry() int64 { } func (*OSSLicensingService) Edition() string { - return "Open Source" + return openSource } func (*OSSLicensingService) StateInfo() string { diff --git a/pkg/services/live/features/broadcast.go b/pkg/services/live/features/broadcast.go index 93f0c8ba37c..1126ebe1f23 100644 --- a/pkg/services/live/features/broadcast.go +++ b/pkg/services/live/features/broadcast.go @@ -1,33 +1,38 @@ package features import ( + "time" + "github.com/centrifugal/centrifuge" "github.com/grafana/grafana/pkg/models" ) // BroadcastRunner will simply broadcast all events to `grafana/broadcast/*` channels // This assumes that data is a JSON object -type BroadcastRunner struct { -} +type BroadcastRunner struct{} // GetHandlerForPath called on init func (b *BroadcastRunner) GetHandlerForPath(path string) (models.ChannelHandler, error) { - return b, nil // for now all channels share config + return b, nil // all dashboards share the same handler } -// GetChannelOptions called fast and often -func (b *BroadcastRunner) GetChannelOptions(id string) centrifuge.ChannelOptions { - return centrifuge.ChannelOptions{} +// OnSubscribe will let anyone connect to the path +func (b *BroadcastRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { + return centrifuge.SubscribeReply{ + Options: centrifuge.SubscribeOptions{ + Presence: true, + JoinLeave: true, + Recover: true, // loads the saved value from history + }, + }, nil } -// OnSubscribe for now allows anyone to subscribe to any dashboard -func (b *BroadcastRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { - // anyone can subscribe - return nil -} - -// OnPublish called when an event is received from the websocket -func (b *BroadcastRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { - // expect the data to be the right shape? - return e.Data, nil +// OnPublish is called when a client wants to broadcast on the websocket +func (b *BroadcastRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { + return centrifuge.PublishReply{ + Options: centrifuge.PublishOptions{ + HistorySize: 1, // The last message is saved for 10 mins + HistoryTTL: 10 * time.Minute, + }, + }, nil } diff --git a/pkg/services/live/features/dashboard.go b/pkg/services/live/features/dashboard.go index 569f6279717..4f8cba42921 100644 --- a/pkg/services/live/features/dashboard.go +++ b/pkg/services/live/features/dashboard.go @@ -21,42 +21,39 @@ type DashboardHandler struct { } // GetHandlerForPath called on init -func (g *DashboardHandler) GetHandlerForPath(path string) (models.ChannelHandler, error) { - return g, nil // all dashboards share the same handler -} - -// GetChannelOptions called fast and often -func (g *DashboardHandler) GetChannelOptions(id string) centrifuge.ChannelOptions { - return centrifuge.ChannelOptions{ - Presence: true, - JoinLeave: true, // if enterprise? - } +func (h *DashboardHandler) GetHandlerForPath(path string) (models.ChannelHandler, error) { + return h, nil // all dashboards share the same handler } // OnSubscribe for now allows anyone to subscribe to any dashboard -func (g *DashboardHandler) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { - // TODO? check authentication - return nil +func (h *DashboardHandler) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { + return centrifuge.SubscribeReply{ + Options: centrifuge.SubscribeOptions{ + Presence: true, + JoinLeave: true, + }, + }, nil } -// OnPublish called when an event is received from the websocket -func (g *DashboardHandler) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { - // TODO -- verify and keep track of editors? - return e.Data, nil +// OnPublish is called when someone begins to edit a dashoard +func (h *DashboardHandler) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { + return centrifuge.PublishReply{ + Options: centrifuge.PublishOptions{}, + }, nil } // DashboardSaved should broadcast to the appropriate stream -func (g *DashboardHandler) publish(event dashboardEvent) error { +func (h *DashboardHandler) publish(event dashboardEvent) error { msg, err := json.Marshal(event) if err != nil { return err } - return g.Publisher("grafana/dashboard/"+event.UID, msg) + return h.Publisher("grafana/dashboard/"+event.UID, msg) } // DashboardSaved will broadcast to all connected dashboards -func (g *DashboardHandler) DashboardSaved(uid string, userID int64) error { - return g.publish(dashboardEvent{ +func (h *DashboardHandler) DashboardSaved(uid string, userID int64) error { + return h.publish(dashboardEvent{ UID: uid, Action: "saved", UserID: userID, @@ -64,8 +61,8 @@ func (g *DashboardHandler) DashboardSaved(uid string, userID int64) error { } // DashboardDeleted will broadcast to all connected dashboards -func (g *DashboardHandler) DashboardDeleted(uid string, userID int64) error { - return g.publish(dashboardEvent{ +func (h *DashboardHandler) DashboardDeleted(uid string, userID int64) error { + return h.publish(dashboardEvent{ UID: uid, Action: "deleted", UserID: userID, diff --git a/pkg/services/live/features/measurements.go b/pkg/services/live/features/measurements.go index 6c78ef90353..9647a023346 100644 --- a/pkg/services/live/features/measurements.go +++ b/pkg/services/live/features/measurements.go @@ -21,21 +21,15 @@ func (m *MeasurementsRunner) GetHandlerForPath(path string) (models.ChannelHandl return m, nil // for now all channels share config } -// GetChannelOptions gets channel options. -// It gets called fast and often. -func (m *MeasurementsRunner) GetChannelOptions(id string) centrifuge.ChannelOptions { - return centrifuge.ChannelOptions{} +// OnSubscribe will let anyone connect to the path +func (m *MeasurementsRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { + return centrifuge.SubscribeReply{}, nil } -// OnSubscribe for now allows anyone to subscribe to any dashboard. -func (m *MeasurementsRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { - // anyone can subscribe - return nil -} - -// OnPublish is called when an event is received from the websocket. -func (m *MeasurementsRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { - // currently generic... but should be stricter - // logger.Debug("Measurements runner got event on channel", "channel", e.Channel) - return e.Data, nil +// OnPublish is called when a client wants to broadcast on the websocket +// Currently this sends measurements over websocket -- should be replaced with the HTTP interface +func (m *MeasurementsRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { + return centrifuge.PublishReply{ + Options: centrifuge.PublishOptions{}, + }, nil } diff --git a/pkg/services/live/features/testdata.go b/pkg/services/live/features/testdata.go index fdbe393adad..ee41aebd9f1 100644 --- a/pkg/services/live/features/testdata.go +++ b/pkg/services/live/features/testdata.go @@ -27,12 +27,12 @@ type TestDataSupplier struct { // GetHandlerForPath gets the channel handler for a path. // Called on init. -func (g *TestDataSupplier) GetHandlerForPath(path string) (models.ChannelHandler, error) { +func (s *TestDataSupplier) GetHandlerForPath(path string) (models.ChannelHandler, error) { channel := "grafana/testdata/" + path if path == "random-2s-stream" { return &testDataRunner{ - publisher: g.Publisher, + publisher: s.Publisher, running: false, speedMillis: 2000, dropPercent: 0, @@ -43,7 +43,7 @@ func (g *TestDataSupplier) GetHandlerForPath(path string) (models.ChannelHandler if path == "random-flakey-stream" { return &testDataRunner{ - publisher: g.Publisher, + publisher: s.Publisher, running: false, speedMillis: 400, dropPercent: .6, @@ -54,39 +54,32 @@ func (g *TestDataSupplier) GetHandlerForPath(path string) (models.ChannelHandler return nil, fmt.Errorf("unknown channel") } -// GetChannelOptions gets channel options. -// Called fast and often. -func (g *testDataRunner) GetChannelOptions(id string) centrifuge.ChannelOptions { - return centrifuge.ChannelOptions{} -} - -// OnSubscribe for now allows anyone to subscribe to any dashboard. -func (g *testDataRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { - if !g.running { - g.running = true +// OnSubscribe will let anyone connect to the path +func (r *testDataRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { + if !r.running { + r.running = true // Run in the background - go g.runRandomCSV() + go r.runRandomCSV() } - // TODO? check authentication - return nil + return centrifuge.SubscribeReply{}, nil } -// OnPublish is called when an event is received from the websocket. -func (g *testDataRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { - return nil, fmt.Errorf("can not publish to testdata") +// OnPublish checks if a message from the websocket can be broadcast on this channel +func (r *testDataRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { + return centrifuge.PublishReply{}, fmt.Errorf("can not publish to testdata") } // runRandomCSV is just for an example. -func (g *testDataRunner) runRandomCSV() { +func (r *testDataRunner) runRandomCSV() { spread := 50.0 walker := rand.Float64() * 100 - ticker := time.NewTicker(time.Duration(g.speedMillis) * time.Millisecond) + ticker := time.NewTicker(time.Duration(r.speedMillis) * time.Millisecond) measurement := models.Measurement{ - Name: g.name, + Name: r.name, Time: 0, Values: make(map[string]interface{}, 5), } @@ -95,7 +88,7 @@ func (g *testDataRunner) runRandomCSV() { } for t := range ticker.C { - if rand.Float64() <= g.dropPercent { + if rand.Float64() <= r.dropPercent { continue } delta := rand.Float64() - 0.5 @@ -112,9 +105,9 @@ func (g *testDataRunner) runRandomCSV() { continue } - err = g.publisher(g.channel, bytes) + err = r.publisher(r.channel, bytes) if err != nil { - logger.Warn("write", "channel", g.channel, "measurement", measurement) + logger.Warn("write", "channel", r.channel, "measurement", measurement) } } } diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 9d9fe981f11..31dd91e6adb 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -1,16 +1,19 @@ package live import ( - "encoding/json" "fmt" "strings" "sync" "github.com/centrifugal/centrifuge" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/live/features" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch" ) var ( @@ -18,6 +21,16 @@ var ( loggerCF = log.New("live.centrifuge") ) +func init() { + registry.RegisterService(&GrafanaLive{ + channels: make(map[string]models.ChannelHandler), + channelsMu: sync.RWMutex{}, + GrafanaScope: CoreGrafanaScope{ + Features: make(map[string]models.ChannelHandlerFactory), + }, + }) +} + // CoreGrafanaScope list of core features type CoreGrafanaScope struct { Features map[string]models.ChannelHandlerFactory @@ -28,7 +41,10 @@ type CoreGrafanaScope struct { // GrafanaLive pretends to be the server type GrafanaLive struct { - node *centrifuge.Node + Cfg *setting.Cfg `inject:""` + RouteRegister routing.RouteRegister `inject:""` + LogsService *cloudwatch.LogsService `inject:""` + node *centrifuge.Node // The websocket handler WebsocketHandler interface{} @@ -41,14 +57,14 @@ type GrafanaLive struct { GrafanaScope CoreGrafanaScope } -// InitializeBroker initializes the broker and starts listening for requests. -func InitializeBroker() (*GrafanaLive, error) { - glive := &GrafanaLive{ - channels: make(map[string]models.ChannelHandler), - channelsMu: sync.RWMutex{}, - GrafanaScope: CoreGrafanaScope{ - Features: make(map[string]models.ChannelHandlerFactory), - }, +// Init initializes the instance. +// Required to implement the registry.Service interface. +func (g *GrafanaLive) Init() error { + logger.Debug("GrafanaLive initing") + + if !g.IsEnabled() { + logger.Debug("GrafanaLive feature not enabled, skipping initialization") + return nil } // We use default config here as starting point. Default config contains @@ -58,107 +74,61 @@ func InitializeBroker() (*GrafanaLive, error) { // cfg.LogLevel = centrifuge.LogLevelDebug cfg.LogHandler = handleLog - // This function is called fast and often -- it must be sychronized - cfg.ChannelOptionsFunc = func(channel string) (centrifuge.ChannelOptions, bool, error) { - handler, err := glive.GetChannelHandler(channel) - if err != nil { - logger.Error("ChannelOptionsFunc", "channel", channel, "err", err) - if err.Error() == "404" { // ???? - return centrifuge.ChannelOptions{}, false, nil - } - return centrifuge.ChannelOptions{}, true, err - } - opts := handler.GetChannelOptions(channel) - return opts, true, nil - } - // Node is the core object in Centrifuge library responsible for many useful // things. For example Node allows to publish messages to channels from server // side with its Publish method, but in this example we will publish messages // only from client side. node, err := centrifuge.New(cfg) if err != nil { - return nil, err + return err } - glive.node = node + g.node = node // Initialize the main features dash := &features.DashboardHandler{ - Publisher: glive.Publish, + Publisher: g.Publish, } - glive.GrafanaScope.Dashboards = dash - glive.GrafanaScope.Features["dashboard"] = dash - glive.GrafanaScope.Features["testdata"] = &features.TestDataSupplier{ - Publisher: glive.Publish, + g.GrafanaScope.Dashboards = dash + g.GrafanaScope.Features["dashboard"] = dash + g.GrafanaScope.Features["testdata"] = &features.TestDataSupplier{ + Publisher: g.Publish, } - glive.GrafanaScope.Features["broadcast"] = &features.BroadcastRunner{} - glive.GrafanaScope.Features["measurements"] = &features.MeasurementsRunner{} + g.GrafanaScope.Features["broadcast"] = &features.BroadcastRunner{} + g.GrafanaScope.Features["measurements"] = &features.MeasurementsRunner{} // Set ConnectHandler called when client successfully connected to Node. Your code // inside handler must be synchronized since it will be called concurrently from // different goroutines (belonging to different client connections). This is also // true for other event handlers. - node.OnConnect(func(c *centrifuge.Client) { - // In our example transport will always be Websocket but it can also be SockJS. - transportName := c.Transport().Name() + node.OnConnect(func(client *centrifuge.Client) { + logger.Debug("Client connected", "user", client.UserID()) - // In our example clients connect with JSON protocol but it can also be Protobuf. - transportEncoding := c.Transport().Encoding() - logger.Debug("client connected", "transport", transportName, "encoding", transportEncoding) - }) + client.OnSubscribe(func(e centrifuge.SubscribeEvent, cb centrifuge.SubscribeCallback) { + handler, err := g.GetChannelHandler(e.Channel) + if err != nil { + cb(centrifuge.SubscribeReply{}, err) + } else { + cb(handler.OnSubscribe(client, e)) + } + }) - // Set Disconnect handler to react on client disconnect events. - node.OnDisconnect(func(c *centrifuge.Client, e centrifuge.DisconnectEvent) { - logger.Info("client disconnected") - }) - - // Set SubscribeHandler to react on every channel subscription attempt - // initiated by client. Here you can theoretically return an error or - // disconnect client from server if needed. But now we just accept - // all subscriptions to all channels. In real life you may use a more - // complex permission check here. - node.OnSubscribe(func(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { - reply := centrifuge.SubscribeReply{} - - handler, err := glive.GetChannelHandler(e.Channel) - if err != nil { - return reply, err - } - - err = handler.OnSubscribe(c, e) - if err != nil { - return reply, err - } - - return reply, nil - }) - - node.OnUnsubscribe(func(c *centrifuge.Client, e centrifuge.UnsubscribeEvent) { - logger.Debug("unsubscribe from channel", "channel", e.Channel, "user", c.UserID()) - }) - - // Called when something is written to the websocket - node.OnPublish(func(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { - reply := centrifuge.PublishReply{} - handler, err := glive.GetChannelHandler(e.Channel) - if err != nil { - return reply, err - } - - data, err := handler.OnPublish(c, e) - if err != nil { - return reply, err - } - if len(data) > 0 { - _, err = node.Publish(e.Channel, e.Data) - } - return centrifuge.PublishReply{}, err // returns an error if it could not publish + // Called when a client writes to the websocket channel. + // In general, we should prefer writing to the HTTP API, but this + // allows some simple prototypes to work quickly + client.OnPublish(func(e centrifuge.PublishEvent, cb centrifuge.PublishCallback) { + handler, err := g.GetChannelHandler(e.Channel) + if err != nil { + cb(centrifuge.PublishReply{}, err) + } else { + cb(handler.OnPublish(client, e)) + } + }) }) // Run node. This method does not block. if err := node.Run(); err != nil { - return nil, err + return err } // SockJS will find the best protocol possible for the browser @@ -175,33 +145,15 @@ func InitializeBroker() (*GrafanaLive, error) { WriteBufferSize: 1024, }) - glive.WebsocketHandler = func(ctx *models.ReqContext) { + g.WebsocketHandler = func(ctx *models.ReqContext) { user := ctx.SignedInUser if user == nil { ctx.Resp.WriteHeader(401) return } - dto := models.UserProfileDTO{ - Id: user.UserId, - Name: user.Name, - Email: user.Email, - Login: user.Login, - IsGrafanaAdmin: user.IsGrafanaAdmin, - OrgId: user.OrgId, - } - - jsonData, err := json.Marshal(dto) - if err != nil { - logger.Debug("error reading user", "dto", dto) - ctx.Resp.WriteHeader(404) - return - } - logger.Info("Logged in user", "user", user) - cred := ¢rifuge.Credentials{ UserID: fmt.Sprintf("%d", user.UserId), - Info: jsonData, } newCtx := centrifuge.SetCredentials(ctx.Req.Context(), cred) @@ -223,7 +175,10 @@ func InitializeBroker() (*GrafanaLive, error) { // Unknown path ctx.Resp.WriteHeader(404) } - return glive, nil + + g.RouteRegister.Any("/live/*", g.WebsocketHandler) + + return nil } // GetChannelHandler gives threadsafe access to the channel @@ -280,6 +235,14 @@ func (g *GrafanaLive) GetChannelHandlerFactory(scope string, name string) (model } if scope == "plugin" { + // Temporary hack until we have a more generic solution later on + if name == "cloudwatch" { + return &cloudwatch.LogQueryRunnerSupplier{ + Publisher: g.Publish, + Service: g.LogsService, + }, nil + } + p, ok := plugins.Plugins[name] if ok { h := &PluginHandler{ @@ -299,6 +262,11 @@ func (g *GrafanaLive) Publish(channel string, data []byte) error { return err } +// IsEnabled returns true if the Grafana Live feature is enabled. +func (g *GrafanaLive) IsEnabled() bool { + return g.Cfg.IsLiveEnabled() +} + // Write to the standard log15 logger func handleLog(msg centrifuge.LogEntry) { arr := make([]interface{}, 0) diff --git a/pkg/services/live/pluginHandler.go b/pkg/services/live/pluginHandler.go index 9941dd05b57..9e0319369f2 100644 --- a/pkg/services/live/pluginHandler.go +++ b/pkg/services/live/pluginHandler.go @@ -12,21 +12,16 @@ type PluginHandler struct { } // GetHandlerForPath called on init -func (g *PluginHandler) GetHandlerForPath(path string) (models.ChannelHandler, error) { - return g, nil // all dashboards share the same handler +func (h *PluginHandler) GetHandlerForPath(path string) (models.ChannelHandler, error) { + return h, nil // all dashboards share the same handler } -// GetChannelOptions called fast and often -func (g *PluginHandler) GetChannelOptions(id string) centrifuge.ChannelOptions { - return centrifuge.ChannelOptions{} +// OnSubscribe for now allows anyone to subscribe +func (h *PluginHandler) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { + return centrifuge.SubscribeReply{}, nil } -// OnSubscribe for now allows anyone to subscribe to any dashboard -func (g *PluginHandler) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) error { - return nil // anyone can subscribe -} - -// OnPublish called when an event is received from the websocket -func (g *PluginHandler) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) ([]byte, error) { - return e.Data, nil // broadcast any event +// OnPublish checks if a message from the websocket can be broadcast on this channel +func (h *PluginHandler) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { + return centrifuge.PublishReply{}, nil // broadcast any event } diff --git a/pkg/services/login/errors.go b/pkg/services/login/errors.go index 7f39389c16d..fa5fd5faf2c 100644 --- a/pkg/services/login/errors.go +++ b/pkg/services/login/errors.go @@ -3,7 +3,7 @@ package login import "errors" var ( - ErrInvalidCredentials = errors.New("Invalid Username or Password") - ErrUsersQuotaReached = errors.New("Users quota reached") - ErrGettingUserQuota = errors.New("Error getting user quota") + ErrInvalidCredentials = errors.New("invalid username or password") + ErrUsersQuotaReached = errors.New("users quota reached") + ErrGettingUserQuota = errors.New("error getting user quota") ) diff --git a/pkg/services/multildap/multildap.go b/pkg/services/multildap/multildap.go index 0f4447af729..a29dd004e5d 100644 --- a/pkg/services/multildap/multildap.go +++ b/pkg/services/multildap/multildap.go @@ -27,10 +27,10 @@ var ErrInvalidCredentials = ldap.ErrInvalidCredentials var ErrCouldNotFindUser = ldap.ErrCouldNotFindUser // ErrNoLDAPServers is returned when there is no LDAP servers specified -var ErrNoLDAPServers = errors.New("No LDAP servers are configured") +var ErrNoLDAPServers = errors.New("no LDAP servers are configured") // ErrDidNotFindUser if request for user is unsuccessful -var ErrDidNotFindUser = errors.New("Did not find a user") +var ErrDidNotFindUser = errors.New("did not find a user") // ServerStatus holds the LDAP server status type ServerStatus struct { diff --git a/pkg/services/ngalert/eval.go b/pkg/services/ngalert/eval.go index e565dae79e9..94866d642a4 100644 --- a/pkg/services/ngalert/eval.go +++ b/pkg/services/ngalert/eval.go @@ -109,12 +109,12 @@ func (ng *AlertNG) LoadAlertCondition(dashboardID int64, panelID int64, conditio blob, err := getDashboardQuery.Result.Data.MarshalJSON() if err != nil { - return nil, errors.New("Failed to marshal dashboard JSON") + return nil, errors.New("failed to marshal dashboard JSON") } var dash minimalDashboard err = json.Unmarshal(blob, &dash) if err != nil { - return nil, errors.New("Failed to unmarshal dashboard JSON") + return nil, errors.New("failed to unmarshal dashboard JSON") } condition := Condition{} @@ -144,7 +144,7 @@ func (ng *AlertNG) LoadAlertCondition(dashboardID int64, panelID int64, conditio } if ds == nil { - return nil, errors.New("No datasource reference found") + return nil, errors.New("no datasource reference found") } if queryDatasource == "" { @@ -192,7 +192,7 @@ func (ng *AlertNG) LoadAlertCondition(dashboardID int64, panelID int64, conditio func (c *Condition) Execute(ctx AlertExecCtx, fromStr, toStr string) (*ExecutionResults, error) { result := ExecutionResults{} if !c.IsValid() { - return nil, fmt.Errorf("Invalid conditions") + return nil, fmt.Errorf("invalid conditions") } request := &tsdb.TsdbQuery{ @@ -212,7 +212,7 @@ func (c *Condition) Execute(ctx AlertExecCtx, fromStr, toStr string) (*Execution conditionResult := resp.Results[c.RefID] if conditionResult == nil { - err = fmt.Errorf("No GEL results") + err = fmt.Errorf("no GEL results") result.Error = err return &result, err } @@ -234,24 +234,24 @@ func EvaluateExecutionResult(results *ExecutionResults) (Results, error) { for _, f := range results.Results { rowLen, err := f.RowLen() if err != nil { - return nil, fmt.Errorf("Unable to get frame row length") + return nil, fmt.Errorf("unable to get frame row length") } if rowLen > 1 { - return nil, fmt.Errorf("Invalid frame %v: row length %v", f.Name, rowLen) + return nil, fmt.Errorf("invalid frame %v: row length %v", f.Name, rowLen) } if len(f.Fields) > 1 { - return nil, fmt.Errorf("Invalid frame %v: field length %v", f.Name, len(f.Fields)) + return nil, fmt.Errorf("invalid frame %v: field length %v", f.Name, len(f.Fields)) } if f.Fields[0].Type() != data.FieldTypeNullableFloat64 { - return nil, fmt.Errorf("Invalid frame %v: field type %v", f.Name, f.Fields[0].Type()) + return nil, fmt.Errorf("invalid frame %v: field type %v", f.Name, f.Fields[0].Type()) } labelsStr := f.Fields[0].Labels.String() _, ok := labels[labelsStr] if ok { - return nil, fmt.Errorf("Invalid frame %v: frames cannot uniquely be identified by its labels: %q", f.Name, labelsStr) + return nil, fmt.Errorf("invalid frame %v: frames cannot uniquely be identified by its labels: %q", f.Name, labelsStr) } labels[labelsStr] = true diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 37df519a7fa..eb30184ea7c 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -105,7 +105,7 @@ func (ns *NotificationService) createDialer() (*gomail.Dialer, error) { if ns.Cfg.Smtp.CertFile != "" { cert, err := tls.LoadX509KeyPair(ns.Cfg.Smtp.CertFile, ns.Cfg.Smtp.KeyFile) if err != nil { - return nil, fmt.Errorf("Could not load cert or key file. error: %v", err) + return nil, fmt.Errorf("could not load cert or key file: %w", err) } tlsconfig.Certificates = []tls.Certificate{cert} } @@ -159,7 +159,7 @@ func (ns *NotificationService) buildEmailMessage(cmd *models.SendEmailCommand) ( subjectText, hasSubject := subjectData["value"] if !hasSubject { - return nil, fmt.Errorf("Missing subject in Template %s", cmd.Template) + return nil, fmt.Errorf("missing subject in template %s", cmd.Template) } subjectTmpl, err := template.New("subject").Parse(subjectText.(string)) diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index d101c20cbe7..beea82f43e6 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -63,7 +63,7 @@ func (ns *NotificationService) Init() error { } if !util.IsEmail(ns.Cfg.Smtp.FromAddress) { - return errors.New("Invalid email address for SMTP from_address config") + return errors.New("invalid email address for SMTP from_address config") } if setting.EmailCodeValidMinutes == 0 { diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index fa0ba04761e..be800a6dddd 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -20,7 +20,7 @@ import ( var ( // ErrFolderNameMissing is returned when folder name is missing. - ErrFolderNameMissing = errors.New("Folder name missing") + ErrFolderNameMissing = errors.New("folder name missing") ) // FileReader is responsible for reading dashboards from disc and @@ -41,7 +41,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger) (*FileReader, error) { if !ok { path, ok = cfg.Options["folder"].(string) if !ok { - return nil, fmt.Errorf("Failed to load dashboards. path param is not a string") + return nil, fmt.Errorf("failed to load dashboards, path param is not a string") } log.Warn("[Deprecated] The folder property is deprecated. Please use path instead.") diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go index f875430d88f..d655b7d5318 100644 --- a/pkg/services/provisioning/notifiers/config_reader_test.go +++ b/pkg/services/provisioning/notifiers/config_reader_test.go @@ -314,7 +314,7 @@ func TestNotificationAsConfig(t *testing.T) { cfgProvider := &configReader{log: log.New("test logger")} _, err := cfgProvider.readConfig(unknownNotifier) So(err, ShouldNotBeNil) - So(err.Error(), ShouldEqual, "Unsupported notification type") + So(err.Error(), ShouldEqual, `unsupported notification type "nonexisting"`) }) Convey("Read incorrect properties", func() { diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index b81a39b1ee7..3361ca4b783 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -72,7 +72,7 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, renderKey string, resp, err := netClient.Do(req) if err != nil { rs.log.Error("Failed to send request to remote rendering service.", "error", err) - return nil, fmt.Errorf("Failed to send request to remote rendering service. %s", err) + return nil, fmt.Errorf("failed to send request to remote rendering service: %w", err) } // save response to file @@ -87,7 +87,8 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, renderKey string, // if we didn't get a 200 response, something went wrong. if resp.StatusCode != http.StatusOK { rs.log.Error("Remote rendering request failed", "error", resp.Status) - return nil, fmt.Errorf("Remote rendering request failed. %d: %s", resp.StatusCode, resp.Status) + return nil, fmt.Errorf("remote rendering request failed, status code: %d, status: %s", resp.StatusCode, + resp.Status) } out, err := os.Create(filePath) @@ -103,7 +104,7 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, renderKey string, return nil, ErrTimeout } rs.log.Error("Remote rendering request failed", "error", err) - return nil, fmt.Errorf("Remote rendering request failed. %s", err) + return nil, fmt.Errorf("remote rendering request failed: %w", err) } return &RenderResult{FilePath: filePath}, err diff --git a/pkg/services/rendering/interface.go b/pkg/services/rendering/interface.go index 754374cdfd6..f5bb54399dc 100644 --- a/pkg/services/rendering/interface.go +++ b/pkg/services/rendering/interface.go @@ -8,8 +8,8 @@ import ( "github.com/grafana/grafana/pkg/models" ) -var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter") -var ErrNoRenderer = errors.New("No renderer plugin found nor is an external render server configured") +var ErrTimeout = errors.New("timeout error - you can set timeout in seconds with &timeout url parameter") +var ErrNoRenderer = errors.New("no renderer plugin found nor is an external render server configured") var ErrPhantomJSNotInstalled = errors.New("PhantomJS executable not found") type Opts struct { diff --git a/pkg/services/rendering/plugin_mode.go b/pkg/services/rendering/plugin_mode.go index 9e2cb5cdda1..7418d3c0c95 100644 --- a/pkg/services/rendering/plugin_mode.go +++ b/pkg/services/rendering/plugin_mode.go @@ -96,7 +96,7 @@ func (rs *RenderingService) renderViaPluginV2(ctx context.Context, renderKey str return nil, err } if rsp.Error != "" { - return nil, fmt.Errorf("Rendering failed: %v", rsp.Error) + return nil, fmt.Errorf("rendering failed: %s", rsp.Error) } return &RenderResult{FilePath: pngPath}, err diff --git a/pkg/services/shorturls/short_url_service.go b/pkg/services/shorturls/short_url_service.go index b2694bc3f4f..5e8fb122b43 100644 --- a/pkg/services/shorturls/short_url_service.go +++ b/pkg/services/shorturls/short_url_service.go @@ -10,6 +10,8 @@ import ( "github.com/grafana/grafana/pkg/util" ) +var getTime = time.Now + func init() { registry.RegisterService(&ShortURLService{}) } @@ -42,6 +44,18 @@ func (s ShortURLService) GetShortURLByUID(ctx context.Context, user *models.Sign return &shortURL, nil } +func (s ShortURLService) UpdateLastSeenAt(ctx context.Context, shortURL *models.ShortUrl) error { + shortURL.LastSeenAt = getTime().Unix() + return s.SQLStore.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + _, err := dbSession.ID(shortURL.Id).Update(shortURL) + if err != nil { + return err + } + + return nil + }) +} + func (s ShortURLService) CreateShortURL(ctx context.Context, user *models.SignedInUser, path string) (*models.ShortUrl, error) { now := time.Now().Unix() shortURL := models.ShortUrl{ diff --git a/pkg/services/shorturls/short_url_service_test.go b/pkg/services/shorturls/short_url_service_test.go index 64c93e37305..4cc2db24d1b 100644 --- a/pkg/services/shorturls/short_url_service_test.go +++ b/pkg/services/shorturls/short_url_service_test.go @@ -3,6 +3,7 @@ package shorturls import ( "context" "testing" + "time" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -27,6 +28,25 @@ func TestShortURLService(t *testing.T) { require.NoError(t, err) require.NotNil(t, existingShortURL) require.Equal(t, refPath, existingShortURL.Path) + + t.Run("and update last seen at", func(t *testing.T) { + origGetTime := getTime + t.Cleanup(func() { + getTime = origGetTime + }) + + expectedTime := time.Date(2020, time.November, 27, 6, 5, 1, 0, time.UTC) + getTime = func() time.Time { + return expectedTime + } + + err := service.UpdateLastSeenAt(context.Background(), existingShortURL) + require.NoError(t, err) + + updatedShortURL, err := service.GetShortURLByUID(context.Background(), user, existingShortURL.Uid) + require.NoError(t, err) + require.Equal(t, expectedTime.Unix(), updatedShortURL.LastSeenAt) + }) }) t.Run("User cannot look up nonexistent short URLs", func(t *testing.T) { diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 15ee0c0396d..876d1b341d6 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -286,7 +286,7 @@ func SetAlertState(cmd *models.SetAlertStateCommand) error { if has, err := sess.ID(cmd.AlertId).Get(&alert); err != nil { return err } else if !has { - return fmt.Errorf("Could not find alert") + return fmt.Errorf("could not find alert") } if alert.State == models.AlertStatePaused { diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index 31c3f47b77b..93b0d6b66b9 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -390,7 +390,7 @@ func UpdateAlertNotification(cmd *models.UpdateAlertNotificationCommand) error { } if sameNameQuery.Result != nil && sameNameQuery.Result.Id != current.Id { - return fmt.Errorf("Alert notification name %s already exists", cmd.Name) + return fmt.Errorf("alert notification name %q already exists", cmd.Name) } // delete empty keys @@ -431,7 +431,7 @@ func UpdateAlertNotification(cmd *models.UpdateAlertNotificationCommand) error { if affected, err := sess.ID(cmd.Id).Update(current); err != nil { return err } else if affected == 0 { - return fmt.Errorf("Could not update alert notification") + return fmt.Errorf("could not update alert notification") } cmd.Result = ¤t @@ -578,7 +578,7 @@ func GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCre } if !exist { - return errors.New("Should not happen") + return errors.New("should not happen") } cmd.Result = nj diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index 1e457d30dba..054f4467874 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -15,7 +15,7 @@ import ( func validateTimeRange(item *annotations.Item) error { if item.EpochEnd == 0 { if item.Epoch == 0 { - return errors.New("Missing Time Range") + return errors.New("missing time range") } item.EpochEnd = item.Epoch } @@ -78,7 +78,7 @@ func (r *SqlAnnotationRepo) Update(item *annotations.Item) error { return err } if !isExist { - return errors.New("Annotation not found") + return errors.New("annotation not found") } existing.Updated = timeNow().UnixNano() / int64(time.Millisecond) diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go index c9c1d184888..f17f9e85f3e 100644 --- a/pkg/services/sqlstore/annotation_test.go +++ b/pkg/services/sqlstore/annotation_test.go @@ -5,7 +5,8 @@ package sqlstore import ( "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/annotations" ) @@ -14,275 +15,241 @@ func TestAnnotations(t *testing.T) { mockTimeNow() defer resetTimeNow() InitTestDB(t) + repo := SqlAnnotationRepo{} - Convey("Testing annotation saving/loading", t, func() { - repo := SqlAnnotationRepo{} + t.Run("Testing annotation create, read, update and delete", func(t *testing.T) { + t.Cleanup(func() { + _, err := x.Exec("DELETE FROM annotation WHERE 1=1") + assert.NoError(t, err) + _, err = x.Exec("DELETE FROM annotation_tag WHERE 1=1") + assert.NoError(t, err) + }) - Convey("Can save annotation", func() { - Reset(func() { - _, err := x.Exec("DELETE FROM annotation WHERE 1=1") - So(err, ShouldBeNil) - _, err = x.Exec("DELETE FROM annotation_tag WHERE 1=1") - So(err, ShouldBeNil) - }) + annotation := &annotations.Item{ + OrgId: 1, + UserId: 1, + DashboardId: 1, + Text: "hello", + Type: "alert", + Epoch: 10, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + } + err := repo.Save(annotation) + require.NoError(t, err) + assert.Greater(t, annotation.Id, int64(0)) + assert.Equal(t, annotation.Epoch, annotation.EpochEnd) - annotation := &annotations.Item{ + annotation2 := &annotations.Item{ + OrgId: 1, + UserId: 1, + DashboardId: 2, + Text: "hello", + Type: "alert", + Epoch: 21, // Should swap epoch & epochEnd + EpochEnd: 20, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + } + err = repo.Save(annotation2) + require.NoError(t, err) + assert.Greater(t, annotation2.Id, int64(0)) + assert.Equal(t, int64(20), annotation2.Epoch) + assert.Equal(t, int64(21), annotation2.EpochEnd) + + globalAnnotation1 := &annotations.Item{ + OrgId: 1, + UserId: 1, + Text: "deploy", + Type: "", + Epoch: 15, + Tags: []string{"deploy"}, + } + err = repo.Save(globalAnnotation1) + require.NoError(t, err) + assert.Greater(t, globalAnnotation1.Id, int64(0)) + + globalAnnotation2 := &annotations.Item{ + OrgId: 1, + UserId: 1, + Text: "rollback", + Type: "", + Epoch: 17, + Tags: []string{"rollback"}, + } + err = repo.Save(globalAnnotation2) + require.NoError(t, err) + assert.Greater(t, globalAnnotation2.Id, int64(0)) + + t.Run("Can query for annotation by dashboard id", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, - UserId: 1, DashboardId: 1, - Text: "hello", - Type: "alert", - Epoch: 10, - Tags: []string{"outage", "error", "type:outage", "server:server-1"}, - } - err := repo.Save(annotation) + From: 0, + To: 15, + }) - So(err, ShouldBeNil) - So(annotation.Id, ShouldBeGreaterThan, 0) - So(annotation.Epoch, ShouldEqual, annotation.EpochEnd) + require.NoError(t, err) + assert.Len(t, items, 1) - annotation2 := &annotations.Item{ + assert.Equal(t, []string{"outage", "error", "type:outage", "server:server-1"}, items[0].Tags) + + assert.GreaterOrEqual(t, items[0].Created, int64(0)) + assert.GreaterOrEqual(t, items[0].Updated, int64(0)) + assert.Equal(t, items[0].Updated, items[0].Created) + }) + + t.Run("Can query for annotation by id", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + AnnotationId: annotation2.Id, + }) + require.NoError(t, err) + assert.Len(t, items, 1) + assert.Equal(t, annotation2.Id, items[0].Id) + }) + + t.Run("Should not find any when item is outside time range", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, - UserId: 1, - DashboardId: 2, - Text: "hello", + DashboardId: 1, + From: 12, + To: 15, + }) + require.NoError(t, err) + assert.Empty(t, items) + }) + + t.Run("Should not find one when tag filter does not match", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"asd"}, + }) + require.NoError(t, err) + assert.Empty(t, items) + }) + + t.Run("Should not find one when type filter does not match", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, Type: "alert", - Epoch: 21, // Should swap epoch & epochEnd - EpochEnd: 20, - Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + }) + require.NoError(t, err) + assert.Empty(t, items) + }) + + t.Run("Should find one when all tag filters does match", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, // this will exclude the second test annotation + Tags: []string{"outage", "error"}, + }) + require.NoError(t, err) + assert.Len(t, items, 1) + }) + + t.Run("Should find two annotations using partial match", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + From: 1, + To: 25, + MatchAny: true, + Tags: []string{"rollback", "deploy"}, + }) + require.NoError(t, err) + assert.Len(t, items, 2) + }) + + t.Run("Should find one when all key value tag filters does match", func(t *testing.T) { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"type:outage", "server:server-1"}, + }) + require.NoError(t, err) + assert.Len(t, items, 1) + }) + + t.Run("Can update annotation and remove all tags", func(t *testing.T) { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, } - err = repo.Save(annotation2) - So(err, ShouldBeNil) - So(annotation2.Id, ShouldBeGreaterThan, 0) - So(annotation2.Epoch, ShouldEqual, 20) - So(annotation2.EpochEnd, ShouldEqual, 21) + items, err := repo.Find(query) + require.NoError(t, err) - globalAnnotation1 := &annotations.Item{ - OrgId: 1, - UserId: 1, - Text: "deploy", - Type: "", - Epoch: 15, - Tags: []string{"deploy"}, + annotationId := items[0].Id + err = repo.Update(&annotations.Item{ + Id: annotationId, + OrgId: 1, + Text: "something new", + Tags: []string{}, + }) + require.NoError(t, err) + + items, err = repo.Find(query) + require.NoError(t, err) + + assert.Equal(t, annotationId, items[0].Id) + assert.Empty(t, items[0].Tags) + assert.Equal(t, "something new", items[0].Text) + }) + + t.Run("Can update annotation with new tags", func(t *testing.T) { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, } - err = repo.Save(globalAnnotation1) - So(err, ShouldBeNil) - So(globalAnnotation1.Id, ShouldBeGreaterThan, 0) + items, err := repo.Find(query) + require.NoError(t, err) - globalAnnotation2 := &annotations.Item{ - OrgId: 1, - UserId: 1, - Text: "rollback", - Type: "", - Epoch: 17, - Tags: []string{"rollback"}, + annotationId := items[0].Id + err = repo.Update(&annotations.Item{ + Id: annotationId, + OrgId: 1, + Text: "something new", + Tags: []string{"newtag1", "newtag2"}, + }) + require.NoError(t, err) + + items, err = repo.Find(query) + require.NoError(t, err) + + assert.Equal(t, annotationId, items[0].Id) + assert.Equal(t, []string{"newtag1", "newtag2"}, items[0].Tags) + assert.Equal(t, "something new", items[0].Text) + assert.Greater(t, items[0].Updated, items[0].Created) + }) + + t.Run("Can delete annotation", func(t *testing.T) { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, } - err = repo.Save(globalAnnotation2) - So(err, ShouldBeNil) - So(globalAnnotation2.Id, ShouldBeGreaterThan, 0) + items, err := repo.Find(query) + require.NoError(t, err) - Convey("Can query for annotation by dashboard id", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 0, - To: 15, - }) + annotationId := items[0].Id + err = repo.Delete(&annotations.DeleteParams{Id: annotationId, OrgId: 1}) + require.NoError(t, err) - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 1) - - Convey("Can read tags", func() { - So(items[0].Tags, ShouldResemble, []string{"outage", "error", "type:outage", "server:server-1"}) - }) - - Convey("Has created and updated values", func() { - So(items[0].Created, ShouldBeGreaterThan, 0) - So(items[0].Updated, ShouldBeGreaterThan, 0) - So(items[0].Updated, ShouldEqual, items[0].Created) - }) - }) - - Convey("Can query for annotation by id", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - AnnotationId: annotation2.Id, - }) - - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 1) - So(items[0].Id, ShouldEqual, annotation2.Id) - }) - - Convey("Should not find any when item is outside time range", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 12, - To: 15, - }) - - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 0) - }) - - Convey("Should not find one when tag filter does not match", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 1, - To: 15, - Tags: []string{"asd"}, - }) - - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 0) - }) - - Convey("Should not find one when type filter does not match", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 1, - To: 15, - Type: "alert", - }) - - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 0) - }) - - Convey("Should find one when all tag filters does match", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 1, - To: 15, // this will exclude the second test annotation - Tags: []string{"outage", "error"}, - }) - - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 1) - }) - - Convey("Should find two annotations using partial match", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - From: 1, - To: 25, - MatchAny: true, - Tags: []string{"rollback", "deploy"}, - }) - - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 2) - }) - - Convey("Should find one when all key value tag filters does match", func() { - items, err := repo.Find(&annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 1, - To: 15, - Tags: []string{"type:outage", "server:server-1"}, - }) - - So(err, ShouldBeNil) - So(items, ShouldHaveLength, 1) - }) - - Convey("Can update annotation and remove all tags", func() { - query := &annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 0, - To: 15, - } - items, err := repo.Find(query) - - So(err, ShouldBeNil) - - annotationId := items[0].Id - - err = repo.Update(&annotations.Item{ - Id: annotationId, - OrgId: 1, - Text: "something new", - Tags: []string{}, - }) - - So(err, ShouldBeNil) - - items, err = repo.Find(query) - - So(err, ShouldBeNil) - - Convey("Can read tags", func() { - So(items[0].Id, ShouldEqual, annotationId) - So(len(items[0].Tags), ShouldEqual, 0) - So(items[0].Text, ShouldEqual, "something new") - }) - }) - - Convey("Can update annotation with new tags", func() { - query := &annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 0, - To: 15, - } - items, err := repo.Find(query) - - So(err, ShouldBeNil) - - annotationId := items[0].Id - - err = repo.Update(&annotations.Item{ - Id: annotationId, - OrgId: 1, - Text: "something new", - Tags: []string{"newtag1", "newtag2"}, - }) - - So(err, ShouldBeNil) - - items, err = repo.Find(query) - - So(err, ShouldBeNil) - - Convey("Can read tags", func() { - So(items[0].Id, ShouldEqual, annotationId) - So(items[0].Tags, ShouldResemble, []string{"newtag1", "newtag2"}) - So(items[0].Text, ShouldEqual, "something new") - }) - - Convey("Updated time has increased", func() { - So(items[0].Updated, ShouldBeGreaterThan, items[0].Created) - }) - }) - - Convey("Can delete annotation", func() { - query := &annotations.ItemQuery{ - OrgId: 1, - DashboardId: 1, - From: 0, - To: 15, - } - items, err := repo.Find(query) - So(err, ShouldBeNil) - - annotationId := items[0].Id - - err = repo.Delete(&annotations.DeleteParams{Id: annotationId, OrgId: 1}) - So(err, ShouldBeNil) - - items, err = repo.Find(query) - So(err, ShouldBeNil) - - Convey("Should be deleted", func() { - So(len(items), ShouldEqual, 0) - }) - }) + items, err = repo.Find(query) + require.NoError(t, err) + assert.Empty(t, items) }) }) } diff --git a/pkg/services/sqlstore/apikey_test.go b/pkg/services/sqlstore/apikey_test.go index dc1ab50aedb..d8a23779939 100644 --- a/pkg/services/sqlstore/apikey_test.go +++ b/pkg/services/sqlstore/apikey_test.go @@ -71,7 +71,7 @@ func TestApiKeyDataAccess(t *testing.T) { query := models.GetApiKeyByNameQuery{KeyName: "key-with-negative-lifespan", OrgId: 1} err = GetApiKeyByName(&query) - assert.EqualError(t, err, "Invalid API Key") + assert.EqualError(t, err, "invalid API key") }) t.Run("Add keys", func(t *testing.T) { diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index d3b71d5ff83..1215936bbf8 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -374,16 +374,16 @@ func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error "DELETE FROM dashboard_version WHERE dashboard_id = ?", "DELETE FROM annotation WHERE dashboard_id = ?", "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", + "DELETE FROM dashboard_acl WHERE dashboard_id = ?", } 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) + err := sess.SQL("SELECT id FROM dashboard WHERE folder_id = ?", dashboard.Id).Find(&dashIds) if err != nil { return err } @@ -393,6 +393,23 @@ func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error return err } } + + if len(dashIds) > 0 { + childrenDeletes := []string{ + "DELETE FROM dashboard_tag WHERE dashboard_id IN (SELECT id FROM dashboard WHERE org_id = ? AND folder_id = ?)", + "DELETE FROM star WHERE dashboard_id IN (SELECT id FROM dashboard WHERE org_id = ? AND folder_id = ?)", + "DELETE FROM dashboard_version WHERE dashboard_id IN (SELECT id FROM dashboard WHERE org_id = ? AND folder_id = ?)", + "DELETE FROM annotation WHERE dashboard_id IN (SELECT id FROM dashboard WHERE org_id = ? AND folder_id = ?)", + "DELETE FROM dashboard_provisioning WHERE dashboard_id IN (SELECT id FROM dashboard WHERE org_id = ? AND folder_id = ?)", + "DELETE FROM dashboard_acl WHERE dashboard_id IN (SELECT id FROM dashboard WHERE org_id = ? AND folder_id = ?)", + } + for _, sql := range childrenDeletes { + _, err := sess.Exec(sql, dashboard.OrgId, dashboard.Id) + if err != nil { + return err + } + } + } } if err := deleteAlertDefinition(dashboard.Id, sess); err != nil { @@ -401,7 +418,6 @@ func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error for _, sql := range deletes { _, err := sess.Exec(sql, dashboard.Id) - if err != nil { return err } diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index c448cc8e281..99467814287 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -201,6 +201,14 @@ func TestDashboardDataAccess(t *testing.T) { So(query.Result.Updated.IsZero(), ShouldBeFalse) }) + Convey("Should be able to delete empty folder", func() { + emptyFolder := insertTestDashboard("2 test dash folder", 1, 0, true, "prod", "webapp") + + deleteCmd := &models.DeleteDashboardCommand{Id: emptyFolder.Id} + err := DeleteDashboard(deleteCmd) + So(err, ShouldBeNil) + }) + Convey("Should be able to delete a dashboard folder and its children", func() { deleteCmd := &models.DeleteDashboardCommand{Id: savedFolder.Id} err := DeleteDashboard(deleteCmd) diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go index 4aef3b00dd9..169708be0a9 100644 --- a/pkg/services/sqlstore/login_attempt.go +++ b/pkg/services/sqlstore/login_attempt.go @@ -39,12 +39,10 @@ func DeleteOldLoginAttempts(cmd *models.DeleteOldLoginAttemptsCommand) error { var maxId int64 sql := "SELECT max(id) as id FROM login_attempt WHERE created < ?" result, err := sess.Query(sql, cmd.OlderThan.Unix()) - if err != nil { return err } - // nolint: gosimple - if result == nil || len(result) == 0 || result[0] == nil { + if len(result) == 0 || result[0] == nil { return nil } diff --git a/pkg/services/sqlstore/migrations/dashboard_acl.go b/pkg/services/sqlstore/migrations/dashboard_acl.go index 32e4aec6d4a..983191c04d1 100644 --- a/pkg/services/sqlstore/migrations/dashboard_acl.go +++ b/pkg/services/sqlstore/migrations/dashboard_acl.go @@ -46,4 +46,7 @@ INSERT INTO dashboard_acl ` mg.AddMigration("save default acl rules in dashboard_acl table", NewRawSqlMigration(rawSQL)) + + mg.AddMigration("delete acl rules for deleted dashboards and folders", NewRawSqlMigration( + "DELETE FROM dashboard_acl WHERE dashboard_id NOT IN (SELECT id FROM dashboard) AND dashboard_id != -1")) } diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 6af020770ab..03d4c016975 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -219,4 +219,10 @@ func addDashboardMigration(mg *Migrator) { Cols: []string{"title"}, Type: IndexType, })) + + mg.AddMigration("delete tags for deleted dashboards", NewRawSqlMigration( + "DELETE FROM dashboard_tag WHERE dashboard_id NOT IN (SELECT id FROM dashboard)")) + + mg.AddMigration("delete stars for deleted dashboards", NewRawSqlMigration( + "DELETE FROM star WHERE dashboard_id NOT IN (SELECT id FROM dashboard)")) } diff --git a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go index 5b4b27455f2..4ff4f7ae62b 100644 --- a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go @@ -68,4 +68,7 @@ func addDashboardSnapshotMigrations(mg *Migrator) { mg.AddMigration("Add encrypted dashboard json column", NewAddColumnMigration(snapshotV5, &Column{ Name: "dashboard_encrypted", Type: DB_Blob, Nullable: true, })) + + mg.AddMigration("Change dashboard_encrypted column to MEDIUMBLOB", NewRawSqlMigration(""). + Mysql("ALTER TABLE dashboard_snapshot MODIFY dashboard_encrypted MEDIUMBLOB;")) } diff --git a/pkg/services/sqlstore/migrations/short_url_mig.go b/pkg/services/sqlstore/migrations/short_url_mig.go index 163e9f8ae7d..161dc743ee1 100755 --- a/pkg/services/sqlstore/migrations/short_url_mig.go +++ b/pkg/services/sqlstore/migrations/short_url_mig.go @@ -1,7 +1,6 @@ package migrations import ( - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -17,12 +16,12 @@ func addShortURLMigrations(mg *Migrator) { {Name: "created_at", Type: DB_Int, Nullable: false}, {Name: "last_seen_at", Type: DB_Int, Nullable: true}, }, - Indices: []*migrator.Index{ - {Cols: []string{"org_id", "uid"}, Type: migrator.UniqueIndex}, + Indices: []*Index{ + {Cols: []string{"org_id", "uid"}, Type: UniqueIndex}, }, } mg.AddMigration("create short_url table v1", NewAddTableMigration(shortURLV1)) - mg.AddMigration("add index short_url.org_id-uid", migrator.NewAddIndexMigration(shortURLV1, shortURLV1.Indices[0])) + mg.AddMigration("add index short_url.org_id-uid", NewAddIndexMigration(shortURLV1, shortURLV1.Indices[0])) } diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index f4d41c70c56..2ce1d248a27 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -79,8 +79,8 @@ type BaseDialect struct { driverName string } -func (d *BaseDialect) DriverName() string { - return d.driverName +func (b *BaseDialect) DriverName() string { + return b.driverName } func (b *BaseDialect) ShowCreateNull() bool { @@ -107,7 +107,7 @@ func (b *BaseDialect) Default(col *Column) string { return col.Default } -func (db *BaseDialect) DateTimeFunc(value string) string { +func (b *BaseDialect) DateTimeFunc(value string) string { return value } @@ -145,12 +145,12 @@ func (b *BaseDialect) CreateTableSql(table *Table) string { return sql } -func (db *BaseDialect) AddColumnSql(tableName string, col *Column) string { - return fmt.Sprintf("alter table %s ADD COLUMN %s", db.dialect.Quote(tableName), col.StringNoPk(db.dialect)) +func (b *BaseDialect) AddColumnSql(tableName string, col *Column) string { + return fmt.Sprintf("alter table %s ADD COLUMN %s", b.dialect.Quote(tableName), col.StringNoPk(b.dialect)) } -func (db *BaseDialect) CreateIndexSql(tableName string, index *Index) string { - quote := db.dialect.Quote +func (b *BaseDialect) CreateIndexSql(tableName string, index *Index) string { + quote := b.dialect.Quote var unique string if index.Type == UniqueIndex { unique = " UNIQUE" @@ -160,66 +160,66 @@ func (db *BaseDialect) CreateIndexSql(tableName string, index *Index) string { quotedCols := []string{} for _, col := range index.Cols { - quotedCols = append(quotedCols, db.dialect.Quote(col)) + quotedCols = append(quotedCols, b.dialect.Quote(col)) } return fmt.Sprintf("CREATE%s INDEX %v ON %v (%v);", unique, quote(idxName), quote(tableName), strings.Join(quotedCols, ",")) } -func (db *BaseDialect) QuoteColList(cols []string) string { +func (b *BaseDialect) QuoteColList(cols []string) string { var sourceColsSql = "" for _, col := range cols { - sourceColsSql += db.dialect.Quote(col) + sourceColsSql += b.dialect.Quote(col) sourceColsSql += "\n, " } return strings.TrimSuffix(sourceColsSql, "\n, ") } -func (db *BaseDialect) CopyTableData(sourceTable string, targetTable string, sourceCols []string, targetCols []string) string { - sourceColsSql := db.QuoteColList(sourceCols) - targetColsSql := db.QuoteColList(targetCols) +func (b *BaseDialect) CopyTableData(sourceTable string, targetTable string, sourceCols []string, targetCols []string) string { + sourceColsSql := b.QuoteColList(sourceCols) + targetColsSql := b.QuoteColList(targetCols) - quote := db.dialect.Quote + quote := b.dialect.Quote return fmt.Sprintf("INSERT INTO %s (%s) SELECT %s FROM %s", quote(targetTable), targetColsSql, sourceColsSql, quote(sourceTable)) } -func (db *BaseDialect) DropTable(tableName string) string { - quote := db.dialect.Quote +func (b *BaseDialect) DropTable(tableName string) string { + quote := b.dialect.Quote return fmt.Sprintf("DROP TABLE IF EXISTS %s", quote(tableName)) } -func (db *BaseDialect) RenameTable(oldName string, newName string) string { - quote := db.dialect.Quote +func (b *BaseDialect) RenameTable(oldName string, newName string) string { + quote := b.dialect.Quote return fmt.Sprintf("ALTER TABLE %s RENAME TO %s", quote(oldName), quote(newName)) } -func (db *BaseDialect) ColumnCheckSql(tableName, columnName string) (string, []interface{}) { +func (b *BaseDialect) ColumnCheckSql(tableName, columnName string) (string, []interface{}) { return "", nil } -func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string { - quote := db.dialect.Quote +func (b *BaseDialect) DropIndexSql(tableName string, index *Index) string { + quote := b.dialect.Quote name := index.XName(tableName) return fmt.Sprintf("DROP INDEX %v ON %s", quote(name), quote(tableName)) } -func (db *BaseDialect) UpdateTableSql(tableName string, columns []*Column) string { +func (b *BaseDialect) UpdateTableSql(tableName string, columns []*Column) string { return "-- NOT REQUIRED" } -func (db *BaseDialect) ColString(col *Column) string { - sql := db.dialect.Quote(col.Name) + " " +func (b *BaseDialect) ColString(col *Column) string { + sql := b.dialect.Quote(col.Name) + " " - sql += db.dialect.SqlType(col) + " " + sql += b.dialect.SqlType(col) + " " if col.IsPrimaryKey { sql += "PRIMARY KEY " if col.IsAutoIncrement { - sql += db.dialect.AutoIncrStr() + " " + sql += b.dialect.AutoIncrStr() + " " } } - if db.dialect.ShowCreateNull() { + if b.dialect.ShowCreateNull() { if col.Nullable { sql += "NULL " } else { @@ -228,18 +228,18 @@ func (db *BaseDialect) ColString(col *Column) string { } if col.Default != "" { - sql += "DEFAULT " + db.dialect.Default(col) + " " + sql += "DEFAULT " + b.dialect.Default(col) + " " } return sql } -func (db *BaseDialect) ColStringNoPk(col *Column) string { - sql := db.dialect.Quote(col.Name) + " " +func (b *BaseDialect) ColStringNoPk(col *Column) string { + sql := b.dialect.Quote(col.Name) + " " - sql += db.dialect.SqlType(col) + " " + sql += b.dialect.SqlType(col) + " " - if db.dialect.ShowCreateNull() { + if b.dialect.ShowCreateNull() { if col.Nullable { sql += "NULL " } else { @@ -248,36 +248,36 @@ func (db *BaseDialect) ColStringNoPk(col *Column) string { } if col.Default != "" { - sql += "DEFAULT " + db.dialect.Default(col) + " " + sql += "DEFAULT " + b.dialect.Default(col) + " " } return sql } -func (db *BaseDialect) Limit(limit int64) string { +func (b *BaseDialect) Limit(limit int64) string { return fmt.Sprintf(" LIMIT %d", limit) } -func (db *BaseDialect) LimitOffset(limit int64, offset int64) string { +func (b *BaseDialect) LimitOffset(limit int64, offset int64) string { return fmt.Sprintf(" LIMIT %d OFFSET %d", limit, offset) } -func (db *BaseDialect) PreInsertId(table string, sess *xorm.Session) error { +func (b *BaseDialect) PreInsertId(table string, sess *xorm.Session) error { return nil } -func (db *BaseDialect) PostInsertId(table string, sess *xorm.Session) error { +func (b *BaseDialect) PostInsertId(table string, sess *xorm.Session) error { return nil } -func (db *BaseDialect) CleanDB() error { +func (b *BaseDialect) CleanDB() error { return nil } -func (db *BaseDialect) NoOpSql() string { +func (b *BaseDialect) NoOpSql() string { return "SELECT 0;" } -func (db *BaseDialect) TruncateDBTables() error { +func (b *BaseDialect) TruncateDBTables() error { return nil } diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go index c22c8a1b239..6ed269bb364 100644 --- a/pkg/services/sqlstore/migrator/postgres_dialect.go +++ b/pkg/services/sqlstore/migrator/postgres_dialect.go @@ -31,7 +31,7 @@ func (db *Postgres) Quote(name string) string { return "\"" + name + "\"" } -func (b *Postgres) LikeStr() string { +func (db *Postgres) LikeStr() string { return "ILIKE" } @@ -43,7 +43,7 @@ func (db *Postgres) BooleanStr(value bool) string { return strconv.FormatBool(value) } -func (b *Postgres) Default(col *Column) string { +func (db *Postgres) Default(col *Column) string { if col.Type == DB_Bool { if col.Default == "0" { return "FALSE" diff --git a/pkg/services/sqlstore/playlist_test.go b/pkg/services/sqlstore/playlist_test.go index c5373366389..45c951de2de 100644 --- a/pkg/services/sqlstore/playlist_test.go +++ b/pkg/services/sqlstore/playlist_test.go @@ -5,41 +5,36 @@ package sqlstore import ( "testing" - . "github.com/smartystreets/goconvey/convey" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/require" ) func TestPlaylistDataAccess(t *testing.T) { - Convey("Testing Playlist data access", t, func() { - InitTestDB(t) + InitTestDB(t) - Convey("Can create playlist", func() { + t.Run("Can create playlist", func(t *testing.T) { + items := []models.PlaylistItemDTO{ + {Title: "graphite", Value: "graphite", Type: "dashboard_by_tag"}, + {Title: "Backend response times", Value: "3", Type: "dashboard_by_id"}, + } + cmd := models.CreatePlaylistCommand{Name: "NYC office", Interval: "10m", OrgId: 1, Items: items} + err := CreatePlaylist(&cmd) + require.NoError(t, err) + + t.Run("Can update playlist", func(t *testing.T) { items := []models.PlaylistItemDTO{ - {Title: "graphite", Value: "graphite", Type: "dashboard_by_tag"}, - {Title: "Backend response times", Value: "3", Type: "dashboard_by_id"}, + {Title: "influxdb", Value: "influxdb", Type: "dashboard_by_tag"}, + {Title: "Backend response times", Value: "2", Type: "dashboard_by_id"}, } - cmd := models.CreatePlaylistCommand{Name: "NYC office", Interval: "10m", OrgId: 1, Items: items} - err := CreatePlaylist(&cmd) - So(err, ShouldBeNil) + query := models.UpdatePlaylistCommand{Name: "NYC office ", OrgId: 1, Id: 1, Interval: "10s", Items: items} + err = UpdatePlaylist(&query) + require.NoError(t, err) + }) - Convey("can update playlist", func() { - items := []models.PlaylistItemDTO{ - {Title: "influxdb", Value: "influxdb", Type: "dashboard_by_tag"}, - {Title: "Backend response times", Value: "2", Type: "dashboard_by_id"}, - } - query := models.UpdatePlaylistCommand{Name: "NYC office ", OrgId: 1, Id: 1, Interval: "10s", Items: items} - err = UpdatePlaylist(&query) - - So(err, ShouldBeNil) - - Convey("can remove playlist", func() { - query := models.DeletePlaylistCommand{Id: 1} - err = DeletePlaylist(&query) - - So(err, ShouldBeNil) - }) - }) + t.Run("Can remove playlist", func(t *testing.T) { + query := models.DeletePlaylistCommand{Id: 1} + err = DeletePlaylist(&query) + require.NoError(t, err) }) }) } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index dde259f4a9b..87e9ae361d7 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -116,7 +116,7 @@ func (ss *SqlStore) ensureMainOrgAndAdminUser() error { systemUserCountQuery := models.GetSystemUserCountStatsQuery{} err := bus.DispatchCtx(ctx, &systemUserCountQuery) if err != nil { - return fmt.Errorf("Could not determine if admin user exists: %v", err) + return fmt.Errorf("could not determine if admin user exists: %w", err) } if systemUserCountQuery.Result.Count > 0 { @@ -132,7 +132,7 @@ func (ss *SqlStore) ensureMainOrgAndAdminUser() error { cmd.IsAdmin = true if err := bus.DispatchCtx(ctx, &cmd); err != nil { - return fmt.Errorf("Failed to create admin user: %v", err) + return fmt.Errorf("failed to create admin user: %s", err) } ss.log.Info("Created default admin", "user", setting.AdminUser) @@ -226,7 +226,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) { cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", ss.dbCfg.Path, ss.dbCfg.CacheMode) cnnstr += ss.buildExtraConnectionString('&') default: - return "", fmt.Errorf("Unknown database type: %s", ss.dbCfg.Type) + return "", fmt.Errorf("unknown database type: %s", ss.dbCfg.Type) } return cnnstr, nil diff --git a/pkg/services/sqlstore/tls_mysql.go b/pkg/services/sqlstore/tls_mysql.go index 0ba3d6da8d5..10ac56004a7 100644 --- a/pkg/services/sqlstore/tls_mysql.go +++ b/pkg/services/sqlstore/tls_mysql.go @@ -15,7 +15,7 @@ func makeCert(config DatabaseConfig) (*tls.Config, error) { rootCertPool := x509.NewCertPool() pem, err := ioutil.ReadFile(config.CaCertPath) if err != nil { - return nil, fmt.Errorf("Could not read DB CA Cert path: %v", config.CaCertPath) + return nil, fmt.Errorf("could not read DB CA Cert path %q: %w", config.CaCertPath, err) } if ok := rootCertPool.AppendCertsFromPEM(pem); !ok { return nil, err diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 99864a7bb47..b418a8f65dd 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -16,7 +16,6 @@ import ( "strings" "time" - "github.com/go-macaron/session" "github.com/prometheus/common/model" ini "gopkg.in/ini.v1" @@ -170,7 +169,6 @@ var ( BasicAuthEnabled bool // Session settings. - SessionOptions session.Options SessionConnMaxLifetime int64 // Global setting objects. @@ -273,6 +271,7 @@ type Cfg struct { PluginsAppsSkipVerifyTLS bool PluginSettings PluginSettings PluginsAllowUnsigned []string + MarketplaceURL string DisableSanitizeHtml bool EnterpriseLicensePath string @@ -326,26 +325,26 @@ type Cfg struct { } // IsExpressionsEnabled returns whether the expressions feature is enabled. -func (c Cfg) IsExpressionsEnabled() bool { - return c.FeatureToggles["expressions"] +func (cfg Cfg) IsExpressionsEnabled() bool { + return cfg.FeatureToggles["expressions"] } // IsLiveEnabled returns if grafana live should be enabled -func (c Cfg) IsLiveEnabled() bool { - return c.FeatureToggles["live"] +func (cfg Cfg) IsLiveEnabled() bool { + return cfg.FeatureToggles["live"] } // IsNgAlertEnabled returns whether the standalone alerts feature is enabled. -func (c Cfg) IsNgAlertEnabled() bool { - return c.FeatureToggles["ngalert"] +func (cfg Cfg) IsNgAlertEnabled() bool { + return cfg.FeatureToggles["ngalert"] } -func (c Cfg) IsDatabaseMetricsEnabled() bool { - return c.FeatureToggles["database_metrics"] +func (cfg Cfg) IsDatabaseMetricsEnabled() bool { + return cfg.FeatureToggles["database_metrics"] } -func (c Cfg) IsHTTPRequestHistogramEnabled() bool { - return c.FeatureToggles["http_request_histogram"] +func (cfg Cfg) IsHTTPRequestHistogramEnabled() bool { + return cfg.FeatureToggles["http_request_histogram"] } type CommandLineArgs struct { @@ -452,7 +451,7 @@ func (cfg *Cfg) readAnnotationSettings() { alertingSection := cfg.Raw.Section("alerting") var newAnnotationCleanupSettings = func(section *ini.Section, maxAgeField string) AnnotationCleanupSettings { - maxAge, err := gtime.ParseInterval(section.Key(maxAgeField).MustString("")) + maxAge, err := gtime.ParseDuration(section.Key(maxAgeField).MustString("")) if err != nil { maxAge = 0 } @@ -553,7 +552,7 @@ func loadSpecifiedConfigFile(configFile string, masterFile *ini.File) error { userConfig, err := ini.Load(configFile) if err != nil { - return fmt.Errorf("Failed to parse %v, %v", configFile, err) + return fmt.Errorf("failed to parse %q: %w", configFile, err) } userConfig.BlockMode = false @@ -796,6 +795,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { plug = strings.TrimSpace(plug) cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug) } + cfg.MarketplaceURL = pluginsSection.Key("marketplace_url").MustString("https://grafana.com/grafana/plugins/") cfg.Protocol = Protocol // Read and populate feature toggles list @@ -1018,7 +1018,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { maxInactiveDaysVal = "7d" } maxInactiveDurationVal := valueAsString(auth, "login_maximum_inactive_lifetime_duration", maxInactiveDaysVal) - cfg.LoginMaxInactiveLifetime, err = gtime.ParseInterval(maxInactiveDurationVal) + cfg.LoginMaxInactiveLifetime, err = gtime.ParseDuration(maxInactiveDurationVal) if err != nil { return err } @@ -1031,7 +1031,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { maxLifetimeDaysVal = "7d" } maxLifetimeDurationVal := valueAsString(auth, "login_maximum_lifetime_duration", maxLifetimeDaysVal) - cfg.LoginMaxLifetime, err = gtime.ParseInterval(maxLifetimeDurationVal) + cfg.LoginMaxLifetime, err = gtime.ParseDuration(maxLifetimeDurationVal) if err != nil { return err } @@ -1121,7 +1121,7 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false) userInviteMaxLifetimeVal := valueAsString(users, "user_invite_max_lifetime_duration", "24h") - userInviteMaxLifetimeDuration, err := gtime.ParseInterval(userInviteMaxLifetimeVal) + userInviteMaxLifetimeDuration, err := gtime.ParseDuration(userInviteMaxLifetimeVal) if err != nil { return err } diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 3434c46ed58..0fb696769fd 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -215,7 +215,8 @@ func TestLoadingSettings(t *testing.T) { }) So(err, ShouldBeNil) - hostname, _ := os.Hostname() + hostname, err := os.Hostname() + So(err, ShouldBeNil) So(InstanceName, ShouldEqual, hostname) }) @@ -291,7 +292,7 @@ func TestLoadingSettings(t *testing.T) { }) } -func TestParseAppUrlAndSubUrl(t *testing.T) { +func TestParseAppURLAndSubURL(t *testing.T) { testCases := []struct { rootURL string expectedAppURL string @@ -315,7 +316,10 @@ func TestParseAppUrlAndSubUrl(t *testing.T) { require.Equal(t, tc.expectedAppSubURL, appSubURL) } } + func TestAuthDurationSettings(t *testing.T) { + const maxInactiveDaysTest = 240 * time.Hour + f := ini.Empty() cfg := NewCfg() sec, err := f.NewSection("auth") @@ -324,7 +328,6 @@ func TestAuthDurationSettings(t *testing.T) { require.NoError(t, err) _, err = sec.NewKey("login_maximum_inactive_lifetime_duration", "") require.NoError(t, err) - maxInactiveDaysTest, _ := time.ParseDuration("240h") err = readAuthSettings(f, cfg) require.NoError(t, err) require.Equal(t, maxInactiveDaysTest, cfg.LoginMaxInactiveLifetime) @@ -334,7 +337,8 @@ func TestAuthDurationSettings(t *testing.T) { require.NoError(t, err) _, err = sec.NewKey("login_maximum_inactive_lifetime_duration", "824h") require.NoError(t, err) - maxInactiveDurationTest, _ := time.ParseDuration("824h") + maxInactiveDurationTest, err := time.ParseDuration("824h") + require.NoError(t, err) err = readAuthSettings(f, cfg) require.NoError(t, err) require.Equal(t, maxInactiveDurationTest, cfg.LoginMaxInactiveLifetime) @@ -346,7 +350,8 @@ func TestAuthDurationSettings(t *testing.T) { require.NoError(t, err) _, err = sec.NewKey("login_maximum_lifetime_duration", "") require.NoError(t, err) - maxLifetimeDaysTest, _ := time.ParseDuration("576h") + maxLifetimeDaysTest, err := time.ParseDuration("576h") + require.NoError(t, err) err = readAuthSettings(f, cfg) require.NoError(t, err) require.Equal(t, maxLifetimeDaysTest, cfg.LoginMaxLifetime) @@ -356,7 +361,8 @@ func TestAuthDurationSettings(t *testing.T) { require.NoError(t, err) _, err = sec.NewKey("login_maximum_lifetime_duration", "824h") require.NoError(t, err) - maxLifetimeDurationTest, _ := time.ParseDuration("824h") + maxLifetimeDurationTest, err := time.ParseDuration("824h") + require.NoError(t, err) err = readAuthSettings(f, cfg) require.NoError(t, err) require.Equal(t, maxLifetimeDurationTest, cfg.LoginMaxLifetime) diff --git a/pkg/tsdb/azuremonitor/applicationinsights-datasource.go b/pkg/tsdb/azuremonitor/applicationinsights-datasource.go index 66324968624..b35705d2588 100644 --- a/pkg/tsdb/azuremonitor/applicationinsights-datasource.go +++ b/pkg/tsdb/azuremonitor/applicationinsights-datasource.go @@ -179,7 +179,7 @@ func (e *ApplicationInsightsDatasource) executeQuery(ctx context.Context, query if res.StatusCode/100 != 2 { azlog.Debug("Request failed", "status", res.Status, "body", string(body)) - return nil, fmt.Errorf("Request failed status: %v", res.Status) + return nil, fmt.Errorf("request failed, status: %s", res.Status) } mr := MetricsResult{} @@ -204,7 +204,7 @@ func (e *ApplicationInsightsDatasource) createRequest(ctx context.Context, dsInf // find plugin plugin, ok := plugins.DataSources[dsInfo.Type] if !ok { - return nil, errors.New("Unable to find datasource plugin Azure Application Insights") + return nil, errors.New("unable to find datasource plugin Azure Application Insights") } cloudName := dsInfo.JsonData.Get("cloudName").MustString("azuremonitor") diff --git a/pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go index 7a77ac2344e..47f305d49d5 100644 --- a/pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/azure-log-analytics-datasource.go @@ -207,7 +207,7 @@ func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { azlog.Debug("Failed to create request", "error", err) - return nil, errutil.Wrap("Failed to create request", err) + return nil, errutil.Wrap("failed to create request", err) } req.Header.Set("Content-Type", "application/json") @@ -216,7 +216,7 @@ func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo // find plugin plugin, ok := plugins.DataSources[dsInfo.Type] if !ok { - return nil, errors.New("Unable to find datasource plugin Azure Monitor") + return nil, errors.New("unable to find datasource plugin Azure Monitor") } cloudName := dsInfo.JsonData.Get("cloudName").MustString("azuremonitor") @@ -272,7 +272,7 @@ func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (Azu if res.StatusCode/100 != 2 { azlog.Debug("Request failed", "status", res.Status, "body", string(body)) - return AzureLogAnalyticsResponse{}, fmt.Errorf("Request failed status: %v: %w", res.Status, fmt.Errorf(string(body))) + return AzureLogAnalyticsResponse{}, fmt.Errorf("request failed, status: %s, body: %s", res.Status, string(body)) } var data AzureLogAnalyticsResponse diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go index a6a6fa5be77..6980ed3690a 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go @@ -220,7 +220,7 @@ func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo *mode // find plugin plugin, ok := plugins.DataSources[dsInfo.Type] if !ok { - return nil, errors.New("Unable to find datasource plugin Azure Monitor") + return nil, errors.New("unable to find datasource plugin Azure Monitor") } cloudName := dsInfo.JsonData.Get("cloudName").MustString("azuremonitor") @@ -263,7 +263,7 @@ func (e *AzureMonitorDatasource) unmarshalResponse(res *http.Response) (AzureMon if res.StatusCode/100 != 2 { azlog.Debug("Request failed", "status", res.Status, "body", string(body)) - return AzureMonitorResponse{}, fmt.Errorf("Request failed status: %v", res.Status) + return AzureMonitorResponse{}, fmt.Errorf("request failed, status: %s", res.Status) } var data AzureMonitorResponse diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go index c6c793197cb..0dec9ceba8d 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor.go +++ b/pkg/tsdb/azuremonitor/azuremonitor.go @@ -66,7 +66,7 @@ func (e *AzureMonitorExecutor) Query(ctx context.Context, dsInfo *models.DataSou case "Insights Analytics": insightsAnalyticsQueries = append(insightsAnalyticsQueries, query) default: - return nil, fmt.Errorf("Alerting not supported for %s", queryType) + return nil, fmt.Errorf("alerting not supported for %q", queryType) } } diff --git a/pkg/tsdb/azuremonitor/insights-analytics-datasource.go b/pkg/tsdb/azuremonitor/insights-analytics-datasource.go index 9b8182e79da..829d75c323a 100644 --- a/pkg/tsdb/azuremonitor/insights-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/insights-analytics-datasource.go @@ -139,7 +139,7 @@ func (e *InsightsAnalyticsDatasource) executeQuery(ctx context.Context, query *I if res.StatusCode/100 != 2 { azlog.Debug("Request failed", "status", res.Status, "body", string(body)) - return queryResultError(fmt.Errorf("Request failed status: %v %w", res.Status, fmt.Errorf(string(body)))) + return queryResultError(fmt.Errorf("request failed, status: %s, body: %s", res.Status, body)) } var logResponse AzureLogAnalyticsResponse d := json.NewDecoder(bytes.NewReader(body)) @@ -180,7 +180,7 @@ func (e *InsightsAnalyticsDatasource) createRequest(ctx context.Context, dsInfo // find plugin plugin, ok := plugins.DataSources[dsInfo.Type] if !ok { - return nil, errors.New("Unable to find datasource plugin Azure Application Insights") + return nil, errors.New("unable to find datasource plugin Azure Application Insights") } cloudName := dsInfo.JsonData.Get("cloudName").MustString("azuremonitor") diff --git a/pkg/tsdb/azuremonitor/macros.go b/pkg/tsdb/azuremonitor/macros.go index 221a73e14fe..11cd9f1e83b 100644 --- a/pkg/tsdb/azuremonitor/macros.go +++ b/pkg/tsdb/azuremonitor/macros.go @@ -126,7 +126,7 @@ func (m *kqlMacroEngine) evaluateMacro(name string, defaultTimeField string, arg case "escapeMulti": return "", fmt.Errorf("escapeMulti macro not formatted correctly") default: - return "", fmt.Errorf("Unknown macro %v", name) + return "", fmt.Errorf("unknown macro %q", name) } } diff --git a/pkg/tsdb/azuremonitor/time-grain.go b/pkg/tsdb/azuremonitor/time-grain.go index 7321192113a..5e28d64353e 100644 --- a/pkg/tsdb/azuremonitor/time-grain.go +++ b/pkg/tsdb/azuremonitor/time-grain.go @@ -28,7 +28,7 @@ func (tg *TimeGrain) createISO8601DurationFromIntervalMS(interval int64) (string timeValueString := formatted[0 : len(formatted)-1] timeValue, err := strconv.Atoi(timeValueString) if err != nil { - return "", fmt.Errorf("Could not parse interval %v to an ISO 8061 duration", interval) + return "", fmt.Errorf("could not parse interval %q to an ISO 8061 duration: %w", interval, err) } unit := formatted[len(formatted)-1:] diff --git a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go index 2f44e456277..42ca088dfb5 100644 --- a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go @@ -103,7 +103,7 @@ func (e *CloudMonitoringExecutor) getGCEDefaultProject(ctx context.Context, tsdb gceDefaultProject, err := e.getDefaultProject(ctx) if err != nil { - return nil, fmt.Errorf("Failed to retrieve default project from GCE metadata server. error: %v", err) + return nil, fmt.Errorf("failed to retrieve default project from GCE metadata server, error: %w", err) } queryResult.Meta.Set("defaultProject", gceDefaultProject) @@ -769,7 +769,7 @@ func (e *CloudMonitoringExecutor) createRequest(ctx context.Context, dsInfo *mod req, err := http.NewRequest(http.MethodGet, "https://monitoring.googleapis.com/", nil) if err != nil { slog.Error("Failed to create request", "error", err) - return nil, fmt.Errorf("Failed to create request. error: %v", err) + return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") @@ -778,7 +778,7 @@ func (e *CloudMonitoringExecutor) createRequest(ctx context.Context, dsInfo *mod // find plugin plugin, ok := plugins.DataSources[dsInfo.Type] if !ok { - return nil, errors.New("Unable to find datasource plugin CloudMonitoring") + return nil, errors.New("unable to find datasource plugin CloudMonitoring") } var cloudMonitoringRoute *plugins.AppPluginRoute @@ -799,14 +799,14 @@ func (e *CloudMonitoringExecutor) getDefaultProject(ctx context.Context) (string if authenticationType == gceAuthentication { defaultCredentials, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/monitoring.read") if err != nil { - return "", fmt.Errorf("Failed to retrieve default project from GCE metadata server. error: %v", err) + return "", fmt.Errorf("failed to retrieve default project from GCE metadata server: %w", err) } token, err := defaultCredentials.TokenSource.Token() if err != nil { - return "", fmt.Errorf("Failed to retrieve GCP credential token. error: %v", err) + return "", fmt.Errorf("failed to retrieve GCP credential token: %w", err) } if !token.Valid() { - return "", errors.New("Failed to validate GCP credentials") + return "", errors.New("failed to validate GCP credentials") } return defaultCredentials.ProjectID, nil diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 7e3d3b7a185..d0198150617 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -5,7 +5,6 @@ import ( "fmt" "regexp" "strings" - "sync" "time" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -27,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb" ) @@ -54,14 +54,30 @@ var plog = log.New("tsdb.cloudwatch") var aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) func init() { - tsdb.RegisterTsdbQueryEndpoint("cloudwatch", func(ds *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - return newExecutor(), nil + registry.Register(®istry.Descriptor{ + Name: "CloudWatchService", + InitPriority: registry.Low, + Instance: &CloudWatchService{}, }) } -func newExecutor() *cloudWatchExecutor { +type CloudWatchService struct { + LogsService *LogsService `inject:""` +} + +func (s *CloudWatchService) Init() error { + plog.Debug("initing") + + tsdb.RegisterTsdbQueryEndpoint("cloudwatch", func(ds *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + return newExecutor(s.LogsService), nil + }) + + return nil +} + +func newExecutor(logsService *LogsService) *cloudWatchExecutor { return &cloudWatchExecutor{ - logsClientsByRegion: map[string]cloudwatchlogsiface.CloudWatchLogsAPI{}, + logsService: logsService, } } @@ -69,10 +85,10 @@ func newExecutor() *cloudWatchExecutor { type cloudWatchExecutor struct { *models.DataSource - ec2Client ec2iface.EC2API - rgtaClient resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI - logsClientsByRegion map[string]cloudwatchlogsiface.CloudWatchLogsAPI - mtx sync.Mutex + ec2Client ec2iface.EC2API + rgtaClient resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI + + logsService *LogsService } func (e *cloudWatchExecutor) newSession(region string) (*session.Session, error) { @@ -187,20 +203,12 @@ func (e *cloudWatchExecutor) getCWClient(region string) (cloudwatchiface.CloudWa } func (e *cloudWatchExecutor) getCWLogsClient(region string) (cloudwatchlogsiface.CloudWatchLogsAPI, error) { - e.mtx.Lock() - defer e.mtx.Unlock() - - if logsClient, ok := e.logsClientsByRegion[region]; ok { - return logsClient, nil - } - sess, err := e.newSession(region) if err != nil { return nil, err } logsClient := newCWLogsClient(sess) - e.logsClientsByRegion[region] = logsClient return logsClient, nil } @@ -301,6 +309,8 @@ func (e *cloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc result, err = e.executeAnnotationQuery(ctx, queryContext) case "logAction": result, err = e.executeLogActions(ctx, queryContext) + case "liveLogAction": + result, err = e.executeLiveLogQuery(ctx, queryContext) case "timeSeriesQuery": fallthrough default: diff --git a/pkg/tsdb/cloudwatch/live.go b/pkg/tsdb/cloudwatch/live.go new file mode 100644 index 00000000000..fa81454187e --- /dev/null +++ b/pkg/tsdb/cloudwatch/live.go @@ -0,0 +1,313 @@ +package cloudwatch + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go/service/servicequotas" + "github.com/aws/aws-sdk-go/service/servicequotas/servicequotasiface" + "github.com/centrifugal/centrifuge" + "github.com/google/uuid" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/util/retryer" + "golang.org/x/sync/errgroup" +) + +const defaultConcurrentQueries = 4 + +type LogQueryRunnerSupplier struct { + Publisher models.ChannelPublisher + Service *LogsService +} + +type logQueryRunner struct { + channelName string + publish models.ChannelPublisher + running map[string]bool + runningMu sync.Mutex + service *LogsService +} + +const ( + maxAttempts = 8 + minRetryDelay = 500 * time.Millisecond + maxRetryDelay = 30 * time.Second +) + +// GetHandlerForPath gets the channel handler for a certain path. +func (s *LogQueryRunnerSupplier) GetHandlerForPath(path string) (models.ChannelHandler, error) { + return &logQueryRunner{ + channelName: path, + publish: s.Publisher, + running: make(map[string]bool), + service: s.Service, + }, nil +} + +// OnSubscribe publishes results from the corresponding CloudWatch Logs query to the provided channel +func (r *logQueryRunner) OnSubscribe(c *centrifuge.Client, e centrifuge.SubscribeEvent) (centrifuge.SubscribeReply, error) { + r.runningMu.Lock() + defer r.runningMu.Unlock() + + if _, ok := r.running[e.Channel]; ok { + return centrifuge.SubscribeReply{}, nil + } + + r.running[e.Channel] = true + go func() { + if err := r.publishResults(e.Channel); err != nil { + plog.Error(err.Error()) + } + }() + + return centrifuge.SubscribeReply{}, nil +} + +// OnPublish checks if a message from the websocket can be broadcast on this channel +func (r *logQueryRunner) OnPublish(c *centrifuge.Client, e centrifuge.PublishEvent) (centrifuge.PublishReply, error) { + return centrifuge.PublishReply{}, fmt.Errorf("can not publish") +} + +func (r *logQueryRunner) publishResults(channelName string) error { + defer func() { + r.service.DeleteResponseChannel(channelName) + r.runningMu.Lock() + delete(r.running, channelName) + r.runningMu.Unlock() + }() + + responseChannel, err := r.service.GetResponseChannel(channelName) + if err != nil { + return err + } + + for response := range responseChannel { + responseBytes, err := json.Marshal(response) + if err != nil { + return err + } + + if err := r.publish(channelName, responseBytes); err != nil { + return err + } + } + + return nil +} + +// executeLiveLogQuery executes a CloudWatch Logs query with live updates over WebSocket. +// A WebSocket channel is created, which goroutines send responses over. +func (e *cloudWatchExecutor) executeLiveLogQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { + responseChannelName := uuid.New().String() + responseChannel := make(chan *tsdb.Response) + if err := e.logsService.AddResponseChannel("plugin/cloudwatch/"+responseChannelName, responseChannel); err != nil { + close(responseChannel) + return nil, err + } + + go e.sendLiveQueriesToChannel(queryContext, responseChannel) + + response := &tsdb.Response{ + Results: map[string]*tsdb.QueryResult{ + "A": { + RefId: "A", + Meta: simplejson.NewFromAny(map[string]interface{}{ + "channelName": responseChannelName, + }), + }, + }, + } + + return response, nil +} + +func (e *cloudWatchExecutor) sendLiveQueriesToChannel(queryContext *tsdb.TsdbQuery, responseChannel chan *tsdb.Response) { + defer close(responseChannel) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + eg, ectx := errgroup.WithContext(ctx) + + for _, query := range queryContext.Queries { + query := query + eg.Go(func() error { + return e.startLiveQuery(ectx, responseChannel, query, queryContext.TimeRange) + }) + } + + if err := eg.Wait(); err != nil { + plog.Error(err.Error()) + } +} + +func (e *cloudWatchExecutor) getQueue(queueKey string) (chan bool, error) { + e.logsService.queueLock.Lock() + defer e.logsService.queueLock.Unlock() + + if queue, ok := e.logsService.queues[queueKey]; ok { + return queue, nil + } + + concurrentQueriesQuota := e.fetchConcurrentQueriesQuota(queueKey) + + queueChannel := make(chan bool, concurrentQueriesQuota) + e.logsService.queues[queueKey] = queueChannel + + return queueChannel, nil +} + +func (e *cloudWatchExecutor) fetchConcurrentQueriesQuota(region string) int { + sess, err := e.newSession(region) + if err != nil { + plog.Warn("Could not get service quota client") + return defaultConcurrentQueries + } + + client := newQuotasClient(sess) + + concurrentQueriesQuota, err := client.GetServiceQuota(&servicequotas.GetServiceQuotaInput{ + ServiceCode: aws.String("logs"), + QuotaCode: aws.String("L-32C48FBB"), + }) + if err != nil { + plog.Warn("Could not get service quota") + return defaultConcurrentQueries + } + + if concurrentQueriesQuota != nil && concurrentQueriesQuota.Quota != nil && concurrentQueriesQuota.Quota.Value != nil { + return int(*concurrentQueriesQuota.Quota.Value) + } + + plog.Warn("Could not get service quota") + + defaultConcurrentQueriesQuota, err := client.GetAWSDefaultServiceQuota(&servicequotas.GetAWSDefaultServiceQuotaInput{ + ServiceCode: aws.String("logs"), + QuotaCode: aws.String("L-32C48FBB"), + }) + if err != nil { + plog.Warn("Could not get default service quota") + return defaultConcurrentQueries + } + + if defaultConcurrentQueriesQuota != nil && defaultConcurrentQueriesQuota.Quota != nil && defaultConcurrentQueriesQuota.Quota.Value != nil { + return int(*defaultConcurrentQueriesQuota.Quota.Value) + } + + plog.Warn("Could not get default service quota") + return defaultConcurrentQueries +} + +func (e *cloudWatchExecutor) startLiveQuery(ctx context.Context, responseChannel chan *tsdb.Response, query *tsdb.Query, timeRange *tsdb.TimeRange) error { + defaultRegion := e.DataSource.JsonData.Get("defaultRegion").MustString() + parameters := query.Model + region := parameters.Get("region").MustString(defaultRegion) + logsClient, err := e.getCWLogsClient(region) + if err != nil { + return err + } + + queue, err := e.getQueue(fmt.Sprintf("%s-%d", region, e.DataSource.Id)) + if err != nil { + return err + } + + // Wait until there are no more active workers than the concurrent queries quota + queue <- true + defer func() { <-queue }() + + startQueryOutput, err := e.executeStartQuery(ctx, logsClient, parameters, timeRange) + if err != nil { + return err + } + + queryResultsInput := &cloudwatchlogs.GetQueryResultsInput{ + QueryId: startQueryOutput.QueryId, + } + + recordsMatched := 0.0 + return retryer.Retry(func() (retryer.RetrySignal, error) { + getQueryResultsOutput, err := logsClient.GetQueryResultsWithContext(ctx, queryResultsInput) + if err != nil { + return retryer.FuncError, err + } + + retryNeeded := *getQueryResultsOutput.Statistics.RecordsMatched <= recordsMatched + recordsMatched = *getQueryResultsOutput.Statistics.RecordsMatched + + dataFrame, err := logsResultsToDataframes(getQueryResultsOutput) + if err != nil { + return retryer.FuncError, err + } + + dataFrame.Name = query.RefId + dataFrame.RefID = query.RefId + var dataFrames data.Frames + + // When a query of the form "stats ... by ..." is made, we want to return + // one series per group defined in the query, but due to the format + // the query response is in, there does not seem to be a way to tell + // by the response alone if/how the results should be grouped. + // Because of this, if the frontend sees that a "stats ... by ..." query is being made + // the "statsGroups" parameter is sent along with the query to the backend so that we + // can correctly group the CloudWatch logs response. + statsGroups := parameters.Get("statsGroups").MustStringArray() + if len(statsGroups) > 0 && len(dataFrame.Fields) > 0 { + groupedFrames, err := groupResults(dataFrame, statsGroups) + if err != nil { + return retryer.FuncError, err + } + + dataFrames = groupedFrames + } else { + if dataFrame.Meta != nil { + dataFrame.Meta.PreferredVisualization = "logs" + } else { + dataFrame.Meta = &data.FrameMeta{ + PreferredVisualization: "logs", + } + } + + dataFrames = data.Frames{dataFrame} + } + + responseChannel <- &tsdb.Response{ + Results: map[string]*tsdb.QueryResult{ + query.RefId: { + RefId: query.RefId, + Dataframes: tsdb.NewDecodedDataFrames(dataFrames), + }, + }, + } + + if isTerminated(*getQueryResultsOutput.Status) { + return retryer.FuncComplete, nil + } else if retryNeeded { + return retryer.FuncFailure, nil + } + + return retryer.FuncSuccess, nil + }, maxAttempts, minRetryDelay, maxRetryDelay) +} + +// Service quotas client factory. +// +// Stubbable by tests. +var newQuotasClient = func(sess *session.Session) servicequotasiface.ServiceQuotasAPI { + client := servicequotas.New(sess) + client.Handlers.Send.PushFront(func(r *request.Request) { + r.HTTPRequest.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion)) + }) + + return client +} diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index 223dfa1721a..c4017b2b1b9 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -47,7 +47,7 @@ func TestQuery_DescribeLogGroups(t *testing.T) { }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -100,7 +100,7 @@ func TestQuery_DescribeLogGroups(t *testing.T) { }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -170,7 +170,7 @@ func TestQuery_GetLogGroupFields(t *testing.T) { const refID = "A" - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -249,7 +249,7 @@ func TestQuery_StartQuery(t *testing.T) { To: "1584700643000", } - executor := newExecutor() + executor := newExecutor(nil) _, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ TimeRange: timeRange, Queries: []*tsdb.Query{ @@ -295,7 +295,7 @@ func TestQuery_StartQuery(t *testing.T) { To: "1584873443000", } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ TimeRange: timeRange, Queries: []*tsdb.Query{ @@ -371,7 +371,7 @@ func TestQuery_StopQuery(t *testing.T) { To: "1584700643000", } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ TimeRange: timeRange, Queries: []*tsdb.Query{ @@ -458,7 +458,7 @@ func TestQuery_GetQueryResults(t *testing.T) { }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { diff --git a/pkg/tsdb/cloudwatch/logs.go b/pkg/tsdb/cloudwatch/logs.go new file mode 100644 index 00000000000..960f1fc8394 --- /dev/null +++ b/pkg/tsdb/cloudwatch/logs.go @@ -0,0 +1,63 @@ +package cloudwatch + +import ( + "fmt" + "sync" + + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/tsdb" +) + +func init() { + registry.RegisterService(&LogsService{}) +} + +// LogsService provides methods for querying CloudWatch Logs. +type LogsService struct { + channelMu sync.Mutex + responseChannels map[string]chan *tsdb.Response + queues map[string](chan bool) + queueLock sync.Mutex +} + +// Init is called by the DI framework to initialize the instance. +func (s *LogsService) Init() error { + s.responseChannels = make(map[string]chan *tsdb.Response) + s.queues = make(map[string](chan bool)) + return nil +} + +func (s *LogsService) AddResponseChannel(name string, channel chan *tsdb.Response) error { + s.channelMu.Lock() + defer s.channelMu.Unlock() + + if _, ok := s.responseChannels[name]; ok { + return fmt.Errorf("channel with name '%s' already exists", name) + } + + s.responseChannels[name] = channel + return nil +} + +func (s *LogsService) GetResponseChannel(name string) (chan *tsdb.Response, error) { + s.channelMu.Lock() + defer s.channelMu.Unlock() + + if responseChannel, ok := s.responseChannels[name]; ok { + return responseChannel, nil + } + + return nil, fmt.Errorf("channel with name '%s' not found", name) +} + +func (s *LogsService) DeleteResponseChannel(name string) { + s.channelMu.Lock() + defer s.channelMu.Unlock() + + if _, ok := s.responseChannels[name]; ok { + delete(s.responseChannels, name) + return + } + + plog.Warn("Channel with name '" + name + "' not found") +} diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 0edb7933940..4dd2146ca5f 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -44,7 +44,7 @@ func TestQuery_Metrics(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -101,7 +101,7 @@ func TestQuery_Metrics(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -163,7 +163,7 @@ func TestQuery_Regions(t *testing.T) { cli = fakeEC2Client{ regions: []string{regionName}, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -245,7 +245,7 @@ func TestQuery_InstanceAttributes(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -348,7 +348,7 @@ func TestQuery_EBSVolumeIDs(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { @@ -448,7 +448,7 @@ func TestQuery_ResourceARNs(t *testing.T) { }, }, } - executor := newExecutor() + executor := newExecutor(nil) resp, err := executor.Query(context.Background(), fakeDataSource(), &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ { diff --git a/pkg/tsdb/cloudwatch/query_transformer.go b/pkg/tsdb/cloudwatch/query_transformer.go index 8a17f7a73bb..0ce7c1e8be5 100644 --- a/pkg/tsdb/cloudwatch/query_transformer.go +++ b/pkg/tsdb/cloudwatch/query_transformer.go @@ -53,7 +53,7 @@ func (e *cloudWatchExecutor) transformRequestQueriesToCloudWatchQueries(requestQ return cloudwatchQueries, nil } -func (e *cloudWatchExecutor) transformQueryResponseToQueryResult(cloudwatchResponses []*cloudwatchResponse) map[string]*tsdb.QueryResult { +func (e *cloudWatchExecutor) transformQueryResponsesToQueryResult(cloudwatchResponses []*cloudwatchResponse) map[string]*tsdb.QueryResult { responsesByRefID := make(map[string][]*cloudwatchResponse) refIDs := sort.StringSlice{} for _, res := range cloudwatchResponses { diff --git a/pkg/tsdb/cloudwatch/query_transformer_test.go b/pkg/tsdb/cloudwatch/query_transformer_test.go index 7bbd6db93a2..7fd9054a57f 100644 --- a/pkg/tsdb/cloudwatch/query_transformer_test.go +++ b/pkg/tsdb/cloudwatch/query_transformer_test.go @@ -9,7 +9,7 @@ import ( ) func TestQueryTransformer(t *testing.T) { - executor := newExecutor() + executor := newExecutor(nil) t.Run("One cloudwatchQuery is generated when its request query has one stat", func(t *testing.T) { requestQueries := []*requestQuery{ { diff --git a/pkg/tsdb/cloudwatch/response_parser.go b/pkg/tsdb/cloudwatch/response_parser.go index c798aeb1752..6dec6513171 100644 --- a/pkg/tsdb/cloudwatch/response_parser.go +++ b/pkg/tsdb/cloudwatch/response_parser.go @@ -46,7 +46,7 @@ func (e *cloudWatchExecutor) parseResponse(metricDataOutputs []*cloudwatch.GetMe } } - cloudWatchResponses := make([]*cloudwatchResponse, 0) + cloudWatchResponses := make([]*cloudwatchResponse, 0, len(mdrs)) for id, lr := range mdrs { query := queries[id] frames, partialData, err := parseMetricResults(lr, labels[id], query) @@ -105,12 +105,11 @@ func parseMetricResults(results map[string]*cloudwatch.MetricDataResult, labels } } - timeField := data.NewField("timestamp", nil, []*time.Time{}) - timeField.SetConfig(&data.FieldConfig{DisplayName: "Time"}) + timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []*time.Time{}) + valueField := data.NewField(data.TimeSeriesValueFieldName, tags, []*float64{}) frameName := formatAlias(query, query.Stats, tags, label) - valueField := data.NewField("value", tags, []*float64{}) - valueField.SetConfig(&data.FieldConfig{DisplayName: frameName}) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName}) emptyFrame := data.Frame{ Name: frameName, @@ -160,12 +159,11 @@ func parseMetricResults(results map[string]*cloudwatch.MetricDataResult, labels points = append(points, val) } - timeField := data.NewField("timestamp", nil, timestamps) - timeField.SetConfig(&data.FieldConfig{DisplayName: "Time"}) + timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, timestamps) + valueField := data.NewField(data.TimeSeriesValueFieldName, tags, points) frameName := formatAlias(query, query.Stats, tags, label) - valueField := data.NewField("value", tags, points) - valueField.SetConfig(&data.FieldConfig{DisplayName: frameName}) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: frameName}) frame := data.Frame{ Name: frameName, diff --git a/pkg/tsdb/cloudwatch/response_parser_test.go b/pkg/tsdb/cloudwatch/response_parser_test.go index 2ae03e80db8..3cd1cfcbfda 100644 --- a/pkg/tsdb/cloudwatch/response_parser_test.go +++ b/pkg/tsdb/cloudwatch/response_parser_test.go @@ -310,10 +310,13 @@ func TestCloudWatchResponseParser(t *testing.T) { frame := frames[0] assert.False(t, partialData) assert.Equal(t, "AWS/ApplicationELB_TargetResponseTime_Average", frame.Name) + assert.Equal(t, "Time", frame.Fields[0].Name) assert.Equal(t, "lb", frame.Fields[1].Labels["LoadBalancer"]) assert.Equal(t, 10.0, *frame.Fields[1].At(0).(*float64)) assert.Equal(t, 20.0, *frame.Fields[1].At(1).(*float64)) assert.Nil(t, frame.Fields[1].At(2)) assert.Equal(t, 30.0, *frame.Fields[1].At(3).(*float64)) + assert.Equal(t, "Value", frame.Fields[1].Name) + assert.Equal(t, "", frame.Fields[1].Config.DisplayName) }) } diff --git a/pkg/tsdb/cloudwatch/session_test.go b/pkg/tsdb/cloudwatch/session_test.go index 8dac32b1bad..24294c5e11f 100644 --- a/pkg/tsdb/cloudwatch/session_test.go +++ b/pkg/tsdb/cloudwatch/session_test.go @@ -57,7 +57,7 @@ func TestNewSession_AssumeRole(t *testing.T) { const roleARN = "test" - e := newExecutor() + e := newExecutor(nil) e.DataSource = fakeDataSource(fakeDataSourceCfg{ assumeRoleARN: roleARN, }) @@ -84,7 +84,7 @@ func TestNewSession_AssumeRole(t *testing.T) { const roleARN = "test" const externalID = "external" - e := newExecutor() + e := newExecutor(nil) e.DataSource = fakeDataSource(fakeDataSourceCfg{ assumeRoleARN: roleARN, externalID: externalID, diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index 2fd1852bcbd..1385cc90df7 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -97,7 +97,7 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo } cloudwatchResponses = append(cloudwatchResponses, responses...) - res := e.transformQueryResponseToQueryResult(cloudwatchResponses) + res := e.transformQueryResponsesToQueryResult(cloudwatchResponses) for _, queryRes := range res { resultChan <- queryRes } diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index df087d5568e..8afdf3bfa11 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -9,7 +9,7 @@ import ( ) func TestTimeSeriesQuery(t *testing.T) { - executor := newExecutor() + executor := newExecutor(nil) t.Run("End time before start time should result in error", func(t *testing.T) { _, err := executor.executeTimeSeriesQuery(context.TODO(), &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")}) diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index f59726c290e..2416889c726 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -68,7 +68,7 @@ func (e *GraphiteExecutor) Query(ctx context.Context, dsInfo *models.DataSource, if target == "" { glog.Error("No targets in query model", "models without targets", strings.Join(emptyQueries, "\n")) - return nil, errors.New("No query target found for the alert rule") + return nil, errors.New("no query target found for the alert rule") } formData["target"] = []string{target} @@ -140,7 +140,7 @@ func (e *GraphiteExecutor) parseResponse(res *http.Response) ([]TargetResponseDT if res.StatusCode/100 != 2 { glog.Info("Request failed", "status", res.Status, "body", string(body)) - return nil, fmt.Errorf("Request failed status: %v", res.Status) + return nil, fmt.Errorf("request failed, status: %s", res.Status) } var data []TargetResponseDTO @@ -163,7 +163,7 @@ func (e *GraphiteExecutor) createRequest(dsInfo *models.DataSource, data url.Val req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(data.Encode())) if err != nil { glog.Info("Failed to create request", "error", err) - return nil, fmt.Errorf("Failed to create request. error: %v", err) + return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/pkg/tsdb/influxdb/flux/builder.go b/pkg/tsdb/influxdb/flux/builder.go index b67117bae35..191ae25f2f3 100644 --- a/pkg/tsdb/influxdb/flux/builder.go +++ b/pkg/tsdb/influxdb/flux/builder.go @@ -125,7 +125,7 @@ func (fb *frameBuilder) Init(metadata *query.FluxTableMetadata) error { if col != nil { fb.timeColumn = col.Name() fb.timeDisplay = "Time" - if "_time" != fb.timeColumn { + if fb.timeColumn != "_time" { fb.timeDisplay = col.Name() } return nil diff --git a/pkg/tsdb/influxdb/influxdb.go b/pkg/tsdb/influxdb/influxdb.go index 57af1a69470..b29cf2b0d70 100644 --- a/pkg/tsdb/influxdb/influxdb.go +++ b/pkg/tsdb/influxdb/influxdb.go @@ -85,7 +85,7 @@ func (e *InfluxDBExecutor) Query(ctx context.Context, dsInfo *models.DataSource, defer resp.Body.Close() if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("Influxdb returned statuscode invalid status code: %v", resp.Status) + return nil, fmt.Errorf("InfluxDB returned statuscode invalid status code: %s", resp.Status) } var response Response diff --git a/pkg/tsdb/models.go b/pkg/tsdb/models.go index 4d4fb50aeb4..ae12ef36390 100644 --- a/pkg/tsdb/models.go +++ b/pkg/tsdb/models.go @@ -72,16 +72,6 @@ func NewTimePoint(value null.Float, timestamp float64) TimePoint { return TimePoint{value, null.FloatFrom(timestamp)} } -func NewTimeSeriesPointsFromArgs(values ...float64) TimeSeriesPoints { - points := make(TimeSeriesPoints, 0) - - for i := 0; i < len(values); i += 2 { - points = append(points, NewTimePoint(null.FloatFrom(values[i]), values[i+1])) - } - - return points -} - func NewTimeSeries(name string, points TimeSeriesPoints) *TimeSeries { return &TimeSeries{ Name: name, diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go index 90a710e428e..e820471398f 100644 --- a/pkg/tsdb/mssql/macros.go +++ b/pkg/tsdb/mssql/macros.go @@ -129,6 +129,6 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er } return "", err default: - return "", fmt.Errorf("Unknown macro %v", name) + return "", fmt.Errorf("unknown macro %q", name) } } diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index 1fb924ecfbd..cee46f0f959 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -35,7 +35,7 @@ func (m *mySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRa matches := restrictedRegExp.FindAllStringSubmatch(sql, 1) if len(matches) > 0 { m.logger.Error("show grants, session_user(), current_user(), system_user() or user() not allowed in query") - return "", errors.New("Invalid query. Inspect Grafana server log for details") + return "", errors.New("invalid query - inspect Grafana server log for details") } rExp, _ := regexp.Compile(sExpr) @@ -135,6 +135,6 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er } return "", err default: - return "", fmt.Errorf("Unknown macro %v", name) + return "", fmt.Errorf("unknown macro %v", name) } } diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index c1945905a77..6dc7472e497 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -181,7 +181,7 @@ func TestMacroEngine(t *testing.T) { for _, tc := range tcs { _, err := engine.Interpolate(nil, nil, tc) - So(err.Error(), ShouldEqual, "Invalid query. Inspect Grafana server log for details") + So(err.Error(), ShouldEqual, "invalid query - inspect Grafana server log for details") } }) }) diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index e2107812b65..75b6ea1610d 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -150,4 +150,4 @@ func (t *mysqlQueryResultTransformer) TransformQueryError(err error) error { return err } -var errQueryFailed = errors.New("Query failed. Please inspect Grafana server log for details") +var errQueryFailed = errors.New("query failed - please inspect Grafana server log for details") diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 9c40a2f25d2..28b70029459 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -88,13 +88,13 @@ func (e *OpenTsdbExecutor) createRequest(dsInfo *models.DataSource, data OpenTsd postData, err := json.Marshal(data) if err != nil { plog.Info("Failed marshaling data", "error", err) - return nil, fmt.Errorf("Failed to create request. error: %v", err) + return nil, fmt.Errorf("failed to create request: %w", err) } req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(string(postData))) if err != nil { plog.Info("Failed to create request", "error", err) - return nil, fmt.Errorf("Failed to create request. error: %v", err) + return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") @@ -117,7 +117,7 @@ func (e *OpenTsdbExecutor) parseResponse(query OpenTsdbQuery, res *http.Response if res.StatusCode/100 != 2 { plog.Info("Request failed", "status", res.Status, "body", string(body)) - return nil, fmt.Errorf("Request failed status: %v", res.Status) + return nil, fmt.Errorf("request failed, status: %s", res.Status) } var data []OpenTsdbResponse diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 55745855b60..a3781c22a6a 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -159,6 +159,6 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string, } return "", err default: - return "", fmt.Errorf("Unknown macro %v", name) + return "", fmt.Errorf("unknown macro %q", name) } } diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 76294da2624..68b367318c9 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -193,7 +193,7 @@ func parseResponse(value model.Value, query *PrometheusQuery) (*tsdb.QueryResult data, ok := value.(model.Matrix) if !ok { - return queryRes, fmt.Errorf("Unsupported result format: %s", value.Type().String()) + return queryRes, fmt.Errorf("unsupported result format: %q", value.Type().String()) } for _, v := range data { diff --git a/pkg/tsdb/query_endpoint.go b/pkg/tsdb/query_endpoint.go index 4e4f1c9ec9e..856b7066832 100644 --- a/pkg/tsdb/query_endpoint.go +++ b/pkg/tsdb/query_endpoint.go @@ -2,7 +2,6 @@ package tsdb import ( "context" - "fmt" "github.com/grafana/grafana/pkg/models" ) @@ -19,18 +18,6 @@ func init() { registry = make(map[string]GetTsdbQueryEndpointFn) } -func getTsdbQueryEndpointFor(dsInfo *models.DataSource) (TsdbQueryEndpoint, error) { - if fn, exists := registry[dsInfo.Type]; exists { - executor, err := fn(dsInfo) - if err != nil { - return nil, err - } - - return executor, nil - } - return nil, fmt.Errorf("Could not find executor for data source type: %s", dsInfo.Type) -} - func RegisterTsdbQueryEndpoint(pluginId string, fn GetTsdbQueryEndpointFn) { registry[pluginId] = fn } diff --git a/pkg/tsdb/request.go b/pkg/tsdb/request.go index 162116fb2fa..6d751374174 100644 --- a/pkg/tsdb/request.go +++ b/pkg/tsdb/request.go @@ -2,6 +2,7 @@ package tsdb import ( "context" + "fmt" "github.com/grafana/grafana/pkg/models" ) @@ -9,7 +10,14 @@ import ( type HandleRequestFunc func(ctx context.Context, dsInfo *models.DataSource, req *TsdbQuery) (*Response, error) func HandleRequest(ctx context.Context, dsInfo *models.DataSource, req *TsdbQuery) (*Response, error) { - endpoint, err := getTsdbQueryEndpointFor(dsInfo) + var endpoint TsdbQueryEndpoint + fn, exists := registry[dsInfo.Type] + if !exists { + return nil, fmt.Errorf("could not find executor for data source type: %s", dsInfo.Type) + } + + var err error + endpoint, err = fn(dsInfo) if err != nil { return nil, err } diff --git a/pkg/tsdb/sqleng/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go index f2f7a2795f3..c90144d5a81 100644 --- a/pkg/tsdb/sqleng/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -199,7 +199,7 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, return result, nil } -// global macros/substitutions for all sql datasources +// Interpolate provides global macros/substitutions for all sql datasources. var Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { minInterval, err := tsdb.GetIntervalFrom(query.DataSource, query.Model, time.Second*60) if err != nil { @@ -343,7 +343,7 @@ func (e *sqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.R } if cfg.timeIndex == -1 { - return fmt.Errorf("Found no column named %s", strings.Join(e.timeColumnNames, " or ")) + return fmt.Errorf("found no column named %q", strings.Join(e.timeColumnNames, " or ")) } if cfg.fillMissing { @@ -666,7 +666,10 @@ func ConvertSqlValueColumnToFloat(columnName string, columnValue interface{}) (n case nil: value.Valid = false default: - return null.NewFloat(0, false), fmt.Errorf("Value column must have numeric datatype, column: %s type: %T value: %v", columnName, typedValue, typedValue) + return null.NewFloat(0, false), fmt.Errorf( + "value column must have numeric datatype, column: %s, type: %T, value: %v", + columnName, typedValue, typedValue, + ) } return value, nil diff --git a/pkg/util/encoding.go b/pkg/util/encoding.go index 2b9afa96018..8f8fe4f4d19 100644 --- a/pkg/util/encoding.go +++ b/pkg/util/encoding.go @@ -57,7 +57,7 @@ func DecodeBasicAuthHeader(header string) (string, string, error) { userAndPass := strings.SplitN(string(decoded), ":", 2) if len(userAndPass) != 2 { - return "", "", errors.New("Invalid basic auth header") + return "", "", errors.New("invalid basic auth header") } return userAndPass[0], userAndPass[1], nil diff --git a/pkg/util/filepath.go b/pkg/util/filepath.go index 783c8ac5455..1b9a85618b3 100644 --- a/pkg/util/filepath.go +++ b/pkg/util/filepath.go @@ -63,7 +63,7 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow // vout("SymLink Path: %v, links to: %v", resolvedPath, path2) if symlinkPathsFollowed != nil { if _, ok := symlinkPathsFollowed[path2]; ok { - errMsg := "Potential SymLink Infinite Loop. Path: %v, Link To: %v" + errMsg := "potential symLink infinite loop, path: %v, link to: %v" return fmt.Errorf(errMsg, resolvedPath, path2) } symlinkPathsFollowed[path2] = true diff --git a/pkg/util/ip_address.go b/pkg/util/ip_address.go index 0f35973651e..7fab23ac68a 100644 --- a/pkg/util/ip_address.go +++ b/pkg/util/ip_address.go @@ -53,7 +53,7 @@ func SplitHostPortDefault(input, defaultHost, defaultPort string) (NetworkAddres addrEnd := strings.LastIndex(input, "]") if addrEnd < 0 { // Malformed address - return addr, fmt.Errorf("Malformed IPv6 address: '%s'", input) + return addr, fmt.Errorf("malformed IPv6 address: '%s'", input) } start = addrEnd @@ -83,7 +83,7 @@ func SplitHostPortDefault(input, defaultHost, defaultPort string) (NetworkAddres // SplitHostPort splits ip address/hostname string by host and port func SplitHostPort(input string) (NetworkAddress, error) { if len(input) == 0 { - return NetworkAddress{}, fmt.Errorf("Input is empty") + return NetworkAddress{}, fmt.Errorf("input is empty") } return SplitHostPortDefault(input, "", "") } diff --git a/pkg/util/ip_address_test.go b/pkg/util/ip_address_test.go index 247797bb395..79053bfc994 100644 --- a/pkg/util/ip_address_test.go +++ b/pkg/util/ip_address_test.go @@ -32,7 +32,7 @@ func TestParseIPAddress_Invalid(t *testing.T) { }{ { input: "[::1", - err: "failed to split network address \"[::1\" by host and port: Malformed IPv6 address: '[::1'", + err: "failed to split network address \"[::1\" by host and port: malformed IPv6 address: '[::1'", }, { input: "::1]", @@ -40,7 +40,7 @@ func TestParseIPAddress_Invalid(t *testing.T) { }, { input: "", - err: "failed to split network address \"\" by host and port: Input is empty", + err: "failed to split network address \"\" by host and port: input is empty", }, } for _, testcase := range tests { diff --git a/pkg/util/math.go b/pkg/util/math.go index 391ab426081..e656393b14e 100644 --- a/pkg/util/math.go +++ b/pkg/util/math.go @@ -1,13 +1,5 @@ package util -// MaxInt returns the larger of x or y. -func MaxInt(x, y int) int { - if x < y { - return y - } - return x -} - // MinInt returns the smaller of x or y. func MinInt(x, y int) int { if x > y { diff --git a/pkg/util/retryer/retryer.go b/pkg/util/retryer/retryer.go new file mode 100644 index 00000000000..8055f06841f --- /dev/null +++ b/pkg/util/retryer/retryer.go @@ -0,0 +1,56 @@ +package retryer + +import ( + "time" +) + +type RetrySignal = int + +const ( + FuncSuccess RetrySignal = iota + FuncFailure + FuncComplete + FuncError +) + +// Retry retries the provided function using exponential backoff, starting with `minDelay` between attempts, and increasing to +// `maxDelay` after each failure. Stops when the provided function returns `FuncComplete`, or `maxRetries` is reached. +func Retry(body func() (RetrySignal, error), maxRetries int, minDelay time.Duration, maxDelay time.Duration) error { + currentDelay := minDelay + ticker := time.NewTicker(currentDelay) + defer ticker.Stop() + + retries := 0 + for range ticker.C { + response, err := body() + if err != nil { + return err + } + + switch response { + case FuncSuccess: + currentDelay = minDelay + ticker.Reset(currentDelay) + retries = 0 + case FuncFailure: + currentDelay = minDuration(currentDelay*2, maxDelay) + ticker.Reset(currentDelay) + retries++ + case FuncComplete: + return nil + } + + if retries >= maxRetries { + return nil + } + } + + return nil +} + +func minDuration(a time.Duration, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} diff --git a/pkg/util/retryer/retryer_test.go b/pkg/util/retryer/retryer_test.go new file mode 100644 index 00000000000..575762f602b --- /dev/null +++ b/pkg/util/retryer/retryer_test.go @@ -0,0 +1,22 @@ +package retryer + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMaxRetries(t *testing.T) { + retryVal := 0 + + err := Retry(func() (RetrySignal, error) { + retryVal++ + return FuncFailure, nil + }, 8, 100*time.Millisecond, 100*time.Millisecond) + if err != nil { + assert.FailNow(t, "Error while retrying function") + } + + assert.Equal(t, 8, retryVal) +} diff --git a/public/app/app.ts b/public/app/app.ts index 9ad96e3fedb..3981ad7ef1c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -13,7 +13,6 @@ import 'jquery'; import 'angular'; import 'angular-route'; import 'angular-sanitize'; -import 'angular-native-dragdrop'; import 'angular-bindonce'; import 'react'; import 'react-dom'; @@ -29,7 +28,6 @@ import _ from 'lodash'; import { AppEvents, setLocale, - setMarkdownOptions, standardEditorsRegistry, standardFieldConfigEditorRegistry, standardTransformersRegistry, @@ -51,6 +49,7 @@ import { getStandardFieldConfigs, getStandardOptionEditors, getScrollbarWidth } import { getDefaultVariableAdapters, variableAdapters } from './features/variables/adapters'; import { initDevFeatures } from './dev'; import { getStandardTransformers } from 'app/core/utils/standardTransformers'; +import { monkeyPatchInjectorWithPreAssignedBindings } from './core/injectorMonkeyPatch'; // add move to lodash for backward compatabiltiy // @ts-ignore @@ -97,8 +96,6 @@ export class GrafanaApp { setLocale(config.bootData.user.locale); setTimeZoneResolver(() => config.bootData.user.timezone); - setMarkdownOptions({ sanitize: !config.disableSanitizeHtml }); - standardEditorsRegistry.setInit(getStandardOptionEditors); standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs); standardTransformersRegistry.setInit(getStandardTransformers); @@ -106,16 +103,12 @@ export class GrafanaApp { app.config( ( - $locationProvider: angular.ILocationProvider, $controllerProvider: angular.IControllerProvider, $compileProvider: angular.ICompileProvider, $filterProvider: angular.IFilterProvider, $httpProvider: angular.IHttpProvider, $provide: angular.auto.IProvideService ) => { - // pre assign bindings before constructor calls - $compileProvider.preAssignBindingsEnabled(true); - if (config.buildInfo.env !== 'development') { $compileProvider.debugInfoEnabled(false); } @@ -153,7 +146,6 @@ export class GrafanaApp { 'ngRoute', 'ngSanitize', '$strap.directives', - 'ang-drag-drop', 'grafana', 'pasvaz.bindonce', 'react', @@ -172,7 +164,9 @@ export class GrafanaApp { $.fn.tooltip.defaults.animation = false; // bootstrap the app - angular.bootstrap(document, this.ngModuleDependencies).invoke(() => { + const injector: any = angular.bootstrap(document, this.ngModuleDependencies); + + injector.invoke(() => { _.each(this.preBootModules, (module: angular.IModule) => { _.extend(module, this.registerFunctions); }); @@ -189,6 +183,8 @@ export class GrafanaApp { } }); + monkeyPatchInjectorWithPreAssignedBindings(injector); + // Preload selected app plugins for (const modulePath of config.pluginsToPreload) { importPluginModule(modulePath); diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 013634a3abf..2a596be7cc4 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -14,6 +14,7 @@ import { DataSourceHttpSettings, GraphContextMenu, Icon, + Spinner, LegacyForms, SeriesColorPickerPopoverWithTheme, UnitPicker, @@ -36,12 +37,12 @@ const { SecretFormField } = LegacyForms; export function registerAngularDirectives() { react2AngularDirective('footer', Footer, []); react2AngularDirective('icon', Icon, [ - 'color', 'name', 'size', 'type', ['onClick', { watchDepth: 'reference', wrapApply: true }], ]); + react2AngularDirective('spinner', Spinner, ['inline']); react2AngularDirective('helpModal', HelpModal, []); react2AngularDirective('sidemenu', SideMenu, []); react2AngularDirective('functionEditor', FunctionEditor, ['func', 'onRemove', 'onMoveLeft', 'onMoveRight']); diff --git a/public/app/core/app_events.ts b/public/app/core/app_events.ts index 1951fd87001..2c3552ec35b 100644 --- a/public/app/core/app_events.ts +++ b/public/app/core/app_events.ts @@ -1,5 +1,5 @@ -import { Emitter } from './utils/emitter'; +import { EventBusSrv, EventBusExtended } from '@grafana/data'; -export const appEvents = new Emitter(); +export const appEvents: EventBusExtended = new EventBusSrv(); export default appEvents; diff --git a/public/app/core/components/Login/LoginForm.tsx b/public/app/core/components/Login/LoginForm.tsx index 8b3eae33059..bdd7ef2fb4e 100644 --- a/public/app/core/components/Login/LoginForm.tsx +++ b/public/app/core/components/Login/LoginForm.tsx @@ -33,6 +33,7 @@ export const LoginForm: FC = ({ children, onSubmit, isLoggingIn, password { children: React.ReactNode; navModel: NavModel; } @@ -44,13 +44,13 @@ class Page extends Component { } render() { - const { navModel } = this.props; + const { navModel, children, ...otherProps } = this.props; return ( -
+
- {this.props.children} + {children}
diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index 7739fb5c6cc..c16019a46ab 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,9 +1,8 @@ -import React, { FormEvent } from 'react'; +import React from 'react'; import { css } from 'emotion'; import { Tab, TabsBar, Icon, IconName } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; import { NavModel, NavModelItem, NavModelBreadcrumb } from '@grafana/data'; -import { CoreEvents } from 'app/types'; +import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; export interface Props { model: NavModel; @@ -18,37 +17,29 @@ const SelectNav = ({ children, customCss }: { children: NavModelItem[]; customCs return navItem.active === true; }); - const gotoUrl = (evt: FormEvent) => { - const element = evt.target as HTMLSelectElement; - const url = element.options[element.selectedIndex].value; - appEvents.emit(CoreEvents.locationChange, { href: url }); - }; - return (
-
); }; @@ -58,14 +49,6 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => { return null; } - const goToUrl = (index: number) => { - children.forEach((child, i) => { - if (i === index) { - appEvents.emit(CoreEvents.locationChange, { href: child.url }); - } - }); - }; - return (
diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 6215c1610ac..62648e24c7b 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -12,6 +12,7 @@ import { MetricFindValue, AnnotationQueryRequest, AnnotationEvent, + DataQueryError, } from '@grafana/data'; import { v4 as uuidv4 } from 'uuid'; import InfluxSeries from './influx_series'; @@ -21,8 +22,9 @@ import { InfluxQueryBuilder } from './query_builder'; import { InfluxQuery, InfluxOptions, InfluxVersion } from './types'; import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; import { getBackendSrv, DataSourceWithBackend, frameToMetricFindValue } from '@grafana/runtime'; -import { Observable, from } from 'rxjs'; +import { Observable, throwError, of } from 'rxjs'; import { FluxQueryEditor } from './components/FluxQueryEditor'; +import { catchError, map } from 'rxjs/operators'; export default class InfluxDatasource extends DataSourceWithBackend { type: string; @@ -75,7 +77,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { + classicQuery(options: any): Observable { let timeFilter = this.getTimeFilter(options); const scopedVars = options.scopedVars; const targets = _.cloneDeep(options.targets); @@ -135,7 +137,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if (!data || !data.results) { - return []; - } - - const seriesList = []; - for (i = 0; i < data.results.length; i++) { - const result = data.results[i]; - if (!result || !result.series) { - continue; + return this._seriesQuery(allQueries, options).pipe( + map((data: any) => { + if (!data || !data.results) { + return { data: [] }; } - const target = queryTargets[i]; - let alias = target.alias; - if (alias) { - alias = this.templateSrv.replace(target.alias, options.scopedVars); - } - - const meta: QueryResultMeta = { - executedQueryString: data.executedQueryString, - }; - - const influxSeries = new InfluxSeries({ - refId: target.refId, - series: data.results[i].series, - alias: alias, - meta, - }); - - switch (target.resultFormat) { - case 'logs': - meta.preferredVisualisationType = 'logs'; - case 'table': { - seriesList.push(influxSeries.getTable()); - break; + const seriesList = []; + for (i = 0; i < data.results.length; i++) { + const result = data.results[i]; + if (!result || !result.series) { + continue; } - default: { - const timeSeries = influxSeries.getTimeSeries(); - for (y = 0; y < timeSeries.length; y++) { - seriesList.push(timeSeries[y]); + + const target = queryTargets[i]; + let alias = target.alias; + if (alias) { + alias = this.templateSrv.replace(target.alias, options.scopedVars); + } + + const meta: QueryResultMeta = { + executedQueryString: data.executedQueryString, + }; + + const influxSeries = new InfluxSeries({ + refId: target.refId, + series: data.results[i].series, + alias: alias, + meta, + }); + + switch (target.resultFormat) { + case 'logs': + meta.preferredVisualisationType = 'logs'; + case 'table': { + seriesList.push(influxSeries.getTable()); + break; + } + default: { + const timeSeries = influxSeries.getTimeSeries(); + for (y = 0; y < timeSeries.length; y++) { + seriesList.push(timeSeries[y]); + } + break; } - break; } } - } - return { data: seriesList }; - }); + return { data: seriesList }; + }) + ); } async annotationQuery(options: AnnotationQueryRequest): Promise { @@ -219,15 +223,17 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if (!data || !data.results || !data.results[0]) { - throw { message: 'No results in response from InfluxDB' }; - } - return new InfluxSeries({ - series: data.results[0].series, - annotation: options.annotation, - }).getAnnotations(); - }); + return this._seriesQuery(query, options) + .toPromise() + .then((data: any) => { + if (!data || !data.results || !data.results[0]) { + throw { message: 'No results in response from InfluxDB' }; + } + return new InfluxSeries({ + series: data.results[0].series, + annotation: options.annotation, + }).getAnnotations(); + }); } targetContainsTemplate(target: any) { @@ -268,14 +274,12 @@ export default class InfluxDatasource extends DataSourceWithBackend { - const expandedTag = { + expandedQuery.tags = query.tags.map(tag => { + return { ...tag, value: this.templateSrv.replace(tag.value, undefined, 'regex'), }; - return expandedTag; }); - expandedQuery.tags = expandedTags; } return expandedQuery; }); @@ -305,9 +309,11 @@ export default class InfluxDatasource extends DataSourceWithBackend { - return this.responseParser.parse(query, resp); - }); + return this._seriesQuery(interpolated, options) + .toPromise() + .then(resp => { + return this.responseParser.parse(query, resp); + }); } getTagKeys(options: any = {}) { @@ -324,7 +330,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { const error = _.get(res, 'results[0].error'); if (error) { @@ -459,14 +466,15 @@ export default class InfluxDatasource extends DataSourceWithBackend { + .fetch(req) + .pipe( + map((result: any) => { const { data } = result; if (data) { data.executedQueryString = q; if (data.results) { const errors = result.data.results.filter((elem: any) => elem.error); + if (errors.length > 0) { throw { message: 'InfluxDB Error: ' + errors[0].error, @@ -476,29 +484,42 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if ((Number.isInteger(err.status) && err.status !== 0) || err.status >= 300) { - if (err.data && err.data.error) { - throw { - message: 'InfluxDB Error: ' + err.data.error, - data: err.data, - config: err.config, - }; - } else { - throw { - message: 'Network Error: ' + err.statusText + '(' + err.status + ')', - data: err.data, - config: err.config, - }; - } - } else { - throw err; + }), + catchError(err => { + if (err.cancelled) { + return of(err); } - } + + return throwError(this.handleErrors(err)); + }) ); } + handleErrors(err: any) { + const error: DataQueryError = { + message: + (err && err.status) || + (err && err.message) || + 'Unknown error during query transaction. Please check JS console logs.', + }; + + if ((Number.isInteger(err.status) && err.status !== 0) || err.status >= 300) { + if (err.data && err.data.error) { + error.message = 'InfluxDB Error: ' + err.data.error; + error.data = err.data; + // @ts-ignore + error.config = err.config; + } else { + error.message = 'Network Error: ' + err.statusText + '(' + err.status + ')'; + error.data = err.data; + // @ts-ignore + error.config = err.config; + } + } + + return error; + } + getTimeFilter(options: any) { const from = this.getInfluxTime(options.rangeRaw.from, false, options.timezone); const until = this.getInfluxTime(options.rangeRaw.to, true, options.timezone); diff --git a/public/app/plugins/datasource/influxdb/specs/datasource.test.ts b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts index 2ae22a67eeb..d42c54b0421 100644 --- a/public/app/plugins/datasource/influxdb/specs/datasource.test.ts +++ b/public/app/plugins/datasource/influxdb/specs/datasource.test.ts @@ -2,6 +2,8 @@ import InfluxDatasource from '../datasource'; import { TemplateSrvStub } from 'test/specs/helpers'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { of } from 'rxjs'; +import { FetchResponse } from '@grafana/runtime'; //@ts-ignore const templateSrv = new TemplateSrvStub(); @@ -16,7 +18,7 @@ describe('InfluxDataSource', () => { instanceSettings: { url: 'url', name: 'influxDb', jsonData: { httpMode: 'GET' } }, }; - const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); + const fetchMock = jest.spyOn(backendSrv, 'fetch'); beforeEach(() => { jest.clearAllMocks(); @@ -35,12 +37,13 @@ describe('InfluxDataSource', () => { let requestQuery: any, requestMethod: any, requestData: any, response: any; beforeEach(async () => { - datasourceRequestMock.mockImplementation((req: any) => { + fetchMock.mockImplementation((req: any) => { requestMethod = req.method; requestQuery = req.params.q; requestData = req.data; - return Promise.resolve({ + return of({ data: { + status: 'success', results: [ { series: [ @@ -53,7 +56,7 @@ describe('InfluxDataSource', () => { }, ], }, - }); + } as FetchResponse); }); response = await ctx.ds.metricFindQuery(query, queryOptions); @@ -96,8 +99,8 @@ describe('InfluxDataSource', () => { }; it('throws an error', async () => { - datasourceRequestMock.mockImplementation((req: any) => { - return Promise.resolve({ + fetchMock.mockImplementation((req: any) => { + return of({ data: { results: [ { @@ -105,7 +108,7 @@ describe('InfluxDataSource', () => { }, ], }, - }); + } as FetchResponse); }); try { @@ -132,23 +135,25 @@ describe('InfluxDataSource', () => { let requestMethod: any, requestQueryParameter: any, queryEncoded: any, requestQuery: any; beforeEach(async () => { - datasourceRequestMock.mockImplementation((req: any) => { + fetchMock.mockImplementation((req: any) => { requestMethod = req.method; requestQueryParameter = req.params; requestQuery = req.data; - return Promise.resolve({ - results: [ - { - series: [ - { - name: 'measurement', - columns: ['max'], - values: [[1]], - }, - ], - }, - ], - }); + return of({ + data: { + results: [ + { + series: [ + { + name: 'measurement', + columns: ['max'], + values: [[1]], + }, + ], + }, + ], + }, + } as FetchResponse); }); queryEncoded = await ctx.ds.serializeParams({ q: query }); diff --git a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx index 56812dc719f..a7974a2ab5d 100644 --- a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx +++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx @@ -8,6 +8,12 @@ const PREFERRED_LABELS = ['job', 'app', 'k8s_app']; const EXAMPLES_LIMIT = 5; const LOGQL_EXAMPLES = [ + { + title: 'Log pipeline', + expression: '{job="mysql"} |= "metrics" | logfmt | duration > 10s', + label: + 'This query targets the MySQL job, filters out logs that don’t contain the word "metrics" and parses each log line to extract more labels and filters with them.', + }, { title: 'Count over time', expression: 'count_over_time({job="mysql"}[5m])', diff --git a/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx index c6208230ed3..27ea21b71bc 100644 --- a/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx +++ b/public/app/plugins/datasource/loki/configuration/DerivedFields.tsx @@ -33,7 +33,7 @@ export const DerivedFields = (props: Props) => {

Derived fields

- Derived fields can be used to extract new fields from the log message and create link from it's value. + Derived fields can be used to extract new fields from a log message and create a link from its value.
diff --git a/public/app/plugins/datasource/mssql/response_parser.ts b/public/app/plugins/datasource/mssql/response_parser.ts index d56ef88d0ff..ca456ebc9bf 100644 --- a/public/app/plugins/datasource/mssql/response_parser.ts +++ b/public/app/plugins/datasource/mssql/response_parser.ts @@ -69,14 +69,13 @@ export default class ResponseParser { for (let i = 0; i < rows.length; i++) { for (let j = 0; j < rows[i].length; j++) { - const value = rows[i][j]; - if (res.indexOf(value) === -1) { - res.push(value); - } + res.push(rows[i][j]); } } - return _.map(res, value => { + const unique = Array.from(new Set(res)); + + return _.map(unique, value => { return { text: value }; }); } diff --git a/public/app/plugins/datasource/mysql/response_parser.ts b/public/app/plugins/datasource/mysql/response_parser.ts index 48c4d3a5b87..b3bb6a1aa99 100644 --- a/public/app/plugins/datasource/mysql/response_parser.ts +++ b/public/app/plugins/datasource/mysql/response_parser.ts @@ -90,14 +90,13 @@ export default class ResponseParser { for (let i = 0; i < rows.length; i++) { for (let j = 0; j < rows[i].length; j++) { - const value = rows[i][j]; - if (res.indexOf(value) === -1) { - res.push(value); - } + res.push(rows[i][j]); } } - return _.map(res, value => { + const unique = Array.from(new Set(res)); + + return _.map(unique, value => { return { text: value }; }); } diff --git a/public/app/plugins/datasource/postgres/response_parser.ts b/public/app/plugins/datasource/postgres/response_parser.ts index f4e3485a7ca..79401560f22 100644 --- a/public/app/plugins/datasource/postgres/response_parser.ts +++ b/public/app/plugins/datasource/postgres/response_parser.ts @@ -72,14 +72,13 @@ export default class ResponseParser { for (let i = 0; i < rows.length; i++) { for (let j = 0; j < rows[i].length; j++) { - const value = rows[i][j]; - if (res.indexOf(value) === -1) { - res.push(value); - } + res.push(rows[i][j]); } } - return _.map(res, value => { + const unique = Array.from(new Set(res)); + + return _.map(unique, value => { return { text: value }; }); } diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx index bbd835ee458..3bc79ea44d0 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx @@ -3,7 +3,7 @@ import RCCascader from 'rc-cascader'; import React from 'react'; import PromQlLanguageProvider, { DEFAULT_LOOKUP_METRICS_THRESHOLD } from '../language_provider'; import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField'; -import { DataSourceInstanceSettings } from '@grafana/data'; +import { DataSourceInstanceSettings, dateTime } from '@grafana/data'; import { PromOptions } from '../types'; import { fireEvent, render, screen } from '@testing-library/react'; @@ -51,61 +51,89 @@ describe('PromQueryField', () => { }); it('refreshes metrics when the data source changes', async () => { + const defaultProps = { + query: { expr: '', refId: '' }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; const metrics = ['foo', 'bar']; - const languageProvider = ({ - histogramMetrics: [] as any, - metrics, - metricsMetadata: {}, - lookupsDisabled: false, - lookupMetricsThreshold: DEFAULT_LOOKUP_METRICS_THRESHOLD, - start: () => { - return Promise.resolve([]); - }, - } as unknown) as PromQlLanguageProvider; - const queryField = render( {}} - onChange={() => {}} - history={[]} + {...defaultProps} /> ); - let cascader = await queryField.findByRole('button'); - fireEvent.keyDown(cascader, { keyCode: 40 }); - let listNodes = screen.getAllByRole('menuitem'); - for (const node of listNodes) { - expect(metrics).toContain(node.innerHTML); - } + checkMetricsInCascader(await screen.findByRole('button'), metrics); const changedMetrics = ['baz', 'moo']; queryField.rerender( {}} - onChange={() => {}} - history={[]} + {...defaultProps} /> ); - cascader = await queryField.findByRole('button'); - fireEvent.keyDown(cascader, { keyCode: 40 }); - listNodes = screen.getAllByRole('menuitem'); - for (const node of listNodes) { - expect(changedMetrics).toContain(node.innerHTML); - } + // If we check the cascader right away it should be in loading state + let cascader = screen.getByRole('button'); + expect(cascader.textContent).toContain('Loading'); + checkMetricsInCascader(await screen.findByRole('button'), changedMetrics); + }); + + it('refreshes metrics when time range changes but dont show loading state', async () => { + const defaultProps = { + query: { expr: '', refId: '' }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + const metrics = ['foo', 'bar']; + const changedMetrics = ['baz', 'moo']; + const range = { + from: dateTime('2020-10-28T00:00:00Z'), + to: dateTime('2020-10-28T01:00:00Z'), + }; + + const languageProvider = makeLanguageProvider({ metrics: [metrics, changedMetrics] }); + const queryField = render( + + ); + checkMetricsInCascader(await screen.findByRole('button'), metrics); + + const newRange = { + from: dateTime('2020-10-28T01:00:00Z'), + to: dateTime('2020-10-28T02:00:00Z'), + }; + queryField.rerender( + + ); + let cascader = screen.getByRole('button'); + // Should not show loading + expect(cascader.textContent).toContain('Metrics'); + checkMetricsInCascader(cascader, metrics); }); }); @@ -162,3 +190,26 @@ describe('groupMetricsByPrefix()', () => { ]); }); }); + +function makeLanguageProvider(options: { metrics: string[][] }) { + const metricsStack = [...options.metrics]; + return ({ + histogramMetrics: [] as any, + metrics: [], + metricsMetadata: {}, + lookupsDisabled: false, + lookupMetricsThreshold: DEFAULT_LOOKUP_METRICS_THRESHOLD, + start() { + this.metrics = metricsStack.shift(); + return Promise.resolve([]); + }, + } as any) as PromQlLanguageProvider; +} + +function checkMetricsInCascader(cascader: HTMLElement, metrics: string[]) { + fireEvent.keyDown(cascader, { keyCode: 40 }); + let listNodes = screen.getAllByRole('menuitem'); + for (const node of listNodes) { + expect(metrics).toContain(node.innerHTML); + } +} diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index ef774adb034..b94322277a6 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -17,14 +17,7 @@ import Prism from 'prismjs'; // dom also includes Element polyfills import { PromQuery, PromOptions, PromMetricsMetadata } from '../types'; import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; -import { - ExploreQueryFieldProps, - QueryHint, - isDataFrame, - toLegacyResponseData, - HistoryItem, - AbsoluteTimeRange, -} from '@grafana/data'; +import { ExploreQueryFieldProps, QueryHint, isDataFrame, toLegacyResponseData, HistoryItem } from '@grafana/data'; import { DOMUtil, SuggestionsState } from '@grafana/ui'; import { PrometheusDatasource } from '../datasource'; @@ -173,21 +166,27 @@ class PromQueryField extends React.PureComponent 0 ? hints[0] : null; + let hint = hints.length > 0 ? hints[0] : null; + + // Hint for big disabled lookups + if (!hint && !datasource.lookupsDisabled && datasource.languageProvider.lookupsDisabled) { + hint = { + label: `Dynamic label lookup is disabled for datasources with more than ${datasource.languageProvider.lookupMetricsThreshold} metrics.`, + type: 'INFO', + }; + } this.setState({ hint }); }; - refreshMetrics = () => { + refreshMetrics = async () => { const { datasource: { languageProvider }, } = this.props; - this.setState({ - syntaxLoaded: false, - }); - Prism.languages[PRISM_SYNTAX] = languageProvider.syntax; this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start()); - this.languageProviderInitializationPromise.promise - .then(remaining => { - remaining.map((task: Promise) => task.then(this.onUpdateLanguage).catch(() => {})); - }) - .then(() => this.onUpdateLanguage()) - .catch(err => { - if (!err.isCanceled) { - throw err; - } - }); + + try { + const remainingTasks = await this.languageProviderInitializationPromise.promise; + await Promise.all(remainingTasks); + this.onUpdateLanguage(); + } catch (err) { + if (!err.isCanceled) { + throw err; + } + } }; onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => { @@ -278,10 +281,9 @@ class PromQueryField extends React.PureComponent { const { - datasource, datasource: { languageProvider }, } = this.props; - const { histogramMetrics, metrics, metricsMetadata, lookupMetricsThreshold } = languageProvider; + const { histogramMetrics, metrics, metricsMetadata } = languageProvider; if (!metrics) { return; @@ -298,17 +300,7 @@ class PromQueryField extends React.PureComponent => { @@ -328,8 +320,6 @@ class PromQueryField extends React.PureComponent { }); describe('When the response is a matrix', () => { + it('should have labels with the value field', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [ + { + metric: { __name__: 'test', job: 'testjob', instance: 'testinstance' }, + values: [ + [0, '10'], + [1, '10'], + [2, '0'], + ], + }, + ], + }, + }; + + const result: DataFrame[] = transform({ data: response } as any, { + ...options, + }); + + expect(result[0].fields[1].labels).toBeDefined(); + expect(result[0].fields[1].labels?.instance).toBe('testinstance'); + expect(result[0].fields[1].labels?.job).toBe('testjob'); + }); + it('should transform into a data frame', () => { const response = { status: 'success', diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index dd93ff8587f..580b4fcb639 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -4,6 +4,7 @@ import { Field, FieldType, formatLabels, + Labels, MutableField, ScopedVars, TIME_SERIES_TIME_FIELD_NAME, @@ -71,7 +72,7 @@ export function transform( meta: options.meta, refId: options.refId, length: 1, - fields: [getTimeField([prometheusResult.result]), getValueField([prometheusResult.result])], + fields: [getTimeField([prometheusResult.result]), getValueField({ data: [prometheusResult.result] })], }, ]; } @@ -109,7 +110,7 @@ function getPreferredVisualisationType(isInstantQuery?: boolean, mixedQueries?: * Transforms matrix and vector result from Prometheus result to DataFrame */ function transformToDataFrame(data: MatrixOrVectorResult, options: TransformOptions): DataFrame { - const { name } = createLabelInfo(data.metric, options); + const { name, labels } = createLabelInfo(data.metric, options); const fields: Field[] = []; @@ -138,10 +139,10 @@ function transformToDataFrame(data: MatrixOrVectorResult, options: TransformOpti dps.push([t, null]); } fields.push(getTimeField(dps, true)); - fields.push(getValueField(dps, undefined, false)); + fields.push(getValueField({ data: dps, parseValue: false, labels, displayName: name })); } else { fields.push(getTimeField([data.value])); - fields.push(getValueField([data.value])); + fields.push(getValueField({ data: [data.value], labels, displayName: name })); } return { @@ -176,7 +177,7 @@ function transformMetricDataToTable(md: MatrixOrVectorResult[], options: Transfo values: new ArrayVector(), }; }); - const valueField = getValueField([], valueText); + const valueField = getValueField({ data: [], valueName: valueText }); md.forEach(d => { if (isMatrixData(d)) { @@ -218,16 +219,28 @@ function getTimeField(data: PromValue[], isMs = false): MutableField { values: new ArrayVector(data.map(val => (isMs ? val[0] : val[0] * 1000))), }; } +type ValueFieldOptions = { + data: PromValue[]; + valueName?: string; + parseValue?: boolean; + labels?: Labels; + displayName?: string; +}; -function getValueField( - data: PromValue[], - valueName: string = TIME_SERIES_VALUE_FIELD_NAME, - parseValue = true -): MutableField { +function getValueField({ + data, + valueName = TIME_SERIES_VALUE_FIELD_NAME, + parseValue = true, + labels, + displayName, +}: ValueFieldOptions): MutableField { return { name: valueName, type: FieldType.number, - config: {}, + config: { + displayName, + }, + labels, values: new ArrayVector(data.map(val => (parseValue ? parseSampleValue(val[1]) : val[1]))), }; } diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 016725658c5..3bd6dac2dd0 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -79,13 +79,12 @@ export class TempoDatasource extends DataSourceApi { try { await this._request(`/api/traces/random`).toPromise(); } catch (e) { - // As we are not searching for a valid trace here this will definitely fail but we should return 502 if it's - // unreachable. 500 should otherwise be from tempo it self but probably makes sense to report them here. - if (e?.status >= 500 && e?.status < 600) { + // If all went well this request will get back with 400 - Bad request + if (e?.status !== 400) { throw e; } } - return true; + return { status: 'success', message: 'Data source is working' }; } getTimeRange(): { start: number; end: number } { diff --git a/public/app/plugins/datasource/testdata/runStreams.ts b/public/app/plugins/datasource/testdata/runStreams.ts index 80f78b7eee5..55f0e8b0c10 100644 --- a/public/app/plugins/datasource/testdata/runStreams.ts +++ b/public/app/plugins/datasource/testdata/runStreams.ts @@ -98,6 +98,7 @@ export function runSignalStream( subscriber.next({ data: [data], key: streamId, + state: LoadingState.Streaming, }); timeoutId = setTimeout(pushNextEvent, speed); diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx index 63342c6b4aa..631b4295610 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -266,7 +266,7 @@ export class AnnoListPanel extends PureComponent { // Previously we showed inidication that it covered all time // { timeInfo && ( // - // {timeInfo} + // {timeInfo} // // )} diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx index 1597ecc456a..97799f1b078 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.test.tsx @@ -97,6 +97,7 @@ function createBarGaugePanelWithData(data: PanelData): ReactWrapper ); } diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 96d5287ec99..cec92e6b90c 100644 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -11,7 +11,7 @@ import './jquery.flot.events'; import $ from 'jquery'; import _ from 'lodash'; import { tickStep } from 'app/core/utils/ticks'; -import { appEvents, coreModule, updateLegendValues } from 'app/core/core'; +import { coreModule, updateLegendValues } from 'app/core/core'; import GraphTooltip from './graph_tooltip'; import { ThresholdManager } from './threshold_manager'; import { TimeRegionManager } from './time_region_manager'; @@ -37,6 +37,8 @@ import { getTimeField, getValueFormat, hasLinks, + LegacyGraphHoverClearEvent, + LegacyGraphHoverEvent, LinkModelSupplier, PanelEvents, toUtc, @@ -45,7 +47,6 @@ import { GraphContextMenuCtrl } from './GraphContextMenuCtrl'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { ContextSrv } from 'app/core/services/context_srv'; import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; -import { CoreEvents } from 'app/types'; const LegendWithThemeProvider = provideTheme(Legend); @@ -86,8 +87,11 @@ class GraphElement { this.ctrl.events.on(PanelEvents.render, this.onRender.bind(this)); // global events - appEvents.on(CoreEvents.graphHover, this.onGraphHover.bind(this), scope); - appEvents.on(CoreEvents.graphHoverClear, this.onGraphHoverClear.bind(this), scope); + // Using old way here to use the scope unsubscribe model as the new $on function does not take scope + this.ctrl.dashboard.events.on(LegacyGraphHoverEvent.type, this.onGraphHover.bind(this), this.scope); + this.ctrl.dashboard.events.on(LegacyGraphHoverClearEvent.type, this.onGraphHoverClear.bind(this), this.scope); + + // plot events this.elem.bind('plotselected', this.onPlotSelected.bind(this)); this.elem.bind('plotclick', this.onPlotClick.bind(this)); diff --git a/public/app/plugins/panel/graph/graph_tooltip.ts b/public/app/plugins/panel/graph/graph_tooltip.ts index 00e2b43ceeb..805cf12d0cb 100644 --- a/public/app/plugins/panel/graph/graph_tooltip.ts +++ b/public/app/plugins/panel/graph/graph_tooltip.ts @@ -1,7 +1,7 @@ import $ from 'jquery'; import { appEvents } from 'app/core/core'; import { CoreEvents } from 'app/types'; -import { textUtil, systemDateFormats } from '@grafana/data'; +import { textUtil, systemDateFormats, LegacyGraphHoverClearEvent, LegacyGraphHoverEvent } from '@grafana/data'; export default function GraphTooltip(this: any, elem: any, dashboard: any, scope: any, getSeriesFn: any) { const self = this; @@ -157,7 +157,7 @@ export default function GraphTooltip(this: any, elem: any, dashboard: any, scope plot.unhighlight(); } } - appEvents.emit(CoreEvents.graphHoverClear); + dashboard.events.publish(new LegacyGraphHoverClearEvent()); }); elem.bind('plothover', (event: any, pos: { panelRelY: number; pageY: number }, item: any) => { @@ -165,7 +165,7 @@ export default function GraphTooltip(this: any, elem: any, dashboard: any, scope // broadcast to other graph panels that we are hovering! pos.panelRelY = (pos.pageY - elem.offset().top) / elem.height(); - appEvents.emit(CoreEvents.graphHover, { pos: pos, panel: panel }); + dashboard.events.publish(new LegacyGraphHoverEvent({ pos: pos, panel: panel })); }); elem.bind('plotclick', (event: any, pos: any, item: any) => { diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 35b20f494da..195c95d55cb 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -12,11 +12,11 @@ import { axesEditorComponent } from './axes_editor'; import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import { getProcessedDataFrames } from 'app/features/dashboard/state/runRequest'; -import { PanelEvents, PanelPlugin, DataFrame, FieldConfigProperty, getColorForTheme } from '@grafana/data'; +import { DataFrame, FieldConfigProperty, getColorForTheme, PanelEvents, PanelPlugin } from '@grafana/data'; import { GraphContextMenuCtrl } from './GraphContextMenuCtrl'; import { graphPanelMigrationHandler } from './GraphMigrations'; -import { DataWarning, GraphPanelOptions, GraphFieldConfig } from './types'; +import { DataWarning, GraphFieldConfig, GraphPanelOptions } from './types'; import { auto } from 'angular'; import { AnnotationsSrv } from 'app/features/annotations/all'; @@ -388,10 +388,14 @@ export class GraphCtrl extends MetricsPanelCtrl { // Use new react style configuration export const plugin = new PanelPlugin(null) .useFieldConfig({ - standardOptions: [ - FieldConfigProperty.DisplayName, - FieldConfigProperty.Unit, - FieldConfigProperty.Links, // previously saved as dataLinks on options + disableStandardOptions: [ + FieldConfigProperty.NoValue, + FieldConfigProperty.Thresholds, + FieldConfigProperty.Max, + FieldConfigProperty.Min, + FieldConfigProperty.Decimals, + FieldConfigProperty.Color, + FieldConfigProperty.Mappings, ], }) .setMigrationHandler(graphPanelMigrationHandler); diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index f39422b0c26..6777a549782 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -20,13 +20,12 @@ import '../module'; import { GraphCtrl } from '../module'; import { MetricsPanelCtrl } from 'app/features/panel/metrics_panel_ctrl'; import { PanelCtrl } from 'app/features/panel/panel_ctrl'; - import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import $ from 'jquery'; import { graphDirective } from '../graph'; -import { dateTime } from '@grafana/data'; +import { dateTime, EventBusSrv } from '@grafana/data'; const ctx = {} as any; let ctrl: any; @@ -84,6 +83,7 @@ describe('grafanaGraph', () => { hiddenSeries: {}, dashboard: { getTimezone: () => 'browser', + events: new EventBusSrv(), }, range: { from: dateTime([2015, 1, 1, 10]), diff --git a/public/app/plugins/panel/graph2/module.tsx b/public/app/plugins/panel/graph2/module.tsx index 5e2063650a7..1eb3400183d 100644 --- a/public/app/plugins/panel/graph2/module.tsx +++ b/public/app/plugins/panel/graph2/module.tsx @@ -1,63 +1,61 @@ -import { FieldConfigProperty, PanelPlugin } from '@grafana/data'; +import { PanelPlugin } from '@grafana/data'; import { GraphPanel } from './GraphPanel'; import { Options } from './types'; -export const plugin = new PanelPlugin(GraphPanel) - .useFieldConfig({ standardOptions: [FieldConfigProperty.Unit, FieldConfigProperty.Decimals] }) - .setPanelOptions(builder => { - builder - .addBooleanSwitch({ - path: 'graph.showBars', - name: 'Show bars', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'graph.showLines', - name: 'Show lines', - description: '', - defaultValue: true, - }) - .addBooleanSwitch({ - path: 'graph.showPoints', - name: 'Show poins', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'legend.isVisible', - name: 'Show legend', - description: '', - defaultValue: true, - }) - .addBooleanSwitch({ - path: 'legend.asTable', - name: 'Display legend as table', - description: '', - defaultValue: false, - }) - .addRadio({ - path: 'legend.placement', - name: 'Legend placement', - description: '', - defaultValue: 'under', - settings: { - options: [ - { value: 'under', label: 'Below graph' }, - { value: 'right', label: 'Right to the graph' }, - ], - }, - }) - .addRadio({ - path: 'tooltipOptions.mode', - name: 'Tooltip mode', - description: '', - defaultValue: 'single', - settings: { - options: [ - { value: 'single', label: 'Single series' }, - { value: 'multi', label: 'All series' }, - ], - }, - }); - }); +export const plugin = new PanelPlugin(GraphPanel).useFieldConfig().setPanelOptions(builder => { + builder + .addBooleanSwitch({ + path: 'graph.showBars', + name: 'Show bars', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'graph.showLines', + name: 'Show lines', + description: '', + defaultValue: true, + }) + .addBooleanSwitch({ + path: 'graph.showPoints', + name: 'Show poins', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'legend.isVisible', + name: 'Show legend', + description: '', + defaultValue: true, + }) + .addBooleanSwitch({ + path: 'legend.asTable', + name: 'Display legend as table', + description: '', + defaultValue: false, + }) + .addRadio({ + path: 'legend.placement', + name: 'Legend placement', + description: '', + defaultValue: 'under', + settings: { + options: [ + { value: 'under', label: 'Below graph' }, + { value: 'right', label: 'Right to the graph' }, + ], + }, + }) + .addRadio({ + path: 'tooltipOptions.mode', + name: 'Tooltip mode', + description: '', + defaultValue: 'single', + settings: { + options: [ + { value: 'single', label: 'Single series' }, + { value: 'multi', label: 'All series' }, + ], + }, + }); +}); diff --git a/public/app/plugins/panel/graph3/module.tsx b/public/app/plugins/panel/graph3/module.tsx index 9cb4d46c670..8a92941c336 100644 --- a/public/app/plugins/panel/graph3/module.tsx +++ b/public/app/plugins/panel/graph3/module.tsx @@ -1,21 +1,20 @@ -import { FieldConfigProperty, PanelPlugin } from '@grafana/data'; +import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data'; import { AxisSide, GraphCustomFieldConfig } from '@grafana/ui'; import { GraphPanel } from './GraphPanel'; import { Options } from './types'; export const plugin = new PanelPlugin(GraphPanel) .useFieldConfig({ - standardOptions: [ - // FieldConfigProperty.Min, - // FieldConfigProperty.Max, - FieldConfigProperty.Color, - FieldConfigProperty.Unit, - FieldConfigProperty.DisplayName, - FieldConfigProperty.Decimals, - // NOT: FieldConfigProperty.Thresholds, - FieldConfigProperty.Mappings, - ], - + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: false, + }, + defaultValue: { + mode: FieldColorModeId.PaletteClassic, + }, + }, + }, useCustomConfig: builder => { builder .addBooleanSwitch({ @@ -24,23 +23,14 @@ export const plugin = new PanelPlugin(GraphPane description: '', defaultValue: true, }) - .addSelect({ + .addSliderInput({ path: 'line.width', name: 'Line width', defaultValue: 1, settings: { - options: [ - { value: 1, label: '1 • thin' }, - { value: 2, label: '2' }, - { value: 3, label: '3' }, - { value: 4, label: '4' }, - { value: 5, label: '5' }, - { value: 6, label: '6' }, - { value: 7, label: '7' }, - { value: 8, label: '8' }, - { value: 9, label: '9' }, - { value: 10, label: '10 • thick' }, - ], + min: 1, + max: 10, + step: 1, }, showIf: c => { return c.line.show; @@ -52,23 +42,14 @@ export const plugin = new PanelPlugin(GraphPane description: '', defaultValue: false, }) - .addSelect({ + .addSliderInput({ path: 'points.radius', name: 'Point radius', defaultValue: 4, settings: { - options: [ - { value: 1, label: '1 • thin' }, - { value: 2, label: '2' }, - { value: 3, label: '3' }, - { value: 4, label: '4' }, - { value: 5, label: '5' }, - { value: 6, label: '6' }, - { value: 7, label: '7' }, - { value: 8, label: '8' }, - { value: 9, label: '9' }, - { value: 10, label: '10 • thick' }, - ], + min: 1, + max: 10, + step: 1, }, showIf: c => c.points.show, }) @@ -78,24 +59,14 @@ export const plugin = new PanelPlugin(GraphPane description: '', defaultValue: false, }) - .addSelect({ + .addSliderInput({ path: 'fill.alpha', name: 'Fill area opacity', defaultValue: 0.1, settings: { - options: [ - { value: 0, label: 'No Fill' }, - { value: 0.1, label: '10% • transparent' }, - { value: 0.2, label: '20%' }, - { value: 0.3, label: '30%' }, - { value: 0.4, label: '40% ' }, - { value: 0.5, label: '50%' }, - { value: 0.6, label: '60%' }, - { value: 0.7, label: '70%' }, - { value: 0.8, label: '80%' }, - { value: 0.9, label: '90%' }, - { value: 1, label: '100% • opaque' }, - ], + min: 0, + max: 1, + step: 0.1, }, }) .addTextInput({ diff --git a/public/app/plugins/panel/heatmap/rendering.ts b/public/app/plugins/panel/heatmap/rendering.ts index 7c7fe30b929..1a4801c580c 100644 --- a/public/app/plugins/panel/heatmap/rendering.ts +++ b/public/app/plugins/panel/heatmap/rendering.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import $ from 'jquery'; import * as d3 from 'd3'; -import { appEvents, contextSrv } from 'app/core/core'; +import { contextSrv } from 'app/core/core'; import * as ticksUtils from 'app/core/utils/ticks'; import { HeatmapTooltip } from './heatmap_tooltip'; import { mergeZeroBuckets } from './heatmap_data_converter'; @@ -12,10 +12,11 @@ import { getValueFormat, formattedValueToString, dateTimeFormat, + LegacyGraphHoverEvent, + LegacyGraphHoverClearEvent, getColorForTheme, } from '@grafana/data'; import { graphTimeFormat } from '@grafana/ui'; -import { CoreEvents } from 'app/types'; import { config } from 'app/core/config'; const MIN_CARD_SIZE = 1, @@ -84,9 +85,8 @@ export class HeatmapRenderer { ///////////////////////////// // Shared crosshair and tooltip - appEvents.on(CoreEvents.graphHover, this.onGraphHover.bind(this), this.scope); - - appEvents.on(CoreEvents.graphHoverClear, this.onGraphHoverClear.bind(this), this.scope); + this.ctrl.dashboard.events.on(LegacyGraphHoverEvent.type, this.onGraphHover.bind(this), this.scope); + this.ctrl.dashboard.events.on(LegacyGraphHoverClearEvent.type, this.onGraphHoverClear.bind(this), this.scope); // Register selection listeners this.$heatmap.on('mousedown', this.onMouseDown.bind(this)); @@ -735,7 +735,7 @@ export class HeatmapRenderer { } onMouseLeave() { - appEvents.emit(CoreEvents.graphHoverClear); + this.ctrl.dashboard.$emit(new LegacyGraphHoverClearEvent()); this.clearCrosshair(); } @@ -781,7 +781,7 @@ export class HeatmapRenderer { // Set minimum offset to prevent showing legend from another panel pos.panelRelY = Math.max(pos.offset.y / this.height, 0.001); // broadcast to other graph panels that we are hovering - appEvents.emit(CoreEvents.graphHover, { pos: pos, panel: this.panel }); + this.ctrl.dashboard.events.emit$(new LegacyGraphHoverEvent({ pos: pos, panel: this.panel })); } limitSelection(x2: number) { diff --git a/public/app/plugins/panel/homelinks/img/news.svg b/public/app/plugins/panel/homelinks/img/news.svg deleted file mode 100644 index b1d737bdeb0..00000000000 --- a/public/app/plugins/panel/homelinks/img/news.svg +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/public/app/plugins/panel/homelinks/module.tsx b/public/app/plugins/panel/homelinks/module.tsx deleted file mode 100755 index 04059dd2535..00000000000 --- a/public/app/plugins/panel/homelinks/module.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// Libraries -import React, { PureComponent, FC } from 'react'; -import { css } from 'emotion'; - -// Utils & Services -import { PanelPlugin } from '@grafana/data'; -import { stylesFactory, styleMixins, Icon, IconName } from '@grafana/ui'; -import config from 'app/core/config'; - -// Types -import { PanelProps } from '@grafana/data'; - -export interface Props extends PanelProps {} - -export class GrafanaLinksPanel extends PureComponent { - render() { - const styles = getStyles(); - - return ( - <> -
- - - - -
- - - ); - } -} - -interface HomeLinkProps { - title: string; - url: string; - target?: string; - icon: IconName; -} - -export const HomeLink: FC = ({ title, url, target, icon }) => { - const styles = getStyles(); - - return ( - - - {title} - - ); -}; - -export const VersionFooter: FC = () => { - const styles = getStyles(); - const { hideVersion, version, commit } = config.buildInfo; - const versionString = hideVersion ? '' : `Version ${version} (${commit})`; - - return
{versionString}
; -}; - -export const getStyles = stylesFactory(() => { - const { theme } = config; - - return { - list: css` - display: flex; - flex-direction: column; - padding: ${theme.spacing.md}; - `, - icon: css` - margin-right: ${theme.spacing.sm}; - `, - footer: css` - background: ${theme.colors.bg2}; - font-size: ${theme.typography.size.sm}; - position: absolute; - bottom: 0; - width: 100%; - text-align: center; - padding: ${theme.spacing.sm}; - border-radius: 0; - box-shadow: none; - color: ${theme.colors.textWeak}; - `, - item: css` - ${styleMixins.listItem(theme)} - padding: ${theme.spacing.sm}; - display: flex; - margin-bottom: ${theme.spacing.xs}; - align-items: center; - `, - }; -}); - -export const plugin = new PanelPlugin(GrafanaLinksPanel).setNoPadding(); diff --git a/public/app/plugins/panel/homelinks/plugin.json b/public/app/plugins/panel/homelinks/plugin.json deleted file mode 100755 index 6d7b8769bc0..00000000000 --- a/public/app/plugins/panel/homelinks/plugin.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "panel", - "name": "Home links", - "id": "homelinks", - - "hideFromList": true, - "skipDataQuery": true, - - "info": { - "author": { - "name": "Grafana Labs", - "url": "https://grafana.com" - }, - "logos": { - "small": "img/news.svg", - "large": "img/news.svg" - } - } -} diff --git a/public/app/plugins/panel/live/LivePanel.tsx b/public/app/plugins/panel/live/LivePanel.tsx index 0e358275367..2567140735a 100755 --- a/public/app/plugins/panel/live/LivePanel.tsx +++ b/public/app/plugins/panel/live/LivePanel.tsx @@ -19,7 +19,7 @@ import { TablePanel } from '../table/TablePanel'; import { LivePanelOptions, MessageDisplayMode } from './types'; import { config, getGrafanaLiveSrv, MeasurementCollector } from '@grafana/runtime'; import { css, cx } from 'emotion'; -import CodeEditor from '@grafana/ui/src/components/Monaco/CodeEditor'; +import { CodeEditor } from '@grafana/ui'; interface Props extends PanelProps {} diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 9b13f5fd77d..2abd1acfb96 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -68,6 +68,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { { value: 'first', text: 'First' }, { value: 'delta', text: 'Delta' }, { value: 'diff', text: 'Difference' }, + { value: 'diffperc', text: 'Difference percent' }, { value: 'range', text: 'Range' }, { value: 'last_time', text: 'Time of last point' }, ]; diff --git a/public/app/plugins/panel/stat/module.tsx b/public/app/plugins/panel/stat/module.tsx index 873af89a110..73f57b2aacd 100644 --- a/public/app/plugins/panel/stat/module.tsx +++ b/public/app/plugins/panel/stat/module.tsx @@ -1,6 +1,6 @@ -import { sharedSingleStatMigrationHandler, BigValueTextMode } from '@grafana/ui'; +import { BigValueTextMode, sharedSingleStatMigrationHandler } from '@grafana/ui'; import { PanelPlugin } from '@grafana/data'; -import { StatPanelOptions, addStandardDataReduceOptions } from './types'; +import { addStandardDataReduceOptions, StatPanelOptions } from './types'; import { StatPanel } from './StatPanel'; import { statPanelChangedHandler } from './StatMigrations'; diff --git a/public/app/plugins/panel/text/TextPanel.tsx b/public/app/plugins/panel/text/TextPanel.tsx index 5a7d374bdbe..de472f1c62c 100644 --- a/public/app/plugins/panel/text/TextPanel.tsx +++ b/public/app/plugins/panel/text/TextPanel.tsx @@ -43,7 +43,8 @@ export class TextPanel extends PureComponent { } prepareMarkdown(content: string): string { - return renderMarkdown(this.interpolateAndSanitizeString(content)); + // Sanitize is disabled here as we handle that after variable interpolation + return renderMarkdown(this.interpolateAndSanitizeString(content), { noSanitize: config.disableSanitizeHtml }); } interpolateAndSanitizeString(content: string): string { diff --git a/public/app/plugins/panel/text/textPanelMigrationHandler.test.ts b/public/app/plugins/panel/text/textPanelMigrationHandler.test.ts index e61c01178c2..009443a6caa 100644 --- a/public/app/plugins/panel/text/textPanelMigrationHandler.test.ts +++ b/public/app/plugins/panel/text/textPanelMigrationHandler.test.ts @@ -8,12 +8,30 @@ describe('textPanelMigrationHandler', () => { const panel: any = { content: 'Hello World', mode: 'html', + options: {}, }; const result = textPanelMigrationHandler(panel); expect(result.content).toEqual('Hello World'); expect(result.mode).toEqual('html'); + expect(panel.content).toBeUndefined(); + expect(panel.mode).toBeUndefined(); + }); + }); + + describe('when invoked and previous version 7.1 or later', () => { + it('then not migrate options', () => { + const panel: any = { + content: 'Hello World', + mode: 'html', + options: { content: 'New content' }, + pluginVersion: '7.1.0', + }; + + const result = textPanelMigrationHandler(panel); + + expect(result.content).toEqual('New content'); }); }); diff --git a/public/app/plugins/panel/text/textPanelMigrationHandler.ts b/public/app/plugins/panel/text/textPanelMigrationHandler.ts index eb30abd086f..7c87f5fd1fe 100644 --- a/public/app/plugins/panel/text/textPanelMigrationHandler.ts +++ b/public/app/plugins/panel/text/textPanelMigrationHandler.ts @@ -2,19 +2,27 @@ import { PanelModel } from '@grafana/data'; import { TextMode, TextOptions } from './types'; export const textPanelMigrationHandler = (panel: PanelModel): Partial => { + const previousVersion = parseFloat(panel.pluginVersion || '6.1'); + let options = panel.options; + // Migrates old Angular based text panel props to new props if (panel.hasOwnProperty('content') && panel.hasOwnProperty('mode')) { - const oldTextPanel: { content: string; mode: string } = (panel as unknown) as any; + const oldTextPanel: any = panel as any; const content = oldTextPanel.content; - const mode = (oldTextPanel.mode as unknown) as TextMode; + const mode = oldTextPanel.mode as TextMode; - return { content, mode }; + delete oldTextPanel.content; + delete oldTextPanel.mode; + + if (previousVersion < 7.1) { + options = { content, mode }; + } } // The 'text' mode has been removed so we need to update any panels still using it to markdown - if (panel.options.mode !== 'html' && panel.options.mode !== 'markdown') { - return { content: panel.options.content, mode: 'markdown' }; + if (options.mode !== 'html' && options.mode !== 'markdown') { + options = { ...options, mode: 'markdown' }; } - return panel.options; + return options; }; diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index ffc8195dc1b..d51c2b9d4df 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -209,8 +209,6 @@ export function grafanaAppDirective( for (const drop of Drop.drops) { drop.destroy(); } - - appEvents.emit(CoreEvents.hideDashSearch); }); // handle kiosk mode diff --git a/public/app/routes/dashboard_loaders.ts b/public/app/routes/dashboard_loaders.ts index 81b62142454..b4df36696d5 100644 --- a/public/app/routes/dashboard_loaders.ts +++ b/public/app/routes/dashboard_loaders.ts @@ -2,7 +2,7 @@ import coreModule from 'app/core/core_module'; import { locationUtil, UrlQueryMap } from '@grafana/data'; import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { ILocationService } from 'angular'; -import { AppEventEmitter, CoreEvents, Scope } from 'app/types'; +import { AppEventEmitter, Scope } from 'app/types'; import { backendSrv } from 'app/core/services/backend_srv'; export class LoadDashboardCtrl { @@ -11,11 +11,8 @@ export class LoadDashboardCtrl { $scope: Scope & AppEventEmitter, $routeParams: UrlQueryMap, dashboardLoaderSrv: DashboardLoaderSrv, - $location: ILocationService, - $browser: any + $location: ILocationService ) { - $scope.appEvent(CoreEvents.dashboardFetchStart); - if (!$routeParams.uid && !$routeParams.slug) { backendSrv.get('/api/dashboards/home').then((homeDash: { redirectUri: string; meta: any }) => { if (homeDash.redirectUri) { diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index 18b4a3a490d..b45e866dc18 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -77,7 +77,7 @@ export enum AclTarget { export interface AclTargetInfo { value: AclTarget; - text: string; + label: string; } export const dataSourceAclLevels = [ @@ -85,10 +85,10 @@ export const dataSourceAclLevels = [ ]; export const dashboardAclTargets: AclTargetInfo[] = [ - { value: AclTarget.Team, text: 'Team' }, - { value: AclTarget.User, text: 'User' }, - { value: AclTarget.Viewer, text: 'Everyone With Viewer Role' }, - { value: AclTarget.Editor, text: 'Everyone With Editor Role' }, + { value: AclTarget.Team, label: 'Team' }, + { value: AclTarget.User, label: 'User' }, + { value: AclTarget.Viewer, label: 'Everyone With Viewer Role' }, + { value: AclTarget.Editor, label: 'Everyone With Editor Role' }, ]; export const dashboardPermissionLevels: DashboardPermissionInfo[] = [ diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 20edb6294bc..230c9ac202f 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -56,19 +56,6 @@ export interface DataSourceResponse { type DataSourceResponsePayload = DataSourceResponse; -export interface SaveDashboardPayload { - overwrite?: boolean; - folderId?: number; - makeEditable?: boolean; -} - -export interface GraphHoverPayload { - pos: any; - panel: { - id: number; - }; -} - export interface ToggleKioskModePayload { exit?: boolean; } @@ -90,27 +77,17 @@ export interface DashScrollPayload { pos?: number; } +export interface PanelChangeViewPayload {} + /** * Events */ -export const showDashSearch = eventFactory('show-dash-search'); -export const hideDashSearch = eventFactory('hide-dash-search'); -export const hideDashEditor = eventFactory('hide-dash-editor'); -export const dashScroll = eventFactory('dash-scroll'); export const dashLinksUpdated = eventFactory('dash-links-updated'); -export const saveDashboard = eventFactory('save-dashboard'); -export const dashboardFetchStart = eventFactory('dashboard-fetch-start'); export const dashboardSaved = eventFactory('dashboard-saved'); export const removePanel = eventFactory('remove-panel'); - export const searchQuery = eventFactory('search-query'); -export const locationChange = eventFactory('location-change'); - -export const timepickerOpen = eventFactory('timepickerOpen'); -export const timepickerClosed = eventFactory('timepickerClosed'); - export const showModal = eventFactory('show-modal'); export const showConfirmModal = eventFactory('confirm-modal'); export const hideModal = eventFactory('hide-modal'); @@ -119,9 +96,6 @@ export const showModalReact = eventFactory('show-modal-re export const dsRequestResponse = eventFactory('ds-request-response'); export const dsRequestError = eventFactory('ds-request-error'); -export const graphHover = eventFactory('graph-hover'); -export const graphHoverClear = eventFactory('graph-hover-clear'); - export const toggleSidemenuMobile = eventFactory('toggle-sidemenu-mobile'); export const toggleSidemenuHidden = eventFactory('toggle-sidemenu-hidden'); @@ -149,12 +123,6 @@ export const shiftTime = eventFactory('shift-time'); export const elasticQueryUpdated = eventFactory('elastic-query-updated'); -export const layoutModeChanged = eventFactory('layout-mode-changed'); - -export const jsonDiffReady = eventFactory('json-diff-ready'); - -export const closeTimepicker = eventFactory('closeTimepicker'); - export const routeUpdated = eventFactory('$routeUpdate'); export const queryChanged = eventFactory('queryChanged'); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 9c5497ae400..995733025b0 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -15,10 +15,9 @@ import { QueryHint, RawTimeRange, TimeRange, + EventBusExtended, } from '@grafana/data'; -import { Emitter } from 'app/core/core'; - export enum ExploreId { left = 'left', right = 'right', @@ -74,7 +73,7 @@ export interface ExploreItemState { /** * Emitter to send events to the rest of Grafana. */ - eventBridge: Emitter; + eventBridge: EventBusExtended; /** * List of timeseries to be shown in the Explore graph result viewer. */ diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts index cca24ef0f8a..65e505fb620 100644 --- a/public/app/types/plugins.ts +++ b/public/app/types/plugins.ts @@ -1,4 +1,4 @@ -import { PluginMeta } from '@grafana/data'; +import { PluginError, PluginMeta } from '@grafana/data'; import { PanelPlugin } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; @@ -24,6 +24,7 @@ export interface PanelPluginsIndex { export interface PluginsState { plugins: PluginMeta[]; + errors: PluginError[]; searchQuery: string; hasFetched: boolean; dashboards: PluginDashboard[]; diff --git a/public/img/plugins/mongodb.svg b/public/img/plugins/mongodb.svg new file mode 100644 index 00000000000..01a953800ea --- /dev/null +++ b/public/img/plugins/mongodb.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/img/plugins/snowflake.svg b/public/img/plugins/snowflake.svg new file mode 100644 index 00000000000..cd2263db996 --- /dev/null +++ b/public/img/plugins/snowflake.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/img/plugins/wavefront.svg b/public/img/plugins/wavefront.svg new file mode 100644 index 00000000000..35b48980714 --- /dev/null +++ b/public/img/plugins/wavefront.svg @@ -0,0 +1,17 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/public/sass/components/_dropdown.scss b/public/sass/components/_dropdown.scss index aedad24ebc4..05b0cc6ed2b 100644 --- a/public/sass/components/_dropdown.scss +++ b/public/sass/components/_dropdown.scss @@ -59,7 +59,7 @@ text-align: left; // Aligns the dropdown menu to right - &.pull-right { + &.pull-left { right: 0; left: auto; } @@ -238,6 +238,7 @@ .dropdown-submenu { position: relative; } + // Default dropdowns .dropdown-submenu > .dropdown-menu { top: 0; @@ -277,16 +278,21 @@ .dropdown-submenu.pull-left { // Undo the float // Yes, this is awkward since .pull-left adds a float, but it sticks to our conventions elsewhere. - float: none; + float: none !important; // Positioning the submenu > .dropdown-menu { left: -100%; - margin-left: 10px; + width: 100%; + margin-left: 2px; @include border-radius(6px 0 6px 6px); } } +.dropdown-submenu.pull-right { + float: none !important; +} + // Tweak nav headers // ----------------- // Increase padding from 15px to 20px on sides diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 83c58374685..dc5a71cdf3c 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -148,6 +148,11 @@ $input-border: 1px solid $input-border-color; border: $panel-border; } + &--dashlink { + background: $panel-bg; + border: $panel-border; + } + &--justify-left { justify-content: left; } @@ -314,6 +319,7 @@ $input-border: 1px solid $input-border-color; .gf-form-input { margin-right: 0; + line-height: $input-height; } select.gf-form-input { diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index 63f86625bf9..46751b2c735 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -18,6 +18,7 @@ .dashboard-content { padding: $dashboard-padding; + flex-basis: 100%; flex-grow: 1; } diff --git a/public/test/mocks/common.ts b/public/test/mocks/common.ts index a660ea11b08..9eee9d09d3e 100644 --- a/public/test/mocks/common.ts +++ b/public/test/mocks/common.ts @@ -24,7 +24,7 @@ export function createNavModel(title: string, ...tabs: string[]): NavModel { const node: NavModelItem = { id: title, text: title, - icon: 'fa fa-fw fa-warning', + icon: 'exclamation-triangle', subTitle: 'subTitle', url: title, children: [], diff --git a/scripts/build/ci-build-windows/Dockerfile b/scripts/build/ci-build-windows/Dockerfile index 32314c08794..9cb29deb0aa 100644 --- a/scripts/build/ci-build-windows/Dockerfile +++ b/scripts/build/ci-build-windows/Dockerfile @@ -7,8 +7,8 @@ RUN powershell Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force RUN powershell Invoke-Expression (New-Object System.Net.WebClient).DownloadString('https://get.scoop.sh') # Scoop first of all needs git to update itself # Run Scoop under PowerShell since it can otherwise fail -RUN powershell -Command scoop install git@2.28.0.windows.1 -RUN powershell -Command scoop install go@1.15.2 unzip@6.00 +RUN powershell -Command scoop install git@2.29.1.windows.1 +RUN powershell -Command scoop install go@1.15.3 unzip@6.00 gcc@9.3.0-2 # Install diffutils, in case we need them RUN powershell (New-Object Net.WebClient).DownloadFile(\ @@ -21,12 +21,12 @@ RUN mkdir -p "C:\Program Files (x86)\GnuWin32" RUN cd "C:\Program Files (x86)\GnuWin32" && unzip C:\App\diffutils-dep.zip && unzip C:\App\diffutils-bin.zip RUN powershell (New-Object Net.WebClient).DownloadFile(\ - \"https://github.com/golangci/golangci-lint/releases/download/v1.31.0/golangci-lint-1.31.0-windows-amd64.zip\", \ + \"https://github.com/golangci/golangci-lint/releases/download/v1.32.0/golangci-lint-1.32.0-windows-amd64.zip\", \ \"golangci-lint.zip\") RUN powershell (Get-FileHash golangci-lint.zip -Algorithm SHA256).Hash -eq \ - \"6CE6B1D3207A63256D82FBBAC80BB9E85D7705EC1A408F005DFE324457C54966\" + \"97a69c2a153cd4285b7000b327aa6e77b694534e7463cbd1b77481c22b6113cf\" RUN unzip golangci-lint.zip -RUN powershell -Command mv golangci-lint-1.31.0-windows-amd64\golangci-lint.exe . +RUN powershell -Command mv golangci-lint-1.32.0-windows-amd64\golangci-lint.exe . RUN powershell -Command scoop cache rm '*' diff --git a/scripts/ci-reference-docs-lint.sh b/scripts/ci-reference-docs-lint.sh index 9df0d4b6bf9..2e67ae9144c 100755 --- a/scripts/ci-reference-docs-lint.sh +++ b/scripts/ci-reference-docs-lint.sh @@ -29,7 +29,7 @@ if [ ! -d "$REPORT_PATH" ]; then fi WARNINGS_COUNT="$(find "$REPORT_PATH" -type f -name \*.log -print0 | xargs -0 grep -o "Warning: " | wc -l | xargs)" -WARNINGS_COUNT_LIMIT=1077 +WARNINGS_COUNT_LIMIT=1075 if [ "$WARNINGS_COUNT" -gt $WARNINGS_COUNT_LIMIT ]; then echo -e "API Extractor warnings/errors $WARNINGS_COUNT exceeded $WARNINGS_COUNT_LIMIT so failing build.\n" diff --git a/scripts/go/configs/.golangci.toml b/scripts/go/configs/.golangci.toml index cb288d92cc7..f9fdaaec752 100644 --- a/scripts/go/configs/.golangci.toml +++ b/scripts/go/configs/.golangci.toml @@ -32,7 +32,7 @@ enable = [ "exportloopref", "staticcheck", "structcheck", - # "stylecheck", + "stylecheck", "typecheck", "unconvert", "unused", @@ -44,6 +44,18 @@ enable = [ # Disabled linters (might want them later) # "unparam" +# Enable when appropriate +# Poorly chosen identifier +[[issues.exclude-rules]] +linters = ["stylecheck"] +text = "ST1003" + +# Enable when appropriate +# Dot imports that aren't in external test packages are discouraged. +[[issues.exclude-rules]] +linters = ["stylecheck"] +text = "ST1001" + [[issues.exclude-rules]] linters = ["gosec"] text = "G108" diff --git a/scripts/lib.star b/scripts/lib.star index a381d82fb2d..ec14052d86e 100644 --- a/scripts/lib.star +++ b/scripts/lib.star @@ -1,4 +1,4 @@ -grabpl_version = '0.5.20' +grabpl_version = '0.5.24' build_image = 'grafana/build-container:1.2.28' publish_image = 'grafana/grafana-ci-deploy:1.2.6' grafana_docker_image = 'grafana/drone-grafana-docker:0.3.2' @@ -264,8 +264,12 @@ def build_storybook_step(edition, ver_mode): # Best to ensure that this step doesn't mess with what's getting built and packaged 'package', ], + 'environment': { + 'NODE_OPTIONS': '--max_old_space_size=4096', + }, 'commands': [ 'yarn storybook:build', + './bin/grabpl verify-storybook', ], } diff --git a/scripts/pr.star b/scripts/pr.star index de8330bc5e4..7189db22d32 100644 --- a/scripts/pr.star +++ b/scripts/pr.star @@ -19,7 +19,6 @@ load( 'build_docker_images_step', 'postgres_integration_tests_step', 'mysql_integration_tests_step', - 'get_windows_steps', 'benchmark_ldap_step', 'ldap_service', 'integration_test_services', @@ -50,7 +49,6 @@ def pr_pipelines(edition): postgres_integration_tests_step(), mysql_integration_tests_step(), ] - windows_steps = get_windows_steps(edition=edition, ver_mode=ver_mode) if edition == 'enterprise': steps.append(benchmark_ldap_step()) services.append(ldap_service()) @@ -62,8 +60,4 @@ def pr_pipelines(edition): name='test-pr', edition=edition, trigger=trigger, services=services, steps=steps, ver_mode=ver_mode, ), - pipeline( - name='windows-pr', edition=edition, trigger=trigger, steps=windows_steps, platform='windows', - ver_mode=ver_mode, - ), ] diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js index db5df129ecf..daac683d766 100644 --- a/scripts/webpack/webpack.common.js +++ b/scripts/webpack/webpack.common.js @@ -65,7 +65,7 @@ module.exports = { new MonacoWebpackPlugin({ // available options are documented at https://github.com/Microsoft/monaco-editor-webpack-plugin#options filename: 'monaco-[name].worker.js', - languages: ['json', 'markdown', 'html', 'sql', 'mysql', 'pgsql'], + languages: ['json', 'markdown', 'html', 'sql', 'mysql', 'pgsql', 'javascript'], features: [ '!accessibilityHelp', 'bracketMatching', diff --git a/yarn.lock b/yarn.lock index baded1d2d07..290aef47be9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3375,10 +3375,10 @@ typescript "3.7.5" yaml "^1.8.3" -"@grafana/eslint-config@2.0.4": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@grafana/eslint-config/-/eslint-config-2.0.4.tgz#961e571e2eaf1db9b0558af0b977fcfd12ffb9eb" - integrity sha512-umdakxytNJv9jSnAHfcWfuLQ1XJ4jCX7m7ommCGn8tJmDP+2G/D6dOFsaZZQWMuhC/7+q1mG+Wy/Zs0kxq/xLg== +"@grafana/eslint-config@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@grafana/eslint-config/-/eslint-config-2.0.5.tgz#d15aadcac00d704b94354a8c2e153be403e03a1f" + integrity sha512-1eOh2Fj36rKd4LDzYhmTwy+CqU6ciiel19GAPhFi1hSCTN9Kq0JSbiJ4TpTinjYK4v9kg6MCkXSOqWQOtWFU1Q== dependencies: "@typescript-eslint/eslint-plugin" "4.0.1" "@typescript-eslint/parser" "4.0.1" @@ -7746,25 +7746,20 @@ angular-mocks@1.6.6: resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.6.6.tgz#c93018e7838c6dc5ceaf1a6bcf9be13c830ea515" integrity sha1-yTAY54OMbcXOrxprz5vhPIMOpRU= -angular-native-dragdrop@1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/angular-native-dragdrop/-/angular-native-dragdrop-1.2.2.tgz#d646c6b75b131c48073c3f6e36a225b2726d8bae" - integrity sha1-1kbGt1sTHEgHPD9uNqIlsnJti64= +angular-route@1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular-route/-/angular-route-1.8.2.tgz#d482bf05a8c9e448300acd8f9989c635d31b5077" + integrity sha512-49LJYxuaQ/ZDiu9dD2xo1LkazqObkGjw0a7dUF7UKCT8EELgBsMd6QrI6aEVGtI62ppkiFkxqmiV8fcwYdXpug== -angular-route@1.6.6: - version "1.6.6" - resolved "https://registry.yarnpkg.com/angular-route/-/angular-route-1.6.6.tgz#8c11748aa195c717b1b615a7e746442bfc7c61f4" - integrity sha1-jBF0iqGVxxexthWn50ZEK/x8YfQ= +angular-sanitize@1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular-sanitize/-/angular-sanitize-1.8.2.tgz#ae78040f00c5e2ce1c63780bcc47fa14a1698296" + integrity sha512-OB6Goa+QN3byf5asQ7XRl7DKZejm/F/ZOqa9z1skqYVOWA2hoBxoCmt9E7+i7T/TbxZP5zYzKxNZVVJNu860Hg== -angular-sanitize@1.6.6: - version "1.6.6" - resolved "https://registry.yarnpkg.com/angular-sanitize/-/angular-sanitize-1.6.6.tgz#0fd065a19931517fbece66596d325d72b6e06041" - integrity sha1-D9BloZkxUX++zmZZbTJdcrbgYEE= - -angular@1.6.9: - version "1.6.9" - resolved "https://registry.yarnpkg.com/angular/-/angular-1.6.9.tgz#bc812932e18909038412d594a5990f4bb66c0619" - integrity sha512-6igWH2GIsxV+J38wNWCh8oyjaZsrIPIDO35twloIUyjlF2Yit6UyLAWujHP05ma/LFxTsx4NtYibRoMNBXPR1A== +angular@1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular/-/angular-1.8.2.tgz#5983bbb5a9fa63e213cb7749199e0d352de3a2f1" + integrity sha512-IauMOej2xEe7/7Ennahkbb5qd/HFADiNuLSESz9Q27inmi32zB0lnAsFeLEWcox3Gd1F6YhNd1CP7/9IukJ0Gw== ansi-align@^2.0.0: version "2.0.0" @@ -7931,33 +7926,6 @@ arch@^2.1.2: resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== -archiver-utils@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-1.3.0.tgz#e50b4c09c70bf3d680e32ff1b7994e9f9d895174" - integrity sha1-5QtMCccL89aA4y/xt5lOn52JUXQ= - dependencies: - glob "^7.0.0" - graceful-fs "^4.1.0" - lazystream "^1.0.0" - lodash "^4.8.0" - normalize-path "^2.0.0" - readable-stream "^2.0.0" - -archiver@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/archiver/-/archiver-1.3.0.tgz#4f2194d6d8f99df3f531e6881f14f15d55faaf22" - integrity sha1-TyGU1tj5nfP1MeaIHxTxXVX6ryI= - dependencies: - archiver-utils "^1.3.0" - async "^2.0.0" - buffer-crc32 "^0.2.1" - glob "^7.0.0" - lodash "^4.8.0" - readable-stream "^2.0.0" - tar-stream "^1.5.0" - walkdir "^0.0.11" - zip-stream "^1.1.0" - are-we-there-yet@~1.1.2: version "1.1.5" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" @@ -8219,11 +8187,6 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== -async-foreach@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" - integrity sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI= - async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" @@ -8241,7 +8204,7 @@ async@^1.5.2, async@~1.5.2: resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= -async@^2.0.0, async@^2.1.4, async@^2.6.1, async@^2.6.2: +async@^2.1.4, async@^2.6.1, async@^2.6.2: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== @@ -8967,21 +8930,6 @@ bizcharts@^3.5.8: resize-observer-polyfill "^1.5.1" warning "^3.0.0" -bl@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" - integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA== - dependencies: - readable-stream "^2.3.5" - safe-buffer "^5.1.1" - -bl@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88" - integrity sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A== - dependencies: - readable-stream "^3.0.1" - blink-diff@1.0.13: version "1.0.13" resolved "https://registry.yarnpkg.com/blink-diff/-/blink-diff-1.0.13.tgz#80e3df69de804b30d40c70f041e983841ecda899" @@ -8991,13 +8939,6 @@ blink-diff@1.0.13: preceptor-core "~0.10.0" promise "6.0.0" -block-stream@*: - version "0.0.9" - resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" - integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= - dependencies: - inherits "~2.0.0" - bluebird@3.7.1, bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5: version "3.7.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de" @@ -9248,29 +9189,11 @@ btoa-lite@^1.0.0: resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337" integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc= -buffer-alloc-unsafe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" - integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== - -buffer-alloc@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" - integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== - dependencies: - buffer-alloc-unsafe "^1.1.0" - buffer-fill "^1.0.0" - -buffer-crc32@^0.2.1, buffer-crc32@~0.2.3: +buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= -buffer-fill@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" - integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= - buffer-from@1.x, buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -9295,14 +9218,6 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.1.0: - version "5.4.3" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115" - integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A== - dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" - builtin-modules@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484" @@ -9725,6 +9640,21 @@ chokidar@3.3.0: optionalDependencies: fsevents "~2.1.1" +"chokidar@>=2.0.0 <4.0.0": + version "3.4.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" + integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.5.0" + optionalDependencies: + fsevents "~2.1.2" + chokidar@^2.0.2, chokidar@^2.0.4, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -10237,16 +10167,6 @@ component-indexof@0.0.3: resolved "https://registry.yarnpkg.com/component-indexof/-/component-indexof-0.0.3.tgz#11d091312239eb8f32c8f25ae9cb002ffe8d3c24" integrity sha1-EdCRMSI5648yyPJa6csAL/6NPCQ= -compress-commons@^1.2.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-1.2.2.tgz#524a9f10903f3a813389b0225d27c48bb751890f" - integrity sha1-UkqfEJA/OoEzibAiXSfEi7dRiQ8= - dependencies: - buffer-crc32 "^0.2.1" - crc32-stream "^2.0.0" - normalize-path "^2.0.0" - readable-stream "^2.0.0" - compressible@~2.0.16: version "2.0.17" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.17.tgz#6e8c108a16ad58384a977f3a482ca20bff2f38c1" @@ -10620,21 +10540,6 @@ cosmiconfig@^6.0.0: path-type "^4.0.0" yaml "^1.7.2" -crc32-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-2.0.0.tgz#e3cdd3b4df3168dd74e3de3fbbcb7b297fe908f4" - integrity sha1-483TtN8xaN10494/u8t7KX/pCPQ= - dependencies: - crc "^3.4.4" - readable-stream "^2.0.0" - -crc@^3.4.4: - version "3.8.0" - resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6" - integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== - dependencies: - buffer "^5.1.0" - create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -10727,14 +10632,6 @@ cross-spawn@7.0.1, cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -cross-spawn@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" - integrity sha1-ElYDfsufDF9549bvE14wdwGEuYI= - dependencies: - lru-cache "^4.0.1" - which "^1.2.9" - cross-spawn@^5.0.1: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -11537,13 +11434,6 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= -decompress-response@^4.2.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" - integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== - dependencies: - mimic-response "^2.0.0" - dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -11725,7 +11615,7 @@ detect-indent@^5.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= -detect-libc@^1.0.2, detect-libc@^1.0.3: +detect-libc@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= @@ -12139,6 +12029,11 @@ emittery@^0.7.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451" integrity sha512-d34LN4L6h18Bzz9xpoku2nPwKxCPlPMr3EEKTkoEBi+1/+b0lcRkRJ1UVyyZaKNeqGR3swcGl6s390DNO4YVgQ== +emitter-component@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6" + integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY= + "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" @@ -12209,7 +12104,7 @@ encoding@^0.1.11: dependencies: iconv-lite "~0.4.13" -end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: +end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -12862,16 +12757,16 @@ eventemitter3@4.0.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== +eventemitter3@4.0.7, eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + eventemitter3@^3.1.0: version "3.1.2" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== -eventemitter3@^4.0.0: - version "4.0.7" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" - integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== - events@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88" @@ -12999,11 +12894,6 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" -expand-template@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" - integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== - expand-tilde@^2.0.0, expand-tilde@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" @@ -13789,11 +13679,6 @@ front-matter@2.1.2: dependencies: js-yaml "^3.4.6" -fs-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - fs-extra@8.1.0, fs-extra@^8.0.1, fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" @@ -13894,16 +13779,6 @@ fsevents@~2.1.1, fsevents@~2.1.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== -fstream@^1.0.0, fstream@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" - integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - function-bind@^1.0.2, function-bind@^1.1.1, function-bind@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -13967,7 +13842,7 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" -gaze@1.1.3, gaze@^1.0.0: +gaze@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a" integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g== @@ -14155,11 +14030,6 @@ gitconfiglocal@^1.0.0: dependencies: ini "^1.3.2" -github-from-package@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" - integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= - github-slugger@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.2.1.tgz#47e904e70bf2dccd0014748142d31126cfd49508" @@ -14475,7 +14345,7 @@ got@^6.7.1: unzip-response "^2.0.1" url-parse-lax "^1.0.0" -graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.2: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== @@ -14536,19 +14406,6 @@ grunt-contrib-clean@2.0.0: async "^2.6.1" rimraf "^2.6.2" -grunt-contrib-compress@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/grunt-contrib-compress/-/grunt-contrib-compress-1.6.0.tgz#9708885c738a97a12c5f3072dc97dbc31b4121db" - integrity sha512-wIFuvk+/Ny4E+OgEfJYFZgoH7KcU/nnNFbYasB7gRvrcRyW6vmTp3Pj8a4rFSR3tbFMjrGvTUszdO6fgLajgZQ== - dependencies: - archiver "^1.3.0" - chalk "^1.1.1" - lodash "^4.7.0" - pretty-bytes "^4.0.2" - stream-buffers "^2.1.0" - optionalDependencies: - iltorb "^2.4.3" - grunt-contrib-copy@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/grunt-contrib-copy/-/grunt-contrib-copy-1.0.0.tgz#7060c6581e904b8ab0d00f076e0a8f6e3e7c3573" @@ -14685,6 +14542,11 @@ gzip-size@5.1.1, gzip-size@^5.0.0: duplexer "^0.1.1" pify "^4.0.1" +hammerjs@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" + integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE= + handle-thing@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754" @@ -15302,17 +15164,6 @@ ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== -iltorb@^2.4.3: - version "2.4.4" - resolved "https://registry.yarnpkg.com/iltorb/-/iltorb-2.4.4.tgz#7ec303bbbd8c0cd4d44a847eb6c6d8490f9c7433" - integrity sha512-7Qk6O7TK3rSWVRVRkPehcNTSN+P2i7MsG9pWmw6iVw/W6NcoNj0rFKOuBDM6fbZV6NNGuUW3JBRem6Ozn4KXhg== - dependencies: - detect-libc "^1.0.3" - nan "^2.14.0" - npmlog "^4.1.2" - prebuild-install "^5.3.2" - which-pm-runs "^1.0.0" - image-size@~0.5.0: version "0.5.5" resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" @@ -15394,11 +15245,6 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -in-publish@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" - integrity sha1-4g/146KvwmkDILbcVSaCqcf631E= - indent-string@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" @@ -15439,7 +15285,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -17269,11 +17115,6 @@ jquery@3.5.1: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.5.1.tgz#d7b4d08e1bfdb86ad2f1a3d039ea17304717abb5" integrity sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg== -js-base64@^2.1.8: - version "2.5.1" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121" - integrity sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw== - js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -17605,6 +17446,11 @@ just-extend@^4.0.2: resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc" integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw== +keycharm@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/keycharm/-/keycharm-0.2.0.tgz#fa6ea2e43b90a68028843d27f2075d35a8c3e6f9" + integrity sha1-+m6i5DuQpoAohD0n8gddNajD5vk= + killable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" @@ -17699,13 +17545,6 @@ lazy-universal-dotenv@^3.0.1: dotenv "^8.0.0" dotenv-expand "^5.1.0" -lazystream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4" - integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ= - dependencies: - readable-stream "^2.0.5" - lcid@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" @@ -18139,7 +17978,7 @@ lodash@4.17.15: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@4.17.19, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.1.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.7.0, lodash@^4.8.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: +lodash@4.17.19, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.1.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.7.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: version "4.17.19" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== @@ -18405,10 +18244,10 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" -marked@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/marked/-/marked-1.1.1.tgz#e5d61b69842210d5df57b05856e0c91572703e6a" - integrity sha512-mJzT8D2yPxoPh7h0UXkB+dBj4FykPJ2OIfxAWeIHrvoHDkFxukV/29QxoFQoPM6RLEwhIFdJpmKBlqVM3s2ZIw== +marked@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.2.tgz#5d77ffb789c4cb0ae828bfe76250f7140b123f70" + integrity sha512-5jjKHVl/FPo0Z6ocP3zYhKiJLzkwJAw4CZoLjv57FkvbUuwOX4LIBBGGcXjAY6ATcd1q9B8UTj5T9Umauj0QYQ== marked@^0.3.12: version "0.3.19" @@ -18564,7 +18403,7 @@ memory-fs@^0.5.0: errno "^0.1.3" readable-stream "^2.0.1" -meow@^3.3.0, meow@^3.7.0: +meow@^3.3.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= @@ -18711,11 +18550,6 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mimic-response@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.0.0.tgz#996a51c60adf12cb8a87d7fb8ef24c2f3d5ebb46" - integrity sha512-8ilDoEapqA4uQ3TwS0jakGONKXVJqpy+RpM+3b7pLdOjghCrEiGp9SRkFbUHAmZW9vdnrENWHjaweIoTIJExSQ== - min-document@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" @@ -18882,7 +18716,7 @@ mkdirp-promise@^5.0.1: dependencies: mkdirp "*" -mkdirp@*, mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: +mkdirp@*, mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= @@ -19082,7 +18916,7 @@ mz@^2.5.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.12.1, nan@^2.13.2, nan@^2.14.0: +nan@^2.12.1: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -19123,11 +18957,6 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -napi-build-utils@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.1.tgz#1381a0f92c39d66bf19852e7873432fc2123e508" - integrity sha512-boQj1WFgQH3v4clhu3mTNfP+vOBxorDlE8EKiMjUlLG3C4qAESnn9AxIOkFgTR2c9LtzNjPrjS60cT27ZKBhaA== - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -19212,13 +19041,6 @@ no-case@^2.2.0, no-case@^2.3.2: dependencies: lower-case "^1.1.1" -node-abi@^2.7.0: - version "2.13.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.13.0.tgz#e2f2ec444d0aca3ea1b3874b6de41d1665828f63" - integrity sha512-9HrZGFVTR5SOu3PZAnAY2hLO36aW1wmA+FDsVkr85BTST32TLCA1H/AEcatVRAsWLyXS3bqUDYCAjq5/QGuSTA== - dependencies: - semver "^5.4.1" - node-ask@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/node-ask/-/node-ask-1.0.1.tgz#caaa1076cc58e0364267a0903e3eadfac158396b" @@ -19266,24 +19088,6 @@ node-forge@0.9.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== -node-gyp@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" - integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA== - dependencies: - fstream "^1.0.0" - glob "^7.0.3" - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3 || 4" - osenv "0" - request "^2.87.0" - rimraf "2" - semver "~5.3.0" - tar "^2.0.0" - which "1" - node-gyp@^5.0.2: version "5.0.5" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-5.0.5.tgz#f6cf1da246eb8c42b097d7cd4d6c3ce23a4163af" @@ -19407,29 +19211,6 @@ node-releases@^1.1.58: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.59.tgz#4d648330641cec704bff10f8e4fe28e453ab8e8e" integrity sha512-H3JrdUczbdiwxN5FuJPyCHnGHIFqQ0wWxo+9j1kAXAzqNMAHlo+4I/sYYxpyK0irQ73HgdiyzD32oqQDcU2Osw== -node-sass@4.13.1, node-sass@^4.13.1: - version "4.13.1" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.13.1.tgz#9db5689696bb2eec2c32b98bfea4c7a2e992d0a3" - integrity sha512-TTWFx+ZhyDx1Biiez2nB0L3YrCZ/8oHagaDalbuBSlqXgUPsdkUSzJsVxeDO9LtPB49+Fh3WQl3slABo6AotNw== - dependencies: - async-foreach "^0.1.3" - chalk "^1.1.1" - cross-spawn "^3.0.0" - gaze "^1.0.0" - get-stdin "^4.0.1" - glob "^7.0.3" - in-publish "^2.0.0" - lodash "^4.17.15" - meow "^3.7.0" - mkdirp "^0.5.1" - nan "^2.13.2" - node-gyp "^3.8.0" - npmlog "^4.0.0" - request "^2.88.0" - sass-graph "^2.2.4" - stdout-stream "^1.4.0" - "true-case-path" "^1.0.2" - nodemon@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.2.tgz#9c7efeaaf9b8259295a97e5d4585ba8f0cbe50b0" @@ -19446,11 +19227,6 @@ nodemon@2.0.2: undefsafe "^2.0.2" update-notifier "^2.5.0" -noop-logger@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" - integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= - "nopt@2 || 3", nopt@~3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" @@ -19483,7 +19259,7 @@ normalize-package-data@^2.0.0, normalize-package-data@^2.3.0, normalize-package- semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -normalize-path@^2.0.0, normalize-path@^2.1.1: +normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= @@ -19580,7 +19356,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.2, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -19960,7 +19736,7 @@ os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= -osenv@0, osenv@^0.1.4, osenv@^0.1.5: +osenv@^0.1.4, osenv@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== @@ -21369,27 +21145,6 @@ power-assert@^1.2.0: universal-deep-strict-equal "^1.2.1" xtend "^4.0.0" -prebuild-install@^5.3.2: - version "5.3.3" - resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.3.tgz#ef4052baac60d465f5ba6bf003c9c1de79b9da8e" - integrity sha512-GV+nsUXuPW2p8Zy7SarF/2W/oiK8bFQgJcncoJ0d7kRpekEA0ftChjfEaF9/Y+QJEc/wFR7RAEa8lYByuUIe2g== - dependencies: - detect-libc "^1.0.3" - expand-template "^2.0.3" - github-from-package "0.0.0" - minimist "^1.2.0" - mkdirp "^0.5.1" - napi-build-utils "^1.0.1" - node-abi "^2.7.0" - noop-logger "^0.1.1" - npmlog "^4.0.1" - pump "^3.0.0" - rc "^1.2.7" - simple-get "^3.0.3" - tar-fs "^2.0.0" - tunnel-agent "^0.6.0" - which-pm-runs "^1.0.0" - preceptor-core@~0.10.0: version "0.10.1" resolved "https://registry.yarnpkg.com/preceptor-core/-/preceptor-core-0.10.1.tgz#c31eb026fad91c24b44351308ac97e625ec69511" @@ -21435,11 +21190,6 @@ prettier@^1.16.4: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea" integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw== -pretty-bytes@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9" - integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk= - pretty-bytes@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" @@ -21631,6 +21381,13 @@ prop-types@15.7.2, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.0, prop object-assign "^4.1.1" react-is "^16.8.1" +propagating-hammerjs@^1.4.6: + version "1.4.7" + resolved "https://registry.yarnpkg.com/propagating-hammerjs/-/propagating-hammerjs-1.4.7.tgz#671ab1791a7f88b9dccce8fd8b14a9655950e7d6" + integrity sha512-oW9Wd+W2Tp5uOz6Fh4mEU7p+FoyU85smLH/mPga83Loh0pHa6AH4ZHGywvwMk3TWP31l7iUsvJyW265p4Ipwrg== + dependencies: + hammerjs "^2.0.8" + property-information@^5.0.0, property-information@^5.0.1, property-information@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.3.0.tgz#bc87ac82dc4e72a31bb62040544b1bf9653da039" @@ -22967,7 +22724,7 @@ read@1, read@~1.0.1: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== @@ -22980,7 +22737,7 @@ read@1, read@~1.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -"readable-stream@2 || 3", readable-stream@^3.0.1, readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1: +"readable-stream@2 || 3", readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1: version "3.4.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc" integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ== @@ -23032,6 +22789,13 @@ readdirp@~3.3.0: dependencies: picomatch "^2.0.7" +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== + dependencies: + picomatch "^2.2.1" + readline2@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" @@ -24051,10 +23815,10 @@ rxjs@6.5.5, rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.3: dependencies: tslib "^1.9.0" -rxjs@6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2" - integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg== +rxjs@6.6.3: + version "6.6.3" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== dependencies: tslib "^1.9.0" @@ -24100,16 +23864,6 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -sass-graph@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" - integrity sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k= - dependencies: - glob "^7.0.0" - lodash "^4.0.0" - scss-tokenizer "^0.2.3" - yargs "^7.0.0" - sass-lint@1.12.1: version "1.12.1" resolved "https://registry.yarnpkg.com/sass-lint/-/sass-lint-1.12.1.tgz#630f69c216aa206b8232fb2aa907bdf3336b6d83" @@ -24161,6 +23915,13 @@ sass-loader@8.0.2: schema-utils "^2.6.1" semver "^6.3.0" +sass@1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.27.0.tgz#0657ff674206b95ec20dc638a93e179c78f6ada2" + integrity sha512-0gcrER56OkzotK/GGwgg4fPrKuiFlPNitO7eUJ18Bs+/NBlofJfMxmxqpqJxjae9vu0Wq8TZzrSyxZal00WDig== + dependencies: + chokidar ">=2.0.0 <4.0.0" + sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -24242,14 +24003,6 @@ screenfull@^5.0.0: resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.0.tgz#5c2010c0e84fd4157bf852877698f90b8cbe96f6" integrity sha512-yShzhaIoE9OtOhWVyBBffA6V98CDCoyHTsp8228blmqYy1Z5bddzE/4FPiJKlr8DVR4VBiiUyfPzIQPIYDkeMA== -scss-tokenizer@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" - integrity sha1-jrBtualyMzOCTT9VMGQRSYR85dE= - dependencies: - js-base64 "^2.1.8" - source-map "^0.4.2" - search-query-parser@1.5.4: version "1.5.4" resolved "https://registry.yarnpkg.com/search-query-parser/-/search-query-parser-1.5.4.tgz#b474185e02717bee95f408e1003d0c1c932fcf55" @@ -24533,20 +24286,6 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= -simple-concat@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.0.tgz#7344cbb8b6e26fb27d66b2fc86f9f6d5997521c6" - integrity sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY= - -simple-get@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" - integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== - dependencies: - decompress-response "^4.2.0" - once "^1.3.1" - simple-concat "^1.0.0" - simple-git@^1.132.0: version "1.132.0" resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-1.132.0.tgz#53ac4c5ec9e74e37c2fd461e23309f22fcdf09b1" @@ -24851,7 +24590,7 @@ source-map@0.1.32: dependencies: amdefine ">=0.0.4" -source-map@0.4.x, source-map@^0.4.2: +source-map@0.4.x: version "0.4.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" integrity sha1-66T12pwNyZneaAMti092FzZSA2s= @@ -25085,13 +24824,6 @@ static-extend@^0.1.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= -stdout-stream@^1.4.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.1.tgz#5ac174cdd5cd726104aa0c0b2bd83815d8d535de" - integrity sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA== - dependencies: - readable-stream "^2.0.1" - stealthy-require@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" @@ -25160,11 +24892,6 @@ stream-buffers@1.0.1: resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-1.0.1.tgz#9a44a37555f96a5b78a5a765f0c48446cb160b8c" integrity sha1-mkSjdVX5alt4padl8MSERssWC4w= -stream-buffers@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" - integrity sha1-kdX1Ew0c75bc+n9yaUUYh0HQnuQ= - stream-each@^1.1.0: version "1.2.3" resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" @@ -25686,49 +25413,6 @@ tape@^4.5.1: string.prototype.trim "~1.1.2" through "~2.3.8" -tar-fs@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.0.tgz#677700fc0c8b337a78bee3623fdc235f21d7afad" - integrity sha512-vaY0obB6Om/fso8a8vakQBzwholQ7v5+uy+tF3Ozvxv1KNezmVQAiWtcNmMHFSFPqL3dJA8ha6gdtFbfX9mcxA== - dependencies: - chownr "^1.1.1" - mkdirp "^0.5.1" - pump "^3.0.0" - tar-stream "^2.0.0" - -tar-stream@^1.5.0: - version "1.6.2" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" - integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== - dependencies: - bl "^1.0.0" - buffer-alloc "^1.2.0" - end-of-stream "^1.0.0" - fs-constants "^1.0.0" - readable-stream "^2.3.0" - to-buffer "^1.1.1" - xtend "^4.0.0" - -tar-stream@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3" - integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw== - dependencies: - bl "^3.0.0" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -tar@^2.0.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" - integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA== - dependencies: - block-stream "*" - fstream "^1.0.12" - inherits "2" - tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" @@ -26067,11 +25751,6 @@ to-arraybuffer@^1.0.0: resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= -to-buffer@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" - integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== - to-camel-case@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46" @@ -26244,13 +25923,6 @@ trough@^1.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.4.tgz#3b52b1f13924f460c3fbfd0df69b587dbcbc762e" integrity sha512-tdzBRDGWcI1OpPVmChbdSKhvSVurznZ8X36AYURAcl+0o2ldlCY2XPzyXNNxwJwwyIU+rIglTCG4kxtNKBQH7Q== -"true-case-path@^1.0.2": - version "1.0.3" - resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.3.tgz#f813b5a8c86b40da59606722b144e3225799f47d" - integrity sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew== - dependencies: - glob "^7.1.2" - tryer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" @@ -26813,10 +26485,10 @@ update-notifier@^2.5.0: semver-diff "^2.0.0" xdg-basedir "^3.0.0" -uplot@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.1.2.tgz#ccdbe0987e7615d197e1dba77946a1655a823c31" - integrity sha512-CpQmMdafoMRR+zRSpfpMXs5mKvqgYFakcCyt7nOfh+pPeZfbxNMcCq9JFeXJcKEaWjrR6JSIiEZ01A4iFHztTQ== +uplot@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.2.2.tgz#b8876ab55c8a76fff81673b4b48fa5f76c4d9d2b" + integrity sha512-FiUCvD0QB+y0YGGtzTYhvaGktsddxiIFMSRScEsd97aasfnAGhIvs6aShbaB6/TZpKa6X1qVzFWuNgwnzaWBcg== upper-case-first@^1.1.0, upper-case-first@^1.1.2: version "1.1.2" @@ -27072,6 +26744,18 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +visjs-network@4.25.0: + version "4.25.0" + resolved "https://registry.yarnpkg.com/visjs-network/-/visjs-network-4.25.0.tgz#a3f0a9ac490b49346a0284986fcd40f8c0622335" + integrity sha512-DL6RXd+G6XME2qHMrSh9VYPbkqrY4Hs/liBuNCA1E0wq6bUpQljrM7ePhZMy7zD0yA3wQziOQFB78tvSOevKEQ== + dependencies: + emitter-component "^1.1.1" + hammerjs "^2.0.8" + keycharm "^0.2.0" + moment "^2.20.1" + propagating-hammerjs "^1.4.6" + timsort "^0.3.0" + vm-browserify@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019" @@ -27145,11 +26829,6 @@ w3c-xmlserializer@^2.0.0: dependencies: xml-name-validator "^3.0.0" -walkdir@^0.0.11: - version "0.0.11" - resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.11.tgz#a16d025eb931bd03b52f308caed0f40fcebe9532" - integrity sha1-oW0CXrkxvQO1LzCMrtD0D86+lTI= - walker@^1.0.7, walker@~1.0.5: version "1.0.7" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" @@ -27853,13 +27532,6 @@ yargs-parser@^4.2.0: dependencies: camelcase "^3.0.0" -yargs-parser@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" - integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo= - dependencies: - camelcase "^3.0.0" - yargs-unparser@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.0.tgz#ef25c2c769ff6bd09e4b0f9d7c605fb27846ea9f" @@ -28007,25 +27679,6 @@ yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" - integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg= - dependencies: - camelcase "^3.0.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^1.4.0" - read-pkg-up "^1.0.1" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^1.0.2" - which-module "^1.0.0" - y18n "^3.2.1" - yargs-parser "^5.0.0" - yargs@~3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" @@ -28067,16 +27720,6 @@ z-schema@~3.18.3: optionalDependencies: commander "^2.7.1" -zip-stream@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04" - integrity sha1-qLxF9MG0lpnGuQGYuqyqzbzUugQ= - dependencies: - archiver-utils "^1.3.0" - compress-commons "^1.2.0" - lodash "^4.8.0" - readable-stream "^2.0.0" - zone.js@0.7.8: version "0.7.8" resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.7.8.tgz#4f3fe8834d44597f2639053a0fa438df34fffded"