diff --git a/.drone.yml b/.drone.yml index 6333a661706..fe38edfddd9 100644 --- a/.drone.yml +++ b/.drone.yml @@ -17,7 +17,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -259,7 +259,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -433,7 +433,7 @@ steps: - package - name: publish-storybook - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json @@ -527,9 +527,9 @@ steps: - end-to-end-tests - name: upload-packages - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - - ./bin/grabpl upload-packages --edition oss + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads environment: GCP_GRAFANA_UPLOAD_KEY: from_secret: gcp_key @@ -539,6 +539,16 @@ steps: - mysql-integration-tests - postgres-integration-tests +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + services: - name: postgres image: postgres:12.3-alpine @@ -581,7 +591,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.36/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/windows/grabpl.exe -OutFile grabpl.exe - .\grabpl.exe verify-drone - name: build-windows-installer @@ -631,14 +641,14 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone environment: DOCKERIZE_VERSION: 0.6.1 - name: publish-packages-oss - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id ${DRONE_BUILD_NUMBER} @@ -680,7 +690,7 @@ steps: image: plugins/slack settings: channel: grafana-ci-notifications - template: "Build {{build.number}} failed: {{build.link}}" + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" webhook: from_secret: slack_webhook @@ -716,7 +726,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone - ./bin/grabpl verify-version ${DRONE_TAG} @@ -854,7 +864,7 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3001 + - ./bin/grabpl e2e-tests --port 3001 --tries 3 environment: HOST: end-to-end-tests-server depends_on: @@ -934,10 +944,20 @@ steps: - test-backend - test-frontend -- name: upload-packages - image: grafana/grafana-ci-deploy:1.3.0 +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 commands: - - ./bin/grabpl upload-packages --edition oss + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads environment: GCP_GRAFANA_UPLOAD_KEY: from_secret: gcp_key @@ -948,7 +968,7 @@ steps: - postgres-integration-tests - name: publish-storybook - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - gcloud auth activate-service-account --key-file=/tmp/gcpkey.json @@ -1013,7 +1033,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.36/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/windows/grabpl.exe -OutFile grabpl.exe - .\grabpl.exe verify-drone - name: build-windows-installer @@ -1064,7 +1084,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -1255,7 +1275,7 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3001 + - ./bin/grabpl e2e-tests --port 3001 --tries 3 environment: HOST: end-to-end-tests-server depends_on: @@ -1325,10 +1345,20 @@ steps: - test-backend - test-frontend -- name: upload-packages - image: grafana/grafana-ci-deploy:1.3.0 +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 commands: - - ./bin/grabpl upload-packages --edition enterprise + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads environment: GCP_GRAFANA_UPLOAD_KEY: from_secret: gcp_key @@ -1356,6 +1386,16 @@ steps: depends_on: - gen-version +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - name: end-to-end-tests-server-enterprise2 image: grafana/build-container:1.3.2 detach: true @@ -1372,14 +1412,14 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3002 + - ./bin/grabpl e2e-tests --port 3002 --tries 3 environment: HOST: end-to-end-tests-server-enterprise2 depends_on: - end-to-end-tests-server-enterprise2 - name: upload-packages-enterprise2 - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 environment: @@ -1434,7 +1474,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.36/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/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} @@ -1500,7 +1540,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone - ./bin/grabpl verify-version ${DRONE_TAG} @@ -1508,7 +1548,7 @@ steps: DOCKERIZE_VERSION: 0.6.1 - name: publish-packages-oss - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json ${DRONE_TAG} @@ -1527,7 +1567,7 @@ steps: - initialize - name: publish-packages-enterprise - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json ${DRONE_TAG} @@ -1569,7 +1609,7 @@ steps: image: plugins/slack settings: channel: grafana-ci-notifications - template: "Build {{build.number}} failed: {{build.link}}" + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" webhook: from_secret: slack_webhook @@ -1605,7 +1645,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone - ./bin/grabpl verify-version v7.3.0-test @@ -1743,7 +1783,7 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3001 + - ./bin/grabpl e2e-tests --port 3001 --tries 3 environment: HOST: end-to-end-tests-server depends_on: @@ -1817,8 +1857,18 @@ steps: - test-backend - test-frontend +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - name: upload-packages - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads-test environment: @@ -1831,7 +1881,7 @@ steps: - postgres-integration-tests - name: publish-storybook - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - echo Testing release environment: @@ -1891,7 +1941,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.36/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/windows/grabpl.exe -OutFile grabpl.exe - .\grabpl.exe verify-drone - name: build-windows-installer @@ -1942,7 +1992,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2133,7 +2183,7 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3001 + - ./bin/grabpl e2e-tests --port 3001 --tries 3 environment: HOST: end-to-end-tests-server depends_on: @@ -2197,8 +2247,18 @@ steps: - test-backend - test-frontend +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + - name: upload-packages - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads-test environment: @@ -2228,6 +2288,16 @@ steps: depends_on: - gen-version +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - name: end-to-end-tests-server-enterprise2 image: grafana/build-container:1.3.2 detach: true @@ -2244,14 +2314,14 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3002 + - ./bin/grabpl e2e-tests --port 3002 --tries 3 environment: HOST: end-to-end-tests-server-enterprise2 depends_on: - end-to-end-tests-server-enterprise2 - name: upload-packages-enterprise2 - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-test environment: @@ -2306,7 +2376,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.36/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise - git checkout master @@ -2372,7 +2442,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone - ./bin/grabpl verify-version v7.3.0-test @@ -2380,7 +2450,7 @@ steps: DOCKERIZE_VERSION: 0.6.1 - name: publish-packages-oss - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test @@ -2399,7 +2469,7 @@ steps: - initialize - name: publish-packages-enterprise - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json --deb-db-bucket grafana-testing-aptly-db --deb-repo-bucket grafana-testing-repo --packages-bucket grafana-downloads-test --rpm-repo-bucket grafana-testing-repo --simulate-release v7.3.0-test @@ -2441,7 +2511,7 @@ steps: image: plugins/slack settings: channel: grafana-ci-notifications - template: "Build {{build.number}} failed: {{build.link}}" + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" webhook: from_secret: slack_webhook @@ -2477,7 +2547,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - ./bin/grabpl verify-drone - curl -fLO https://github.com/jwilder/dockerize/releases/download/v$${DOCKERIZE_VERSION}/dockerize-linux-amd64-v$${DOCKERIZE_VERSION}.tar.gz @@ -2611,7 +2681,7 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3001 + - ./bin/grabpl e2e-tests --port 3001 --tries 3 environment: HOST: end-to-end-tests-server depends_on: @@ -2685,10 +2755,20 @@ steps: - test-backend - test-frontend -- name: upload-packages - image: grafana/grafana-ci-deploy:1.3.0 +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 commands: - - ./bin/grabpl upload-packages --edition oss + - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads environment: GCP_GRAFANA_UPLOAD_KEY: from_secret: gcp_key @@ -2738,7 +2818,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.36/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/windows/grabpl.exe -OutFile grabpl.exe - .\grabpl.exe verify-drone - name: build-windows-installer @@ -2785,7 +2865,7 @@ steps: image: grafana/build-container:1.3.2 commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl - chmod +x bin/grabpl - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2969,7 +3049,7 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3001 + - ./bin/grabpl e2e-tests --port 3001 --tries 3 environment: HOST: end-to-end-tests-server depends_on: @@ -3043,10 +3123,20 @@ steps: - test-backend - test-frontend -- name: upload-packages - image: grafana/grafana-ci-deploy:1.3.0 +- name: upload-cdn-assets + image: grafana/grafana-ci-deploy:1.3.1 commands: - - ./bin/grabpl upload-packages --edition enterprise + - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package + +- name: upload-packages + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads environment: GCP_GRAFANA_UPLOAD_KEY: from_secret: gcp_key @@ -3074,6 +3164,16 @@ steps: depends_on: - gen-version +- name: upload-cdn-assets-enterprise2 + image: grafana/grafana-ci-deploy:1.3.1 + commands: + - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + environment: + GCP_GRAFANA_UPLOAD_KEY: + from_secret: gcp_key + depends_on: + - package-enterprise2 + - name: end-to-end-tests-server-enterprise2 image: grafana/build-container:1.3.2 detach: true @@ -3090,14 +3190,14 @@ steps: image: grafana/ci-e2e:12.19.0-1 commands: - ./node_modules/.bin/cypress install - - ./bin/grabpl e2e-tests --port 3002 + - ./bin/grabpl e2e-tests --port 3002 --tries 3 environment: HOST: end-to-end-tests-server-enterprise2 depends_on: - end-to-end-tests-server-enterprise2 - name: upload-packages-enterprise2 - image: grafana/grafana-ci-deploy:1.3.0 + image: grafana/grafana-ci-deploy:1.3.1 commands: - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 environment: @@ -3152,7 +3252,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.36/windows/grabpl.exe -OutFile grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/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 @@ -3209,7 +3309,7 @@ steps: image: plugins/slack settings: channel: grafana-ci-notifications - template: "Build {{build.number}} failed: {{build.link}}" + template: "Build {{build.number}} failed for commit: : {{build.link}}\nAuthor: {{build.author}}" webhook: from_secret: slack_webhook diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d9d5447a58d..6360d22fa8c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,6 +22,12 @@ go.mod @grafana/backend-platform go.sum @grafana/backend-platform +#Cloud Datasources backend code +/pkg/tsdb/cloudwatch @grafana/cloud-datasources +/pkg/tsdb/azuremonitor @grafana/cloud-datasources +/pkg/tsdb/cloudmonitoring @grafana/cloud-datasources + + # Backend code docs /contribute/style-guides/backend.md @grafana/backend-platform @@ -48,9 +54,9 @@ lerna.json @grafana/grafana-frontend-platform /packages/jaeger-ui-components/ @grafana/observability-squad # Core datasources -/public/app/plugins/datasource/cloudwatch @grafana/backend-platform @grafana/observability-squad +/public/app/plugins/datasource/cloudwatch @grafana/cloud-datasources @grafana/observability-squad /public/app/plugins/datasource/elasticsearch @grafana/observability-squad -/public/app/plugins/datasource/grafana-azure-monitor-datasource @grafana/backend-platform +/public/app/plugins/datasource/grafana-azure-monitor-datasource @grafana/cloud-datasources /public/app/plugins/datasource/graphite @grafana/observability-squad /public/app/plugins/datasource/influxdb @grafana/observability-squad /public/app/plugins/datasource/jaeger @grafana/observability-squad @@ -60,7 +66,7 @@ lerna.json @grafana/grafana-frontend-platform /public/app/plugins/datasource/opentsdb @grafana/backend-platform /public/app/plugins/datasource/postgres @grafana/backend-platform /public/app/plugins/datasource/prometheus @grafana/observability-squad -/public/app/plugins/datasource/cloud-monitoring @grafana/backend-platform +/public/app/plugins/datasource/cloud-monitoring @grafana/cloud-datasources /public/app/plugins/datasource/zipkin @grafana/observability-squad # Cloud middleware diff --git a/.github/commands.json b/.github/commands.json index 0731881a086..f07072c5022 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -35,7 +35,7 @@ "action": "updateLabels", "addLabel": "needs more info", "removeLabel": "bot/needs more info", - "comment": "Thanks for creating this issue! We think it's missing some basic information. \r\n\r\nFollow the issue template and add additional information that will help us replicate the problem. \r\nFor data visualization issues: \r\n- Query results from the inspect drawer (data tab & query inspector)\r\n- Panel settings can be extracted in the panel inspect drawer JSON tab\r\n\r\nFor dashboard related issues: \r\n- Dashboard JSON can be found in the dashboard settings JSON model view\r\n\r\nFor authentication and alerting issues, Grafana server logs are useful. \r\n\r\nHappy graphing!" + "comment": "Thanks for creating this issue! We think it's missing some basic information. \r\n\r\nFollow the issue template and add additional information that will help us replicate the problem. \r\nFor data visualization issues: \r\n- Query results from the inspect drawer (data tab & query inspector)\r\n- Panel settings can be extracted in the panel inspect drawer JSON tab\r\n\r\nFor dashboard related issues: \r\n- Dashboard JSON can be found in the dashboard settings JSON model view\r\n\r\nFor authentication, provisioning and alerting issues, Grafana server logs are useful. \r\n\r\nHappy graphing!" }, { "type": "label", diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a64b34a681c..3e47fcbac05 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v1 - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync - - uses: actions/cache@v2 + - uses: actions/cache@v2.1.4 with: path: '**/node_modules' key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f968060eb8c..10ba509bee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,56 @@ + + +# 7.4.2 (2021-02-17) + +### Features and enhancements + +* **Explore**: Do not show non queryable data sources in data source picker. [#31144](https://github.com/grafana/grafana/pull/31144), [@torkelo](https://github.com/torkelo) +* **Snapshots**: Do not allow an anonymous user to create snapshots. [#31263](https://github.com/grafana/grafana/pull/31263), [@marefr](https://github.com/marefr) + +### Bug fixes + +* **CloudWatch**: Ensure empty query row errors are not passed to the panel. [#31172](https://github.com/grafana/grafana/pull/31172), [@sunker](https://github.com/sunker) +* **DashboardLinks**: Fix the links that always cause a full page to reload. [#31178](https://github.com/grafana/grafana/pull/31178), [@torkelo](https://github.com/torkelo) +* **DashboardListPanel**: Fix issue with folder picker always showing All and using old form styles. [#31160](https://github.com/grafana/grafana/pull/31160), [@torkelo](https://github.com/torkelo) +* **IPv6**: Support host address configured with enclosing square brackets. [#31226](https://github.com/grafana/grafana/pull/31226), [@aknuds1](https://github.com/aknuds1) +* **Permissions**: Fix team and role permissions on folders/dashboards not displayed for non Grafana Admin users. [#31132](https://github.com/grafana/grafana/pull/31132), [@AgnesToulet](https://github.com/AgnesToulet) +* **Postgres**: Fix timeGroup macro converts long intervals to invalid numbers when TimescaleDB is enabled. [#31179](https://github.com/grafana/grafana/pull/31179), [@kurokochin](https://github.com/kurokochin) +* **Prometheus**: Fix enabling of disabled queries when editing in dashboard. [#31055](https://github.com/grafana/grafana/pull/31055), [@ivanahuckova](https://github.com/ivanahuckova) +* **QueryEditors**: Fix an issue that happens after moving queries then editing would update other queries. [#31193](https://github.com/grafana/grafana/pull/31193), [@torkelo](https://github.com/torkelo) +* **SqlDataSources**: Fix the Show Generated SQL button in query editors. [#31236](https://github.com/grafana/grafana/pull/31236), [@torkelo](https://github.com/torkelo) +* **StatPanels**: Fix an issue where the palette color scheme is not cleared when loading panel. [#31126](https://github.com/grafana/grafana/pull/31126), [@torkelo](https://github.com/torkelo) +* **Variables**: Add the default option back for the data source variable. [#31208](https://github.com/grafana/grafana/pull/31208), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fix missing empty elements from regex filters. [#31156](https://github.com/grafana/grafana/pull/31156), [@hugohaggmark](https://github.com/hugohaggmark) + + + + + +# 7.4.1 (2021-02-11) + +### Features and enhancements + +* **Influx**: Make max series limit configurable and show the limiting message if applied. [#31025](https://github.com/grafana/grafana/pull/31025), [@aocenas](https://github.com/aocenas) +* **Make value mappings correctly interpret numeric-like strings**. [#30893](https://github.com/grafana/grafana/pull/30893), [@dprokop](https://github.com/dprokop) +* **Variables**: Adds queryparam formatting option. [#30858](https://github.com/grafana/grafana/pull/30858), [@hugohaggmark](https://github.com/hugohaggmark) + +### Bug fixes + +* **Alerting**: Fixes so notification channels are properly deleted. [#31040](https://github.com/grafana/grafana/pull/31040), [@hugohaggmark](https://github.com/hugohaggmark) +* **BarGauge**: Improvements to value sizing and table inner width calculations. [#30990](https://github.com/grafana/grafana/pull/30990), [@torkelo](https://github.com/torkelo) +* **DashboardLinks**: Fixes crash when link has no title. [#31008](https://github.com/grafana/grafana/pull/31008), [@hugohaggmark](https://github.com/hugohaggmark) +* **Elasticsearch**: Fix alias field value not being shown in query editor. [#30992](https://github.com/grafana/grafana/pull/30992), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Fix log row context errors. [#31088](https://github.com/grafana/grafana/pull/31088), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Show Size setting for raw_data metric. [#30980](https://github.com/grafana/grafana/pull/30980), [@Elfo404](https://github.com/Elfo404) +* **Graph**: Fixes so graph is shown for non numeric time values. [#30972](https://github.com/grafana/grafana/pull/30972), [@hugohaggmark](https://github.com/hugohaggmark) +* **Logging**: Ignore 'file already closed' error when closing file. [#31119](https://github.com/grafana/grafana/pull/31119), [@aknuds1](https://github.com/aknuds1) +* **Plugins**: Fix plugin signature validation for manifest v2 on Windows. [#31045](https://github.com/grafana/grafana/pull/31045), [@wbrowne](https://github.com/wbrowne) +* **TextPanel**: Fixes so panel title is updated when variables change. [#30884](https://github.com/grafana/grafana/pull/30884), [@hugohaggmark](https://github.com/hugohaggmark) +* **Transforms**: Fixes Outer join issue with duplicate field names not getting the same unique field names as before. [#31121](https://github.com/grafana/grafana/pull/31121), [@torkelo](https://github.com/torkelo) + + + # 7.4.0 (2021-02-04) diff --git a/conf/defaults.ini b/conf/defaults.ini index ff0901874f7..d09dfe3c428 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -162,6 +162,9 @@ send_user_header = false # Change this option to false to disable reporting. reporting_enabled = true +# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs +reporting_distributor = grafana-labs + # Set to false to disable all checks to https://grafana.com # for new versions (grafana itself and plugins), check is used # in some UI views to notify that grafana or plugin update exists diff --git a/conf/sample.ini b/conf/sample.ini index b0a182d03c6..80bae2091de 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -168,6 +168,9 @@ # Change this option to false to disable reporting. ;reporting_enabled = true +# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs +;reporting_distributor = grafana-labs + # Set to false to disable all checks to https://grafana.net # for new versions (grafana itself and plugins), check is used # in some UI views to notify that grafana or plugin update exists diff --git a/devenv/datasources.yaml b/devenv/datasources.yaml index 477d361d71d..405c69436ab 100644 --- a/devenv/datasources.yaml +++ b/devenv/datasources.yaml @@ -227,20 +227,6 @@ datasources: defaultRegion: eu-west-2 customMetricsNamespaces: "CWAgent" - # Keep to test old /api/prom API - - name: gdev-loki-0.3 - type: loki - access: proxy - url: http://localhost:3103 - editable: false - - # First version with new v1 API (remove once v1 is out) - - name: gdev-loki-0.4 - type: loki - access: proxy - url: http://localhost:3104 - editable: false - - name: gdev-loki type: loki access: proxy diff --git a/devenv/datasources_docker.yaml b/devenv/datasources_docker.yaml index 12ca1388ed2..d7abd11fb3b 100644 --- a/devenv/datasources_docker.yaml +++ b/devenv/datasources_docker.yaml @@ -227,20 +227,6 @@ datasources: authType: credentials defaultRegion: eu-west-2 - # Keep to test old /api/prom API - - name: gdev-loki-0.3 - type: loki - access: proxy - url: http://loki0.3:3100 - editable: false - - # First version with new v1 API (remove once v1 is out) - - name: gdev-loki-0.4 - type: loki - access: proxy - url: http://loki0.4:3100 - editable: false - - name: gdev-loki type: loki access: proxy diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-gradient-area.json b/devenv/dev-dashboards/panel-graph/graph-ng-gradient-area.json index e5701b48e9c..76629a0505a 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-gradient-area.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-gradient-area.json @@ -19,7 +19,9 @@ { "icon": "external link", "tags": ["gdev", "graph-ng"], - "type": "dashboards" + "type": "dashboards", + "asDropdown": true, + "title": "Graph Tests" } ], "panels": [ diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-hue-gradients.json b/devenv/dev-dashboards/panel-graph/graph-ng-hue-gradients.json index 7cf0f4a6f8a..23fe602a5f9 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-hue-gradients.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-hue-gradients.json @@ -16,7 +16,15 @@ "gnetId": null, "graphTooltip": 0, "id": 391, - "links": [], + "links": [ + { + "icon": "external link", + "tags": ["gdev", "graph-ng"], + "type": "dashboards", + "asDropdown": true, + "title": "Graph Tests" + } + ], "panels": [ { "datasource": null, diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json b/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json index c5dbb646638..26766b74b05 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json @@ -19,7 +19,9 @@ { "icon": "external link", "tags": ["gdev", "graph-ng"], - "type": "dashboards" + "type": "dashboards", + "asDropdown": true, + "title": "Graph Tests" } ], "panels": [ diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-soft-limits.json b/devenv/dev-dashboards/panel-graph/graph-ng-soft-limits.json index d79ede0cf49..8507ebadb61 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-soft-limits.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-soft-limits.json @@ -19,7 +19,9 @@ { "icon": "external link", "tags": ["gdev", "graph-ng"], - "type": "dashboards" + "type": "dashboards", + "asDropdown": true, + "title": "Graph Tests" } ], "panels": [ diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-time.json b/devenv/dev-dashboards/panel-graph/graph-ng-time.json new file mode 100644 index 00000000000..ed06868f01a --- /dev/null +++ b/devenv/dev-dashboards/panel-graph/graph-ng-time.json @@ -0,0 +1,470 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [ + { + "icon": "external link", + "tags": ["gdev", "graph-ng"], + "type": "dashboards", + "asDropdown": true, + "title": "Graph Tests" + } + ], + "panels": [ + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 41, + "gradientMode": "opacity", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 2, + "maxDataPoints": 50, + "options": { + "graph": {}, + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.5.0-pre", + "timeFrom": "1s", + "timeShift": null, + "title": "Sub second range ", + "type": "timeseries" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 17, + "gradientMode": "opacity", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 3, + "maxDataPoints": 50, + "options": { + "graph": {}, + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.5.0-pre", + "timeFrom": "1m", + "timeShift": null, + "title": "Sub minute range", + "type": "timeseries" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "opacity", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 4, + "maxDataPoints": 50, + "options": { + "graph": {}, + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.5.0-pre", + "timeFrom": "1h", + "timeShift": null, + "title": "Sub hour range", + "type": "timeseries" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 37, + "gradientMode": "opacity", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 9 + }, + "id": 5, + "maxDataPoints": 50, + "options": { + "graph": {}, + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.5.0-pre", + "timeFrom": "1d", + "timeShift": null, + "title": "Sub day range", + "type": "timeseries" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 37, + "gradientMode": "opacity", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 9 + }, + "id": 6, + "maxDataPoints": 50, + "options": { + "graph": {}, + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.5.0-pre", + "timeFrom": "2d", + "timeShift": null, + "title": "Last days (shows date)", + "type": "timeseries" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 37, + "gradientMode": "opacity", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 9 + }, + "id": 7, + "maxDataPoints": 50, + "options": { + "graph": {}, + "legend": { + "calcs": [], + "displayMode": "hidden", + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.5.0-pre", + "timeFrom": "5y", + "timeShift": null, + "title": "Last 5 days (year only)", + "type": "timeseries" + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": ["gdev", "panel-tests", "graph-ng"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Panel Tests - GraphNG - Time Axis", + "uid": "sd27_CPGz", + "version": 3 +} diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-yaxis-ticks.json b/devenv/dev-dashboards/panel-graph/graph-ng-yaxis-ticks.json index f12c2e65b9a..1a9177bfdfa 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-yaxis-ticks.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-yaxis-ticks.json @@ -19,7 +19,9 @@ { "icon": "external link", "tags": ["gdev", "graph-ng"], - "type": "dashboards" + "type": "dashboards", + "asDropdown": true, + "title": "Graph Tests" } ], "panels": [ diff --git a/devenv/dev-dashboards/panel-graph/graph-ng.json b/devenv/dev-dashboards/panel-graph/graph-ng.json index 43176b3d224..abf88a47626 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng.json @@ -19,7 +19,9 @@ { "icon": "external link", "tags": ["gdev", "graph-ng"], - "type": "dashboards" + "type": "dashboards", + "asDropdown": true, + "title": "Graph Tests" } ], "panels": [ diff --git a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json new file mode 100644 index 00000000000..08e94e66e2e --- /dev/null +++ b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json @@ -0,0 +1,468 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 231, + "links": [], + "panels": [ + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": {}, + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 8, + "x": 0, + "y": 3 + }, + "id": 2, + "options": { + "labelOptions": { + "showName": true, + "showPercent": false, + "showValue": false + }, + "pieType": "donut", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "showLegend": true, + "strokeWidth": 1 + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "alias": "__house_locations", + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 5 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Donut name", + "type": "piechart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": {}, + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 8, + "x": 8, + "y": 3 + }, + "id": 11, + "options": { + "labelOptions": { + "showName": true, + "showPercent": true, + "showValue": false + }, + "pieType": "pie", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "showLegend": true, + "strokeWidth": 1 + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "alias": "__house_locations", + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 5 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Name and Percent", + "type": "piechart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 8, + "x": 16, + "y": 3 + }, + "id": 8, + "options": { + "labelOptions": { + "showName": true, + "showPercent": false, + "showValue": false + }, + "pieType": "pie", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "showLegend": false, + "strokeWidth": 1 + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "alias": "__server_names", + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 4 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Name", + "type": "piechart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 0, + "y": 17 + }, + "id": 3, + "options": { + "labelOptions": { + "showName": false, + "showPercent": true, + "showValue": false + }, + "pieType": "pie", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "showLegend": false, + "strokeWidth": 1 + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "alias": "__server_names", + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 4 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Percent", + "type": "piechart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 5, + "y": 17 + }, + "id": 9, + "options": { + "labelOptions": { + "showName": false, + "showPercent": false, + "showValue": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "showLegend": false, + "strokeWidth": 1 + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "alias": "__server_names", + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 4 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Value", + "type": "piechart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 6, + "x": 10, + "y": 17 + }, + "id": 6, + "options": { + "labelOptions": { + "showName": true, + "showPercent": false, + "showValue": false + }, + "pieType": "donut", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "showLegend": false, + "strokeWidth": 1 + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "alias": "__server_names", + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 4 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Name", + "type": "piechart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 17 + }, + "id": 10, + "options": { + "labelOptions": { + "showName": true, + "showPercent": false, + "showValue": false + }, + "pieType": "pie", + "reduceOptions": { + "calcs": ["mean"], + "fields": "", + "values": false + }, + "showLegend": false, + "strokeWidth": 1 + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "alias": "__server_names", + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 4 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Memory", + "type": "piechart" + } + ], + "schemaVersion": 26, + "style": "dark", + "tags": ["gdev", "panel-tests"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Panel Tests - Pie chart", + "uid": "lVE-2YFMz", + "version": 1 +} diff --git a/devenv/dev-dashboards/panel-table/table_tests_new.json b/devenv/dev-dashboards/panel-table/table_tests_new.json index 09e6da02526..31e7d074e25 100644 --- a/devenv/dev-dashboards/panel-table/table_tests_new.json +++ b/devenv/dev-dashboards/panel-table/table_tests_new.json @@ -33,9 +33,13 @@ "datasource": "gdev-testdata", "fieldConfig": { "defaults": { + "color": { + "mode": "thresholds" + }, "custom": { "align": "center", - "displayMode": "color-background" + "displayMode": "color-background", + "filterable": false }, "mappings": [], "thresholds": { @@ -128,7 +132,7 @@ } ] }, - "pluginVersion": "7.1.0-pre", + "pluginVersion": "7.5.0-pre", "targets": [ { "refId": "A", @@ -144,7 +148,11 @@ { "id": "reduce", "options": { - "reducers": ["max", "mean", "last"] + "reducers": [ + "max", + "mean", + "last" + ] } } ], @@ -154,8 +162,12 @@ "datasource": "gdev-testdata", "fieldConfig": { "defaults": { + "color": { + "mode": "thresholds" + }, "custom": { - "align": null + "align": null, + "filterable": false }, "mappings": [], "thresholds": { @@ -176,7 +188,7 @@ { "matcher": { "id": "byName", - "options": "Value" + "options": "A" }, "properties": [ { @@ -251,7 +263,7 @@ } ] }, - "pluginVersion": "7.1.0-pre", + "pluginVersion": "7.5.0-pre", "targets": [ { "refId": "A", @@ -287,8 +299,12 @@ "description": "", "fieldConfig": { "defaults": { + "color": { + "mode": "thresholds" + }, "custom": { - "align": null + "align": null, + "filterable": false }, "mappings": [], "thresholds": { @@ -309,7 +325,7 @@ { "matcher": { "id": "byName", - "options": "Value" + "options": "A" }, "properties": [ { @@ -335,7 +351,7 @@ "showHeader": true, "sortBy": [] }, - "pluginVersion": "7.1.0-pre", + "pluginVersion": "7.5.0-pre", "targets": [ { "refId": "A", @@ -387,9 +403,13 @@ "datasource": "gdev-testdata", "fieldConfig": { "defaults": { + "color": { + "mode": "thresholds" + }, "custom": { "align": "center", - "displayMode": "color-text" + "displayMode": "color-text", + "filterable": false }, "decimals": 2, "mappings": [], @@ -459,7 +479,7 @@ "options": { "showHeader": true }, - "pluginVersion": "7.1.0-pre", + "pluginVersion": "7.5.0-pre", "targets": [ { "alias": "S1", @@ -501,9 +521,13 @@ "datasource": "gdev-testdata", "fieldConfig": { "defaults": { + "color": { + "mode": "thresholds" + }, "custom": { "align": "center", - "displayMode": "auto" + "displayMode": "auto", + "filterable": false }, "mappings": [], "thresholds": { @@ -547,7 +571,7 @@ } ] }, - "pluginVersion": "7.1.0-pre", + "pluginVersion": "7.5.0-pre", "targets": [ { "refId": "A", @@ -582,9 +606,12 @@ "type": "table" } ], - "schemaVersion": 25, + "schemaVersion": 27, "style": "dark", - "tags": ["gdev", "panel-tests"], + "tags": [ + "gdev", + "panel-tests" + ], "templating": { "list": [] }, @@ -593,10 +620,20 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] }, "timezone": "", "title": "Panel Tests - React Table", "uid": "U_bZIMRMk", - "version": 17 -} + "version": 1 +} \ No newline at end of file diff --git a/devenv/docker/blocks/loki/config.yaml b/devenv/docker/blocks/loki/config.yaml index 9451b6ba79b..c44c6f85805 100644 --- a/devenv/docker/blocks/loki/config.yaml +++ b/devenv/docker/blocks/loki/config.yaml @@ -10,7 +10,6 @@ client: scrape_configs: - job_name: system - entry_parser: raw static_configs: - targets: - localhost @@ -18,7 +17,6 @@ scrape_configs: job: varlogs __path__: /var/log/*log - job_name: grafana - entry_parser: raw static_configs: - targets: - localhost diff --git a/devenv/docker/blocks/loki0.3/config.yaml b/devenv/docker/blocks/loki0.3/config.yaml deleted file mode 100644 index e23620db2aa..00000000000 --- a/devenv/docker/blocks/loki0.3/config.yaml +++ /dev/null @@ -1,27 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /tmp/positions.yaml - -client: - url: http://loki0.3:3100/api/prom/push - -scrape_configs: - - job_name: system - entry_parser: raw - static_configs: - - targets: - - localhost - labels: - job: varlogs - __path__: /var/log/*log - - job_name: grafana - entry_parser: raw - static_configs: - - targets: - - localhost - labels: - job: grafana - __path__: /var/log/grafana/*log diff --git a/devenv/docker/blocks/loki0.3/docker-compose.yaml b/devenv/docker/blocks/loki0.3/docker-compose.yaml deleted file mode 100644 index 4314ea14329..00000000000 --- a/devenv/docker/blocks/loki0.3/docker-compose.yaml +++ /dev/null @@ -1,15 +0,0 @@ - # datasource URL: http://localhost:3103/ - loki0.3: - image: grafana/loki:v0.3.0 - ports: - - "3103:3100" - command: -config.file=/etc/loki/local-config.yaml - - promtail0.3: - image: grafana/promtail:v0.3.0 - volumes: - - ./docker/blocks/loki0.3/config.yaml:/etc/promtail/docker-config.yaml - - /var/log:/var/log - - ../data/log:/var/log/grafana - command: - -config.file=/etc/promtail/docker-config.yaml diff --git a/devenv/docker/blocks/loki0.4/config.yaml b/devenv/docker/blocks/loki0.4/config.yaml deleted file mode 100644 index 57c502b21a0..00000000000 --- a/devenv/docker/blocks/loki0.4/config.yaml +++ /dev/null @@ -1,27 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /tmp/positions.yaml - -client: - url: http://loki0.4:3100/api/prom/push - -scrape_configs: - - job_name: system - entry_parser: raw - static_configs: - - targets: - - localhost - labels: - job: varlogs - __path__: /var/log/*log - - job_name: grafana - entry_parser: raw - static_configs: - - targets: - - localhost - labels: - job: grafana - __path__: /var/log/grafana/*log diff --git a/devenv/docker/blocks/loki0.4/docker-compose.yaml b/devenv/docker/blocks/loki0.4/docker-compose.yaml deleted file mode 100644 index 79035616820..00000000000 --- a/devenv/docker/blocks/loki0.4/docker-compose.yaml +++ /dev/null @@ -1,15 +0,0 @@ - # datasource URL: http://localhost:3104/ - loki0.4: - image: grafana/loki:v0.4.0 - ports: - - "3104:3100" - command: -config.file=/etc/loki/local-config.yaml - - promtail0.4: - image: grafana/promtail:v0.4.0 - volumes: - - ./docker/blocks/loki0.4/config.yaml:/etc/promtail/docker-config.yaml - - /var/log:/var/log - - ../data/log:/var/log/grafana - command: - -config.file=/etc/promtail/docker-config.yaml diff --git a/devenv/docker/blocks/prometheus2/Dockerfile b/devenv/docker/blocks/prometheus2/Dockerfile index fe73ec26780..ea9fc071e3c 100644 --- a/devenv/docker/blocks/prometheus2/Dockerfile +++ b/devenv/docker/blocks/prometheus2/Dockerfile @@ -1,4 +1,4 @@ -FROM prom/prometheus:v2.7.2 +FROM prom/prometheus:latest ADD prometheus.yml /etc/prometheus/ ADD recording.yml /etc/prometheus/ ADD alert.yml /etc/prometheus/ diff --git a/devenv/docker/blocks/zipkin/docker-compose.yaml b/devenv/docker/blocks/zipkin/docker-compose.yaml index 55f8792df4f..f887cb4afe7 100644 --- a/devenv/docker/blocks/zipkin/docker-compose.yaml +++ b/devenv/docker/blocks/zipkin/docker-compose.yaml @@ -1,5 +1,21 @@ -# There is no data generator for this so easiest way to get some data here is run this example app -# https://github.com/openzipkin/zipkin-js-example/tree/master/web + # Generate traffic by hitting http://localhost:8081 + frontend-example: + image: ghcr.io/openzipkin/brave-example + entrypoint: start-frontend + ports: + - 8081:8081 + depends_on: + backend-example: + condition: service_healthy + zipkin: + condition: service_started + # Serves the /api endpoint the frontend uses + backend-example: + image: ghcr.io/openzipkin/brave-example + entrypoint: start-backend + depends_on: + zipkin: + condition: service_started zipkin: image: openzipkin/zipkin:latest ports: diff --git a/docs/sources/administration/change-home-dashboard.md b/docs/sources/administration/change-home-dashboard.md deleted file mode 100644 index 8e886bb1553..00000000000 --- a/docs/sources/administration/change-home-dashboard.md +++ /dev/null @@ -1,53 +0,0 @@ -+++ -title = "Change the default home dashboard" -description = "How to replace the default home dashboard" -keywords = ["grafana", "configuration", "documentation", "home"] -weight = 100 -+++ - -# Change the default home dashboard - -You can change the default dashboard on the organization, team and user level. The home dashboard you set for the whole organization is the one all users will see by default. The team dashboard applies to all users assigned to the team in Grafana. The team dashboard overrides the organization dashboard settings. Personal dashboard settings override team dashboard settings. - -## Set the home dashboard for your organization - -### Set the default dashboard through preferences - -1. Navigate to the dashboard you want to set as the home dashboard. -1. Star this dashboard by clicking on the star next to the dashboard title. -1. On the left menu, hover your cursor over the **Configuration** (gear) icon and then click **Preferences**. -1. In the **Home Dashboard** field, select the dashboard you want to use for your home dashboard. Options include all starred dashboards. - -### Set the default dashboard through configuration - -If preferences are set as described above, then they override this value. -You can provide your own JSON file to change the home dashboard. No user will be able to update this dashboard in Grafana. - -#### [Optional] Convert an existing dashboard into a JSON file -1. Navigate to your dashboard page. -1. Click the **Share dashboard** icon next to the dashboard title. -1. In the **Export** tab, click on **Save to file**. - -#### Use a JSON file as the home dashboard -1. Save your JSON file somewhere that Grafana can access it, for example, in the Grafana `data` folder of Grafana. -1. Update your configuration file to set the path to the JSON file. Read how to update this file in the [configuration]({{< relref "./configuration.md">}}) documentation. -```ini -[dashboards] -# Path to the default home dashboard. If this value is empty, then Grafana uses StaticRootPath + "dashboards/home.json" -default_home_dashboard_path = data/main-dashboard.json -``` - -## Set home dashboard for your team - -1. Navigate to the dashboard you want to set as the home dashboard. -1. Star this dashboard by clicking on the star next to the dashboard title. -1. On the left menu, hover your cursor over the **Configuration** (gear) icon and then click **Teams**. -1. Click on the team you want to change the home dashboard for and then navigate to the **Settings** tab. -1. In the **Home Dashboard** field, select the dashboard you want to use for your home dashboard. Options include all starred dashboards. - -## Set your personal home dashboard - -1. Navigate to the dashboard you want to set as the home dashboard. -1. Star this dashboard by clicking on the star next to the dashboard title. -1. On the left menu, hover your cursor over your avatar and then click **Preferences**. -1. In the **Home Dashboard** field, select the dashboard you want to use for your home dashboard. Options include all starred dashboards. diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 7a34e562946..8a4b20a25e0 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -654,13 +654,15 @@ Grafana provides many ways to authenticate users. Refer to the Grafana [Authenti The cookie name for storing the auth token. Default is `grafana_session`. -### login_maximum_inactive_lifetime_days +### login_maximum_inactive_lifetime_duration -The lifetime (days) an authenticated user can be inactive before being required to log in at next visit. Default is 7 days. +The maximum lifetime (duration) an authenticated user can be inactive before being required to login at next visit. Default is 7 days (7d). +This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). The lifetime resets at each successful token rotation (token_rotation_interval_minutes). -### login_maximum_lifetime_days +### login_maximum_lifetime_duration -The maximum lifetime (days) an authenticated user can be logged in before being required to login. Default is 30 days. +The maximum lifetime (duration) an authenticated user can be logged in since login time before being required to login. Default is 30 days (30d). +This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). ### token_rotation_interval_minutes diff --git a/docs/sources/administration/preferences.md b/docs/sources/administration/preferences.md deleted file mode 100644 index b2d6a8516c7..00000000000 --- a/docs/sources/administration/preferences.md +++ /dev/null @@ -1,56 +0,0 @@ -+++ -title = "Change your preferences" -description = "How to change your Grafana preferences" -keywords = ["grafana", "profile", "change", "preferences"] -weight = 200 -+++ - -# Change your Grafana preferences - -You can perform several tasks in the Preferences tab. You can edit your profile, change your Grafana preferences, and view information about your profile and Grafana usage. - -## Edit your Grafana profile - -Your profile includes your name, user name, and email address. - -1. Navigate to the Preferences tab. Hover your cursor over your user icon in the lower left corner of the screen, and then click **Preferences.** -1. In the Edit Profile section, you can edit any of the following: - - **Name -** Edit this field to change the display name associated with your profile. - - **Email -** Edit this field to change the email address associated with your profile. - - **Username -** Edit this field to change your user name. -1. Click **Save**. - -## Edit your Grafana preferences - -Your Grafana preferences include whether uses the dark or light theme, your home dashboard, and your timezone. - -> **Note:** Settings on your personal instance override settings made by your administrator at the instance or team level. - -1. Navigate to the Preferences tab. Hover your cursor over your user icon in the lower left corner of the screen, and then click **Preferences.** -1. In the Preferences section, you can edit any of the following: - - **UI Theme -** Click to set the **Dark** or **Light** to select a theme. **Default** is either the dark theme or the theme selected by your Grafana administrator. - - **Home Dashboard -** Refer to [Set your personal home dashboard]({{< relref "change-home-dashboard.md#set-your-personal-home-dashboard" >}}) for more information. - - **Timezone -** Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected by your Grafana administrator. Refer to [Time range controls]({{< relref "../dashboards/time-range-controls.md" >}}) for more information about Grafana time settings. -1. Click **Save**. - -## View your assigned organizations - -Every user is a member of at least one organization. You can have different roles in every organization that you are a member of. - -1. Navigate to the Preferences tab. Hover your cursor over your user icon in the lower left corner of the screen, and then click **Preferences.** -1. Scroll down to the Organizations section. - - **Name -** The name of the organizations you are a member of in that Grafana instance. - - **Role -** The role you are assigned in the organization. Refer to [Organization roles]({{< relref "../permissions/organization_roles.md" >}}) about permissions assigned to each role. - - **Current -** Grafana tags the organization that you are currently signed in to as _Current_. - -## View your Grafana sessions - -Grafana logs your sessions in each Grafana instance. You can review this section if you suspect someone has misused your Grafana credentials. - -1. Navigate to the Preferences tab. Hover your cursor over your user icon in the lower left corner of the screen, and then click **Preferences.** -1. Scroll down to the Sessions section. Grafana displays the following: - - **Last seen -** How long ago you logged on. - - **Logged on -** The date you logged on to the current Grafana instance. - - **IP address -** The IP address that you logged on from. - - **Browser & OS -** The web browser and operating system used to log on to Grafana. - - If you are a Grafana Admin for the instance, then you can revoke a session by clicking the red signout icon in the session row. diff --git a/docs/sources/administration/preferences/_index.md b/docs/sources/administration/preferences/_index.md new file mode 100644 index 00000000000..c96bb6d684d --- /dev/null +++ b/docs/sources/administration/preferences/_index.md @@ -0,0 +1,20 @@ ++++ +title = "Preferences" +aliases =["/docs/grafana/latest/administration/preferences.md"] +weight = 50 ++++ + +# Grafana preferences + +Grafana preferences are basic settings. They control the Grafana UI theme, home dashboard, time zone, and so on. + +Preferences are sometimes confusing because they can be set at four different levels, listed from highest level to lowest: + +- **Server -** Affects all users on the Grafana server. Set by a [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}). +- **Organization -** Affects all users in an organization. Set by an [Organization Admin]({{< relref "../../permissions/organization_roles.md#organization-admin-role" >}}). +- **Team -** Affects all users assigned to a team. Set by an Organization Admin or Team Admin. To learn more about these roles, refer to [Organization roles]({{< relref "../../permissions/organization_roles.md" >}}). +- **User account -** Affects the individual user. Set by the user on their own account. + +The lowest level always takes precedence. For example, if a user sets their theme to **Light**, then their visualization of Grafana displays the light theme. Nothing at any higher level can override that. + +If the user is aware of the change and intended it, then that's great! But if the user is a Server Admin who made the change to their user preferences a long time ago, they might have forgotten they did that. Then, if that Server Admin is trying to change the theme at the server level, they'll get frustrated as none of their changes have any effect that they can see. (Also, the users on the server might be confused, because _they_ can see the server-level changes!) diff --git a/docs/sources/administration/preferences/change-grafana-name.md b/docs/sources/administration/preferences/change-grafana-name.md new file mode 100644 index 00000000000..0883fb84c09 --- /dev/null +++ b/docs/sources/administration/preferences/change-grafana-name.md @@ -0,0 +1,53 @@ ++++ +title = "Change name and email" +keywords = ["grafana", "configuration", "documentation", "home"] +weight = 100 ++++ + +# Change Grafana name and email + +In Grafana, you can change your names and emails associated with groups or accounts in the Settings or Preferences. This topic provides instructions for each task. + +{{< docs/shared "preferences/some-tasks-require-permissions.md" >}} + +## Change organization name + +Grafana server administrators and organization administrators can change organization names. + +### Grafana Server Admin change organization name + +Follow these instructions if you are a Grafana Server Admin. + +{{< docs/list >}} +{{< docs/shared "manage-users/view-server-org-list.md" >}} +1. In the organization list, click the name of the organization that you want to change. +1. In **Name**, enter the new organization name. +1. Click **Update**. +{{< /docs/list >}} + +### Organization Admin change organization name + +If you are an Organization Admin, follow these steps: + +{{< docs/list >}} +{{< docs/shared "preferences/org-preferences-list.md" >}} +1. In **Organization name**, enter the new name. +1. Click **Update organization name**. +{{< /docs/list >}} + +## Change team name or email + +Organization administrators and team administrators can change team names and email addresses. +To change the team name or email, follow these steps: +1. Hover your cursor over the **Configuration** (gear) icon in the side menu. +1. Click **Teams**. Grafana displays the team list. +1. In the team list, click the name of the team that you want to change. +1. Click the **Settings** tab. +1. In the Team Settings section, you can edit the following: + - **Name -** Edit this field to change the display name associated with the team. + - **Email -** Edit this field to change the email address associated with the team. +1. Click **Update**. + +## Change user name or email + +To learn how to edit your user information, refer to [Grafana user account profile]({{< relref "../../manage-users/user-admin/user-profile.md" >}}). diff --git a/docs/sources/administration/preferences/change-grafana-theme.md b/docs/sources/administration/preferences/change-grafana-theme.md new file mode 100644 index 00000000000..e5f086b5a0a --- /dev/null +++ b/docs/sources/administration/preferences/change-grafana-theme.md @@ -0,0 +1,66 @@ ++++ +title = "Change UI theme" +description = "How to set the Grafana UI theme" +keywords = ["grafana", "configuration", "documentation", "home"] +weight = 200 ++++ + +# Change Grafana UI theme + +In Grafana, you can modify the UI theme configured in the Settings or Preferences. Set the UI theme for the server, an organization, a team, or your personal user account using the instructions in this topic. + +{{< docs/shared "preferences/some-tasks-require-permissions.md" >}} + +## Theme options + +The theme affects how Grafana displays graphs, menus, other UI elements. + +### Default + +**Default** is either the dark theme or the theme selected in a higher level. For example, if an Organization administrator set the **Light** theme, then that is the default for all the teams in that organization. + +### Dark + +Here is an example of the dark theme. + +![Dark theme example](/img/docs/preferences/dark-theme-7-4.png) + +### Light + +Here is an example of the light theme. + +![Light theme example](/img/docs/preferences/light-theme-7-4.png) + +## Change server UI theme + +Grafana server administrators can change the Grafana UI theme for all users on the server by setting the [default_theme]({{< relref "../configuration.md#default-theme" >}}) option in the Grafana configuration file. + +To see what the current settings are, refer to [View server settings]({{< relref "../view-server/view-server-settings.md" >}}). + +## Change organization UI theme + +Organization administrators can change the UI theme for all users in an organization. + +{{< docs/list >}} +{{< docs/shared "preferences/org-preferences-list.md" >}} +{{< docs/shared "preferences/select-ui-theme-list.md" >}} +{{< /docs/list >}} + +## Change team UI theme + +Organization and team administrators can change the UI theme for all users in a team. + +{{< docs/list >}} +{{< docs/shared "manage-users/view-team-list.md" >}} +1. Click on the team that you want to change the UI theme for and then navigate to the **Settings** tab. +{{< docs/shared "preferences/select-ui-theme-list.md" >}} +{{< /docs/list >}} + +## Change your personal UI theme + +You can change the UI theme for your user account. This setting overrides UI theme settings at higher levels. + +{{< docs/list >}} +{{< docs/shared "preferences/navigate-user-preferences-list.md" >}} +{{< docs/shared "preferences/select-ui-theme-list.md" >}} +{{< /docs/list >}} diff --git a/docs/sources/administration/preferences/change-grafana-timezone.md b/docs/sources/administration/preferences/change-grafana-timezone.md new file mode 100644 index 00000000000..82a26c58079 --- /dev/null +++ b/docs/sources/administration/preferences/change-grafana-timezone.md @@ -0,0 +1,44 @@ ++++ +title = "Change default timezone" +description = "How to change your Grafana timezone" +keywords = ["grafana", "configuration", "documentation", "home"] +weight = 400 ++++ + +# Change the Grafana default timezone + +By default, Grafana uses the timezone in your web browser. However, you can override this setting at the server, organization, team, or individual user level. This topic provides instructions for each task. + +{{< docs/shared "preferences/some-tasks-require-permissions.md" >}} + +## Set server timezone + +Grafana server administrators can choose a default timezone for all users on the server by setting the [default_timezone]({{< relref "../configuration.md#default-timezone" >}}) option in the Grafana configuration file. + +## Set organization timezone + +Organization administrators can choose a default timezone for their organization. + +{{< docs/list >}} +{{< docs/shared "preferences/org-preferences-list.md" >}} +{{< docs/shared "preferences/select-timezone-list.md" >}} +{{< /docs/list >}} + +## Set team timezone + +Organization administrators and team administrators can choose a default timezone for all users in a team. + +{{< docs/list >}} +{{< docs/shared "manage-users/view-team-list.md" >}} +1. Click on the team you that you want to change the timezone for and then navigate to the **Settings** tab. +{{< docs/shared "preferences/select-timezone-list.md" >}} +{{< /docs/list >}} + +## Set your personal timezone + +You can change the timezone for your user account. This setting overrides timezone settings at higher levels. + +{{< docs/list >}} +{{< docs/shared "preferences/navigate-user-preferences-list.md" >}} +{{< docs/shared "preferences/select-timezone-list.md" >}} +{{< /docs/list >}} \ No newline at end of file diff --git a/docs/sources/administration/preferences/change-home-dashboard.md b/docs/sources/administration/preferences/change-home-dashboard.md new file mode 100644 index 00000000000..bac996dce64 --- /dev/null +++ b/docs/sources/administration/preferences/change-home-dashboard.md @@ -0,0 +1,72 @@ ++++ +title = "Change home dashboard" +description = "How to replace the default home dashboard" +keywords = ["grafana", "configuration", "documentation", "home"] +aliases = ["/docs/grafana/latest/administration/change-home-dashboard/"] +weight = 300 ++++ + +# Change the default home dashboard + +The home dashboard you set is the one all users will see by default when they log in. You can set the home dashboard for the server, an organization, a team, or your personal user account. This topic provides instructions for each task. + +{{< docs/shared "preferences/some-tasks-require-permissions.md" >}} + +## Navigate to the home dashboard + +The home dashboard is the first dashboard a user sees when they sign in to Grafana. You can also navigate to the home dashboard manually. + +1. Hover your cursor over the **Dashboards** (four squares) icon. +1. Click **Home**. + +## Set the home dashboard for the server + +Users with the Grafana Server Admin flag on their account or access to the configuration file can define a JSON file to use as the home dashboard for all users on the server. + +### [Optional] Convert an existing dashboard into a JSON file + +1. Navigate to the page of the dashboard you want to use as the home dashboard. +1. Click the **Share dashboard** icon next to the dashboard title. +1. In the Export tab, click **Save to file**. Grafana converts the dashboard to a JSON file and saves it locally. + +### Use a JSON file as the home dashboard + +1. Save your JSON file somewhere that Grafana can access it. For example, in the Grafana `data` folder of Grafana. +1. Update your configuration file to set the path to the JSON file. Refer to [default_home_dashboard_path]({{< relref "../configuration.md">}}) for more information about modifying the Grafana configuration files. + +```ini +[dashboards] +# Path to the default home dashboard. If this value is empty, then Grafana uses StaticRootPath + "dashboards/home.json" +default_home_dashboard_path = data/main-dashboard.json +``` + +## Set the home dashboard for your organization + +Organization administrators can choose a home dashboard for their organization. + +{{< docs/list >}} +{{< docs/shared "preferences/navigate-to-the-dashboard-list.md" >}} +{{< docs/shared "preferences/org-preferences-list.md" >}} +{{< docs/shared "preferences/select-home-dashboard-list.md" >}} +{{< /docs/list >}} + +## Set home dashboard for your team + +Organization administrators and Team Admins can choose a home dashboard for a team. + +{{< docs/list >}} +{{< docs/shared "preferences/navigate-to-the-dashboard-list.md" >}} +{{< docs/shared "manage-users/view-team-list.md" >}} +1. Click on the team that you want to change the home dashboard for and then navigate to the **Settings** tab. +{{< docs/shared "preferences/select-home-dashboard-list.md" >}} +{{< /docs/list >}} + +## Set your personal home dashboard + +You can choose your own personal home dashboard. This setting overrides all home dashboards set at higher levels. + +{{< docs/list >}} +{{< docs/shared "preferences/navigate-to-the-dashboard-list.md" >}} +{{< docs/shared "preferences/navigate-user-preferences-list.md" >}} +{{< docs/shared "preferences/select-home-dashboard-list.md" >}} +{{< /docs/list >}} diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 3c32d106732..44499362ad8 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -148,8 +148,9 @@ Since not all datasources have the same configuration settings we only have the | tlsSkipVerify | boolean | _All_ | Controls whether a client verifies the server's certificate chain and host name. | | serverName | string | _All_ | Optional. Controls the server name used for certificate common name/subject alternative name verification. Defaults to using the data source URL. | | graphiteVersion | string | Graphite | Graphite version | -| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source. | +| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL and MSSQL | Lowest interval/step value that should be used for this data source. | | httpMode | string | Influxdb | HTTP Method. 'GET', 'POST', defaults to GET | +| maxSeries | number | Influxdb | Max number of series/tables that Grafana processes | | httpMethod | string | Prometheus | HTTP Method. 'GET', 'POST', defaults to GET | | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56/60/70) | | timeField | string | Elasticsearch | Which field that should be used as timestamp | diff --git a/docs/sources/dashboards/json-model.md b/docs/sources/dashboards/json-model.md index 884818b39ca..f425eb8645d 100644 --- a/docs/sources/dashboards/json-model.md +++ b/docs/sources/dashboards/json-model.md @@ -9,11 +9,11 @@ weight = 1200 A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from panels, template variables, panel queries, etc. -To view the JSON of a dashboard, follow the steps mentioned below: +To view the JSON of a dashboard: - 1. Go to a dashboard - 1. Click on `Manage dashboard` menu on the top navigation bar - 1. Select `View JSON` from the dropdown menu + 1. Navigate to a dashboard. + 1. In the top navigation menu, click the **Dashboard settings** (gear) icon. + 1. Click **JSON Model**. ## JSON fields diff --git a/docs/sources/datasources/graphite.md b/docs/sources/datasources/graphite.md index 668f323708f..4bb4dd68168 100644 --- a/docs/sources/datasources/graphite.md +++ b/docs/sources/datasources/graphite.md @@ -12,7 +12,7 @@ Grafana has an advanced Graphite query editor that lets you quickly navigate the change function parameters and much more. The editor can handle all types of graphite queries. It can even handle complex nested queries through the use of query references. -Refer to [Add a data source]({{< relref "add-a-data-source.md" >}}) for instructions on how to add a data source to Grafana. Only organization admins can add data sources. +Refer to [Add a data source]({{< relref "add-a-data-source.md" >}}) for instructions on how to add a data source to Grafana. Only organization admins can add data sources. To learn more about the Graphite data source, refer to Graphite's [product documentation](https://graphite.readthedocs.io/en/stable/). ## Graphite settings @@ -70,6 +70,8 @@ a function is selected, it will be added and your focus will be in the text box Some functions like aliasByNode support an optional second argument. To add an argument, hover your mouse over the first argument and then click the `+` symbol that appears. To remove the second optional parameter, click on it and leave it blank and the editor will remove it. +To learn more, refer to [Graphite's documentation on functions](https://graphite.readthedocs.io/en/latest/functions.html). + ### Sort labels If you want consistent ordering, use sortByName. This can be particularly annoying when you have the same labels on multiple graphs, and they are both sorted differently and using different colors. To fix this, use `sortByName()`. diff --git a/docs/sources/datasources/influxdb.md b/docs/sources/datasources/influxdb.md index de41fb1546c..8dc50899f14 100644 --- a/docs/sources/datasources/influxdb.md +++ b/docs/sources/datasources/influxdb.md @@ -33,6 +33,8 @@ Name | Description `User` | Name of your database user `Password` | Database user's password `HTTP mode` | How to query the database (`GET` or `POST` HTTP verb). The `POST` verb allows heavy queries that would return an error using the `GET` verb. Default is `GET`. +`Min time interval` | Refer to [Min time interval]({{< relref "#min-time-interval" >}}). +`Max series`| Limits the number of series/tables that Grafana processes. Lower this number to prevent abuse, and increase it if you have lots of small time series and not all are shown. Defaults to 1000. Access mode controls how requests to the data source will be handled. Server should be the preferred way if nothing else stated. @@ -130,6 +132,8 @@ Name | Description `Organization` | The [Influx organization](https://v2.docs.influxdata.com/v2.0/organizations/) that will be used for Flux queries. This is also used to for the `v.organization` query macro `Token` | The authentication token used for Flux queries. With Influx 2.0, use the [influx authentication token to function](https://v2.docs.influxdata.com/v2.0/security/tokens/create-token/). For influx 1.8, the token is `username:password` `Default Bucket` | The [Influx bucket](https://v2.docs.influxdata.com/v2.0/organizations/buckets/) that will be used for the `v.defaultBucket` macro in Flux queries +`Min time interval` | Refer to [Min time interval]({{< relref "#min-time-interval" >}}). +`Max series`| Limits the number of series/tables that Grafana processes. Lower this number to prevent abuse, and increase it if you have lots of small time series and not all are shown. Defaults to 1000. You can use the [Flux query and scripting language](https://www.influxdata.com/products/flux/). Grafana's Flux query editor is a text editor for raw Flux queries with Macro support. diff --git a/docs/sources/datasources/prometheus.md b/docs/sources/datasources/prometheus.md index 6fc53b54942..a502d4c78e6 100644 --- a/docs/sources/datasources/prometheus.md +++ b/docs/sources/datasources/prometheus.md @@ -141,6 +141,11 @@ There are two syntaxes: Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the _Multi-value_ or _Include all value_ options are enabled, Grafana converts the labels from plain text to a regex compatible string. Which means you have to use `=~` instead of `=`. +### Ad hoc filters variable + +Prometheus supports the special [ad hoc filters]({{< relref "../variables/variable-types/add-ad-hoc-filters.md" >}}) variable type. It allows you to specify any number of label/value filters on the fly. These filters are automatically +applied to all your Prometheus queries. + ## Annotations [Annotations]({{< relref "../dashboards/annotations.md" >}}) allow you to overlay rich event information on top of graphs. You add annotation @@ -207,7 +212,7 @@ If you are running Grafana in an Amazon EKS cluster, follow the AWS guide to [Qu ## Exemplars -> **Note:** This feature is available in Prometheus 2.25+ and Grafana 7.4+. +> **Note:** This feature is available in Prometheus 2.26+ and Grafana 7.4+. Grafana 7.4 and later versions have the capability to show exemplars data alongside a metric both in Explore and Dashboards. Exemplars are a way to associate higher cardinality metadata from a specific event with traditional timeseries data. diff --git a/docs/sources/enterprise/_index.md b/docs/sources/enterprise/_index.md index a42e7ca150f..f14091b8534 100644 --- a/docs/sources/enterprise/_index.md +++ b/docs/sources/enterprise/_index.md @@ -50,6 +50,7 @@ With Grafana Enterprise, you get access to new features, including: - [Usage insights]({{< relref "usage-insights/_index.md" >}}) to understand how your Grafana instance is used. - [Vault integration]({{< relref "vault.md" >}}) to manage your configuration or provisioning secrets with Vault. - [Auditing]({{< relref "auditing.md" >}}) tracks important changes to your Grafana instance to help you manage and mitigate suspicious activity and meet compliance requirements. +- [Request security]({{< relref "request-security.md" >}}) makes it possible to restrict outgoing requests from the Grafana server. ## Enterprise plugins @@ -70,4 +71,3 @@ With a Grafana Enterprise license, you get access to premium plugins, including: To purchase or obtain a trial license contact the Grafana Labs [Sales Team](https://grafana.com/contact?about=support&topic=Grafana%20Enterprise). - diff --git a/docs/sources/enterprise/enterprise-configuration.md b/docs/sources/enterprise/enterprise-configuration.md index 379122f2c98..1ec61f7bbb1 100644 --- a/docs/sources/enterprise/enterprise-configuration.md +++ b/docs/sources/enterprise/enterprise-configuration.md @@ -304,3 +304,25 @@ Time until expiration for tokens which are renewed. Should have a value higher t ### lease_renewal_increment New duration for renewed tokens. Vault may be configured to ignore this value and impose a stricter limit. + +## [security.egress] + +> **Note:** Available in Grafana Enterprise v7.4 and later versions. + +Security egress makes it possible to control outgoing traffic from the Grafana server. + +### host_deny_list + +A list of hostnames or IP addresses separated by spaces for which requests are blocked. + +### host_allow_list + +A list of hostnames or IP addresses separated by spaces for which requests are allowed. All other requests are blocked. + +### header_drop_list + +A list of headers that are stripped from the outgoing data source and alerting requests. + +### cookie_drop_list + +A list of cookies that are stripped from the outgoing data source and alerting requests. diff --git a/docs/sources/enterprise/license-restrictions.md b/docs/sources/enterprise/license-restrictions.md index aef95cf3b9e..9d309dd7c0f 100644 --- a/docs/sources/enterprise/license-restrictions.md +++ b/docs/sources/enterprise/license-restrictions.md @@ -25,6 +25,8 @@ Restrictions are applied separately for viewers and editors. When the number of maximum active viewers or editors is reached, Grafana displays a warning banner. +Sometimes it is useful to log in to an account from multiple locations concurrently. With Grafana Enterprise, accounts are limited to three concurrent sessions. + ## Expiration date The license expiration date is the date when a license is no longer active. As the license expiration date approaches, Grafana Enterprise displays a banner. diff --git a/docs/sources/enterprise/request-security.md b/docs/sources/enterprise/request-security.md new file mode 100644 index 00000000000..b1f6ffc8883 --- /dev/null +++ b/docs/sources/enterprise/request-security.md @@ -0,0 +1,57 @@ ++++ +title = "Request security" +description = "Grafana Enterprise request security" +keywords = ["grafana", "security", "enterprise"] +weight = 110 ++++ + +# Request security + +> **Note:** Available in Grafana Enterprise v7.4 and later versions. + +Request security makes it possible to limit requests from the Grafana server, and it targets requests that are generated by users. + +For example: +- Data source metric queries +- Alert notifications + +This can be used to limit access to internal systems that the server Grafana runs on can access but that users of Grafana should not be able to access. This feature does not affect traffic from the Grafana users browser. + +> **Note:** Although request security works with backend plugins, you can create a backend plugin that bypasses this security. + +## IP and hostname blocking + +You can limit requests based on a hostname, an IP address, or both. + +### Deny list + +Grafana blocks any request to a hostname or IP address on the deny list. + +### Allow list + +If there is at least one entry on the list, then any request to a hostname or IP address not on the list is denied. + +For example: + +```toml +[security.egress] +# A list of hostnames or IP addresses separated by spaces for which requests are blocked. +host_deny_list = supersecret.internal 192.168.1.10 +# a list of hostnames or IP addresses separated by spaces for which requests will be allowed, all other requests will be blocked +host_allow_list = prometheus.internal + +``` + +## Drop headers and cookies + +You can set a list of cookies or headers that are to be dropped from outgoing requests. + +Example: + +```toml +[security.egress] +# a list of headers that will be stripped from outgoing datasource and alerting requests +header_drop_list = user +# a list of cookies that will be stripped from outgoing datasource requests (case sensitive) +cookie_drop_list = session_id +``` diff --git a/docs/sources/getting-started/glossary.md b/docs/sources/getting-started/glossary.md index 512644ff79d..fbb4229d766 100644 --- a/docs/sources/getting-started/glossary.md +++ b/docs/sources/getting-started/glossary.md @@ -20,7 +20,7 @@ This topic lists words and abbreviations that are commonly used in the Grafana d Data source - A file, database, or service providing the data. Grafana supports a several data sources by default, and can be extended to support additional data sources through plugins. + A file, database, or service providing the data. Grafana supports several data sources by default, and can be extended to support additional data sources through plugins. @@ -32,7 +32,7 @@ This topic lists words and abbreviations that are commonly used in the Grafana d Explore - 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. For more information, refer to the Explore topic. + Explore allows a user to focus on building a query. Users can refine the query to return the expected metrics before building a dashboard. For more information, refer to the Explore topic. diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md index 71c21947220..5a87f5c3593 100644 --- a/docs/sources/http_api/annotations.md +++ b/docs/sources/http_api/annotations.md @@ -88,7 +88,9 @@ Content-Type: application/json Creates an annotation in the Grafana database. The `dashboardId` and `panelId` fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds -the Grafana annotations data source. When creating a region annotation include the timeEnd property. +the Grafana annotations data source. When creating a region annotation include the timeEnd property. + +The format for `time` and `timeEnd` should be epoch numbers in millisecond resolution. `POST /api/annotations` diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index b204dc5889b..e0ae75892a7 100755 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -139,7 +139,7 @@ This section contains important information if you want to migrate from previous ### Migrate to v7.3 or later -The Grafana Docker image runs with the `root` group (id 1) instead of the `grafana` group (id 472), for better compatibility with OpenShift. If you extend the official Docker image you may need to change your scripts to use the `root` group instead of `grafana`. +The Grafana Docker image runs with the `root` group (id 0) instead of the `grafana` group (id 472), for better compatibility with OpenShift. If you extend the official Docker image you may need to change your scripts to use the `root` group instead of `grafana`. ### Migrate to v6.5 or later diff --git a/docs/sources/installation/mac.md b/docs/sources/installation/mac.md index 8451fc3bac7..a4465d93c00 100755 --- a/docs/sources/installation/mac.md +++ b/docs/sources/installation/mac.md @@ -15,7 +15,7 @@ This page explains how to install Grafana and get the service running on your ma Use [Homebrew](http://brew.sh/) to install the most recent released version of Grafana using Homebrew package. -1. On the Homberew homepage, search for Grafana. The last stable and released version is listed. +1. On the Homebrew homepage, search for Grafana. The last stable and released version is listed. 1. Open a terminal and enter: ``` brew update diff --git a/docs/sources/manage-users/org-admin/index.md b/docs/sources/manage-users/org-admin/index.md index f525bb4f237..4133769d6d7 100644 --- a/docs/sources/manage-users/org-admin/index.md +++ b/docs/sources/manage-users/org-admin/index.md @@ -65,10 +65,6 @@ Grafana displays a list of pending invitations. If necessary, you can use the se ![Pending Invites list](/img/docs/manage-users/pending-invites-list-7-3.png) -### Copy invitation - -SME - What does this do? I see the button, but it does not seem to do anything. - ### Cancel invitation Revoke the invitation of a user that was invited but has not logged in. diff --git a/docs/sources/manage-users/server-admin/server-admin-manage-orgs.md b/docs/sources/manage-users/server-admin/server-admin-manage-orgs.md index 88852398519..1983cf84419 100644 --- a/docs/sources/manage-users/server-admin/server-admin-manage-orgs.md +++ b/docs/sources/manage-users/server-admin/server-admin-manage-orgs.md @@ -35,7 +35,7 @@ Add an organization to your Grafana server. Two things happen: - Grafana creates a new organization with you as the sole member and Admin. -- Grafana opens the new organization [Preferences tab]({{< relref "../../administration/preferences.md" >}}). +- Grafana opens the new organization [Preferences tab]({{< relref "../../administration/preferences/_index.md" >}}). You can now add users or perform other Organization Admin tasks. diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index 4cce086f003..795a132e657 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,6 +8,8 @@ weight = 10000 Here you can find detailed release notes that list everything that is included in every release as well as notices about deprecations, breaking changes as well as changes that relate to plugin development. +- [Release notes for 7.4.2]({{< relref "release-notes-7-4-2" >}}) +- [Release notes for 7.4.1]({{< relref "release-notes-7-4-1" >}}) - [Release notes for 7.4.0]({{< relref "release-notes-7-4-0" >}}) - [Release notes for 7.3.7]({{< relref "release-notes-7-3-7" >}}) - [Release notes for 7.3.6]({{< relref "release-notes-7-3-6" >}}) diff --git a/docs/sources/release-notes/release-notes-7-4-1.md b/docs/sources/release-notes/release-notes-7-4-1.md new file mode 100644 index 00000000000..51fb3cf52d1 --- /dev/null +++ b/docs/sources/release-notes/release-notes-7-4-1.md @@ -0,0 +1,30 @@ ++++ +title = "Release notes for Grafana 7.4.1" +[_build] +list = false ++++ + + + +# Release notes for Grafana 7.4.1 + +### Features and enhancements + +* **Influx**: Make max series limit configurable and show the limiting message if applied. [#31025](https://github.com/grafana/grafana/pull/31025), [@aocenas](https://github.com/aocenas) +* **Make value mappings correctly interpret numeric-like strings**. [#30893](https://github.com/grafana/grafana/pull/30893), [@dprokop](https://github.com/dprokop) +* **Variables**: Adds queryparam formatting option. [#30858](https://github.com/grafana/grafana/pull/30858), [@hugohaggmark](https://github.com/hugohaggmark) + +### Bug fixes + +* **Alerting**: Fixes so notification channels are properly deleted. [#31040](https://github.com/grafana/grafana/pull/31040), [@hugohaggmark](https://github.com/hugohaggmark) +* **BarGauge**: Improvements to value sizing and table inner width calculations. [#30990](https://github.com/grafana/grafana/pull/30990), [@torkelo](https://github.com/torkelo) +* **DashboardLinks**: Fixes crash when link has no title. [#31008](https://github.com/grafana/grafana/pull/31008), [@hugohaggmark](https://github.com/hugohaggmark) +* **Elasticsearch**: Fix alias field value not being shown in query editor. [#30992](https://github.com/grafana/grafana/pull/30992), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Fix log row context errors. [#31088](https://github.com/grafana/grafana/pull/31088), [@Elfo404](https://github.com/Elfo404) +* **Elasticsearch**: Show Size setting for raw_data metric. [#30980](https://github.com/grafana/grafana/pull/30980), [@Elfo404](https://github.com/Elfo404) +* **Graph**: Fixes so graph is shown for non numeric time values. [#30972](https://github.com/grafana/grafana/pull/30972), [@hugohaggmark](https://github.com/hugohaggmark) +* **Logging**: Ignore 'file already closed' error when closing file. [#31119](https://github.com/grafana/grafana/pull/31119), [@aknuds1](https://github.com/aknuds1) +* **Plugins**: Fix plugin signature validation for manifest v2 on Windows. [#31045](https://github.com/grafana/grafana/pull/31045), [@wbrowne](https://github.com/wbrowne) +* **TextPanel**: Fixes so panel title is updated when variables change. [#30884](https://github.com/grafana/grafana/pull/30884), [@hugohaggmark](https://github.com/hugohaggmark) +* **Transforms**: Fixes Outer join issue with duplicate field names not getting the same unique field names as before. [#31121](https://github.com/grafana/grafana/pull/31121), [@torkelo](https://github.com/torkelo) + diff --git a/docs/sources/release-notes/release-notes-7-4-2.md b/docs/sources/release-notes/release-notes-7-4-2.md new file mode 100644 index 00000000000..a1336b38dc2 --- /dev/null +++ b/docs/sources/release-notes/release-notes-7-4-2.md @@ -0,0 +1,30 @@ ++++ +title = "Release notes for Grafana 7.4.2" +[_build] +list = false ++++ + + + +# Release notes for Grafana 7.4.2 + +### Features and enhancements + +* **Explore**: Do not show non queryable data sources in data source picker. [#31144](https://github.com/grafana/grafana/pull/31144), [@torkelo](https://github.com/torkelo) +* **Snapshots**: Do not allow an anonymous user to create snapshots. [#31263](https://github.com/grafana/grafana/pull/31263), [@marefr](https://github.com/marefr) + +### Bug fixes + +* **CloudWatch**: Ensure empty query row errors are not passed to the panel. [#31172](https://github.com/grafana/grafana/pull/31172), [@sunker](https://github.com/sunker) +* **DashboardLinks**: Fix the links that always cause a full page to reload. [#31178](https://github.com/grafana/grafana/pull/31178), [@torkelo](https://github.com/torkelo) +* **DashboardListPanel**: Fix issue with folder picker always showing All and using old form styles. [#31160](https://github.com/grafana/grafana/pull/31160), [@torkelo](https://github.com/torkelo) +* **IPv6**: Support host address configured with enclosing square brackets. [#31226](https://github.com/grafana/grafana/pull/31226), [@aknuds1](https://github.com/aknuds1) +* **Permissions**: Fix team and role permissions on folders/dashboards not displayed for non Grafana Admin users. [#31132](https://github.com/grafana/grafana/pull/31132), [@AgnesToulet](https://github.com/AgnesToulet) +* **Postgres**: Fix timeGroup macro converts long intervals to invalid numbers when TimescaleDB is enabled. [#31179](https://github.com/grafana/grafana/pull/31179), [@kurokochin](https://github.com/kurokochin) +* **Prometheus**: Fix enabling of disabled queries when editing in dashboard. [#31055](https://github.com/grafana/grafana/pull/31055), [@ivanahuckova](https://github.com/ivanahuckova) +* **QueryEditors**: Fix an issue that happens after moving queries then editing would update other queries. [#31193](https://github.com/grafana/grafana/pull/31193), [@torkelo](https://github.com/torkelo) +* **SqlDataSources**: Fix the Show Generated SQL button in query editors. [#31236](https://github.com/grafana/grafana/pull/31236), [@torkelo](https://github.com/torkelo) +* **StatPanels**: Fix an issue where the palette color scheme is not cleared when loading panel. [#31126](https://github.com/grafana/grafana/pull/31126), [@torkelo](https://github.com/torkelo) +* **Variables**: Add the default option back for the data source variable. [#31208](https://github.com/grafana/grafana/pull/31208), [@hugohaggmark](https://github.com/hugohaggmark) +* **Variables**: Fix missing empty elements from regex filters. [#31156](https://github.com/grafana/grafana/pull/31156), [@hugohaggmark](https://github.com/hugohaggmark) + diff --git a/docs/sources/shared/preferences/navigate-to-the-dashboard-list.md b/docs/sources/shared/preferences/navigate-to-the-dashboard-list.md new file mode 100644 index 00000000000..a23eeb861b7 --- /dev/null +++ b/docs/sources/shared/preferences/navigate-to-the-dashboard-list.md @@ -0,0 +1,6 @@ +--- +title: Navigate to the dashboard list +--- + +1. Navigate to the dashboard you want to set as the home dashboard. +1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. \ No newline at end of file diff --git a/docs/sources/shared/preferences/navigate-user-preferences-list.md b/docs/sources/shared/preferences/navigate-user-preferences-list.md new file mode 100644 index 00000000000..7404fc4364b --- /dev/null +++ b/docs/sources/shared/preferences/navigate-user-preferences-list.md @@ -0,0 +1,5 @@ +--- +title: Navigate to user preferences list +--- + +1. On the left menu, hover your cursor over your avatar and then click **Preferences**. \ No newline at end of file diff --git a/docs/sources/shared/preferences/org-preferences-list.md b/docs/sources/shared/preferences/org-preferences-list.md new file mode 100644 index 00000000000..0246b8f65d1 --- /dev/null +++ b/docs/sources/shared/preferences/org-preferences-list.md @@ -0,0 +1,6 @@ +--- +title: Click Org preferences +--- + +1. Hover your cursor over the **Configuration** (gear) icon. +1. Click **Preferences**. \ No newline at end of file diff --git a/docs/sources/shared/preferences/select-home-dashboard-list.md b/docs/sources/shared/preferences/select-home-dashboard-list.md new file mode 100644 index 00000000000..e63a0fdef34 --- /dev/null +++ b/docs/sources/shared/preferences/select-home-dashboard-list.md @@ -0,0 +1,6 @@ +--- +title: Select home dashboard list +--- + +1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. +1. Click **Save**. \ No newline at end of file diff --git a/docs/sources/shared/preferences/select-timezone-list.md b/docs/sources/shared/preferences/select-timezone-list.md new file mode 100644 index 00000000000..4b62b478442 --- /dev/null +++ b/docs/sources/shared/preferences/select-timezone-list.md @@ -0,0 +1,6 @@ +--- +title: Select home dashboard list +--- + +1. Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected at a higher level. Refer to [Time range controls]({{< relref "../../dashboards/time-range-controls.md" >}}) for more information about Grafana time settings. +1. Click **Save**. \ No newline at end of file diff --git a/docs/sources/shared/preferences/select-ui-theme-list.md b/docs/sources/shared/preferences/select-ui-theme-list.md new file mode 100644 index 00000000000..8501eab045f --- /dev/null +++ b/docs/sources/shared/preferences/select-ui-theme-list.md @@ -0,0 +1,6 @@ +--- +title: Select the UI theme +--- + +1. In the Preferences section, select the **UI theme**. +1. Click **Save**. \ No newline at end of file diff --git a/docs/sources/shared/preferences/some-tasks-require-permissions.md b/docs/sources/shared/preferences/some-tasks-require-permissions.md new file mode 100644 index 00000000000..0df3b0f8534 --- /dev/null +++ b/docs/sources/shared/preferences/some-tasks-require-permissions.md @@ -0,0 +1,5 @@ +--- +title: Some tasks require permissions +--- + +Some tasks require certain permissions. For more information about roles, refer to [Permissions]({{< relref "../../permissions/_index.md" >}}). \ No newline at end of file diff --git a/docs/sources/variables/variable-types/add-custom-variable.md b/docs/sources/variables/variable-types/add-custom-variable.md index df37e4249e2..d6eb46275a8 100644 --- a/docs/sources/variables/variable-types/add-custom-variable.md +++ b/docs/sources/variables/variable-types/add-custom-variable.md @@ -24,7 +24,7 @@ For example, if you have server names or region names that never change, then yo ## Enter Custom Options -1. In the **Values separated by comma** list, enter the values for this variable in a comma-separated list. You can include numbers, strings, other variables or key/value pairs separated by a colon. +1. In the **Values separated by comma** list, enter the values for this variable in a comma-separated list. You can include numbers, strings, other variables or key/value pairs separated by a space and a colon, i.e. `key1 : value1,key2 : value2`. 1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}). 1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect. 1. Click **Add** to add the variable to the dashboard. diff --git a/docs/sources/variables/variable-types/global-variables.md b/docs/sources/variables/variable-types/global-variables.md index fce23365490..c81e27d86a3 100644 --- a/docs/sources/variables/variable-types/global-variables.md +++ b/docs/sources/variables/variable-types/global-variables.md @@ -27,7 +27,7 @@ Grafana has two built in time range variables: `$__from` and `$__to`. They are c | `${__from:date}` | 2020-07-13T20:19:09.254Z | No args, defaults to ISO 8601/RFC 3339 | | `${__from:date:iso}` | 2020-07-13T20:19:09.254Z | ISO 8601/RFC 3339 | | `${__from:date:seconds}` | 1594671549 | Unix seconds epoch | -| `${__from:date:YYYY-MM}` | 2020-07 | Any custom [date format](https://momentjs.com/docs/#/displaying/) | +| `${__from:date:YYYY-MM}` | 2020-07 | Any custom [date format](https://momentjs.com/docs/#/displaying/) that does not include the `:` character | The above syntax works with `${__to}` as well. diff --git a/docs/sources/whatsnew/whats-new-in-v7-4.md b/docs/sources/whatsnew/whats-new-in-v7-4.md index eb58a13a274..c3ef607e2d3 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-4.md +++ b/docs/sources/whatsnew/whats-new-in-v7-4.md @@ -3,6 +3,7 @@ title = "What's New in Grafana v7.4" description = "Feature and improvement highlights for Grafana v7.4" keywords = ["grafana", "new", "documentation", "7.4", "release notes"] weight = -31 +aliases = ["/docs/grafana/latest/guides/whats-new-in-v7-4/"] [_build] list = false +++ @@ -161,7 +162,7 @@ Google Cloud Monitoring data source ships with pre-configured dashboards for som {{< docs-imagebox img="/img/docs/google-cloud-monitoring/curated-dashboards-7-4.png" max-width= "650px" >}} -If you want to customize a dashboard, we recommend that you save it under a different name. Otherwise the dashboard will be overwritten when a new version of the dashboard is released. +If you want to customize a dashboard, we recommend that you save it under a different name. Otherwise the dashboard will be overwritten when a new version of the dashboard is released. For more information, refer to the [Google Cloud Monitoring docs]({{< relref "../datasources/google-cloud-monitoring/_index.md/#out-of-the-box-dashboards" >}}). @@ -191,7 +192,7 @@ After the number of Viewers or Editors has reached its license limit, only Admin Grafana Enterprise license tokens update automatically on a daily basis, which means you no longer need to manually update your license, and the process for adding additional users to a license is smoother than it was before. -Refer to [Licensing restrictions](../enterprise/license-restrictions.md) for more information. +Refer to [Licensing restrictions]({{< relref "../enterprise/license-restrictions.md" >}}) for more information. ### Export usage insights to Loki @@ -213,6 +214,12 @@ You can now select a font, other than the default, for Unicode-based scripts. As [Reporting]({{< relref "../enterprise/reporting.md#rendering-configuration" >}}) was updated as a result of this change. +### Request security + +Request security introduces ways to limit requests from the Grafana server, and it targets requests that are generated by users. + +For more information, refer to [Request security]({{< relref "../enterprise/request-security.md" >}}). + ## Breaking changes The following Grafana 7.4 changes might break previous functionality. diff --git a/e2e/suite1/specs/trace-view-scrolling.spec.ts b/e2e/suite1/specs/trace-view-scrolling.spec.ts index 1f52839d16a..58313697072 100644 --- a/e2e/suite1/specs/trace-view-scrolling.spec.ts +++ b/e2e/suite1/specs/trace-view-scrolling.spec.ts @@ -3,7 +3,7 @@ import { e2e } from '@grafana/e2e'; describe('Trace view', () => { it('Can lazy load big traces', () => { e2e.flows.login('admin', 'admin'); - e2e().intercept('GET', '/api/datasources/proxy/29/api/traces/long-trace', { + e2e().intercept('GET', '/api/traces/long-trace', { fixture: 'long-trace-response.json', }); diff --git a/go.mod b/go.mod index 642471a70b8..9dacb42f689 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,10 @@ replace github.com/denisenkom/go-mssqldb => github.com/denisenkom/go-mssqldb v0. replace k8s.io/client-go => k8s.io/client-go v0.18.8 require ( - cloud.google.com/go v0.70.0 // indirect - cloud.google.com/go/storage v1.12.0 + cloud.google.com/go/storage v1.13.0 github.com/BurntSushi/toml v0.3.1 github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f - github.com/aws/aws-sdk-go v1.36.31 + github.com/aws/aws-sdk-go v1.37.7 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 @@ -31,23 +30,23 @@ require ( github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect github.com/fatih/color v1.10.0 github.com/gchaincl/sqlhooks v1.3.0 - github.com/getsentry/sentry-go v0.9.0 + github.com/getsentry/sentry-go v0.10.0 github.com/go-macaron/binding v0.0.0-20190806013118-0b4f37bab25b github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 github.com/go-sourcemap/sourcemap v2.1.3+incompatible 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/mock v1.5.0 github.com/golang/protobuf v1.4.3 github.com/google/go-cmp v0.5.4 github.com/google/uuid v1.2.0 github.com/gosimple/slug v1.9.0 - github.com/grafana/grafana-aws-sdk v0.0.0-20210112151715-f082803d987a + github.com/grafana/grafana-aws-sdk v0.1.0 github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 - github.com/grafana/grafana-plugin-sdk-go v0.83.0 + github.com/grafana/grafana-plugin-sdk-go v0.88.0 github.com/grafana/loki v1.6.2-0.20201026154740-6978ee5d7387 - github.com/grpc-ecosystem/go-grpc-middleware v1.2.1 + github.com/grpc-ecosystem/go-grpc-middleware v1.2.2 github.com/hashicorp/go-hclog v0.15.0 github.com/hashicorp/go-plugin v1.4.0 github.com/hashicorp/go-version v1.2.1 @@ -56,10 +55,10 @@ require ( github.com/jmespath/go-jmespath v0.4.0 github.com/jonboulle/clockwork v0.2.2 // indirect github.com/json-iterator/go v1.1.10 - github.com/jung-kurt/gofpdf v1.10.1 + github.com/jung-kurt/gofpdf v1.16.2 github.com/lib/pq v1.9.0 - github.com/linkedin/goavro/v2 v2.9.7 - github.com/magefile/mage v1.10.0 + github.com/linkedin/goavro/v2 v2.10.0 + github.com/magefile/mage v1.11.0 github.com/mattn/go-isatty v0.0.12 github.com/mattn/go-sqlite3 v1.14.6 github.com/opentracing/opentracing-go v1.2.0 @@ -67,7 +66,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.9.0 github.com/prometheus/client_model v0.2.0 - github.com/prometheus/common v0.15.0 + github.com/prometheus/common v0.17.0 github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/robfig/cron/v3 v3.0.1 github.com/russellhaering/goxmldsig v1.1.0 @@ -83,14 +82,12 @@ require ( github.com/xorcare/pointer v1.1.0 github.com/yudai/gojsondiff v1.0.0 golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 - golang.org/x/net v0.0.0-20201110031124-69a78807bb2b - golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 - golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 + golang.org/x/net v0.0.0-20201224014010-6772e930b67b + golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3 + golang.org/x/sync v0.0.0-20201207232520-09787c993a3a golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e gonum.org/v1/gonum v0.8.2 - google.golang.org/api v0.33.0 - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20201022181438-0ff5f38871d5 // indirect + google.golang.org/api v0.39.0 google.golang.org/grpc v1.35.0 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect @@ -102,5 +99,5 @@ require ( gopkg.in/square/go-jose.v2 v2.5.1 gopkg.in/yaml.v2 v2.4.0 xorm.io/core v0.7.3 - xorm.io/xorm v0.8.1 + xorm.io/xorm v0.8.2 ) diff --git a/go.sum b/go.sum index c52eed2e2aa..711fe529f6c 100644 --- a/go.sum +++ b/go.sum @@ -17,9 +17,11 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV 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/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -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= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0 h1:kpgPA77kSSbjSs+fWHkPTxQ6J5Z2Qkruo5jfXEkHxNQ= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0 h1:XgtDnVJRCPEUG21gjFiRPz4zI1Mjg16R+NYQjfmU4XY= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -40,8 +42,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= 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= +cloud.google.com/go/storage v1.13.0 h1:amPvhCOI+Hltp6rPu+62YdwhIrjf+34PKVAL4HwgYwk= +cloud.google.com/go/storage v1.13.0/go.mod h1:pqFyBUK3zZqMIIU5+8NaZq6/Ma3ClgUg9Hv5jfuJnvo= code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc= collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs= @@ -173,8 +175,8 @@ github.com/aws/aws-sdk-go v1.34.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU 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 v1.35.30/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= -github.com/aws/aws-sdk-go v1.36.31 h1:BMVngapDGAfLBVEVzaSIw3fmJdWx7jOvhLCXgRXbXQI= -github.com/aws/aws-sdk-go v1.36.31/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.37.7 h1:vfald/ssuWaA2HgJ9DrieVVXVE9eD0Kly/9kl0hofbE= +github.com/aws/aws-sdk-go v1.37.7/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= @@ -210,7 +212,7 @@ github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 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/centrifuge-go v0.6.4/go.mod h1:YW9BKhnEMOBPU7C/wfqdqzhIiF0xRd0R4sHW82a7sf8= +github.com/centrifugal/centrifuge-go v0.6.5/go.mod h1:YW9BKhnEMOBPU7C/wfqdqzhIiF0xRd0R4sHW82a7sf8= 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= @@ -232,6 +234,7 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= @@ -337,6 +340,7 @@ github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4s github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= @@ -381,8 +385,8 @@ github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv github.com/gchaincl/sqlhooks v1.3.0 h1:yKPXxW9a5CjXaVf2HkQn6wn7TZARvbAOAelr3H8vK2Y= github.com/gchaincl/sqlhooks v1.3.0/go.mod h1:9BypXnereMT0+Ys8WGWHqzgkkOfHIhyeUCqXC24ra34= github.com/getkin/kin-openapi v0.13.0/go.mod h1:WGRs2ZMM1Q8LR1QBEwUxC6RJEfaBcD0s+pcEVXFuAjw= -github.com/getsentry/sentry-go v0.9.0 h1:KIfpY/D9hX3gWAEd3d8z6ImuHNWtqEsjlpdF8zXFsHM= -github.com/getsentry/sentry-go v0.9.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws= +github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g= +github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= @@ -567,6 +571,8 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt 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= +github.com/golang/mock v1.5.0 h1:jlYHihg//f7RRwuPfptm04yp4s7O6Kw8EZiVYIGcH0g= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -620,6 +626,8 @@ github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPg github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0 h1:pMen7vLs8nvgEYhywH3KDWJIJTeEr2ULsVWHWYHQyBs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -630,9 +638,10 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200507031123-427632fa3b1c/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200615235658-03e1cf38a040/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201007051231-1066cbb265c7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201009210932-67992a1a5a35/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 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= @@ -676,21 +685,21 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs= github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= -github.com/grafana/grafana-aws-sdk v0.0.0-20210112151715-f082803d987a h1:VB7i+rh7N4edvF9fJq9YYKkH1SjsKBgZYtYyDqeAfIg= -github.com/grafana/grafana-aws-sdk v0.0.0-20210112151715-f082803d987a/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbRkcTyQYqMiAj7G5U= +github.com/grafana/grafana-aws-sdk v0.1.0 h1:25TSkS57lvVen3nTCNMu5SdI8ju5Z0f7cvCsukhlqJc= +github.com/grafana/grafana-aws-sdk v0.1.0/go.mod h1:+pPo5U+pX0zWimR7YBc7ASeSQfbRkcTyQYqMiAj7G5U= 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.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60= -github.com/grafana/grafana-plugin-sdk-go v0.83.0 h1:X84eJMLSx0KOTRW1EziXkqLw9pSmK0RggWC98ImX/9g= -github.com/grafana/grafana-plugin-sdk-go v0.83.0/go.mod h1:exQQHhClzHs2gOwjPSO4FOKwjjZ8VrnzbbABHX8LB6U= +github.com/grafana/grafana-plugin-sdk-go v0.88.0 h1:Up4HGwlzHTb3XGljteirrqY7g0UNbMC46/eYNqVhHdw= +github.com/grafana/grafana-plugin-sdk-go v0.88.0/go.mod h1:PTALh0lz+Y7k0+OMczAABTpeocL63aw6FVOBptp5GVo= github.com/grafana/loki v1.6.2-0.20201026154740-6978ee5d7387 h1:iwcM8lkYJ3EhytGLJ2BvRSwutb0QWoI7EWbYv3yJRsY= github.com/grafana/loki v1.6.2-0.20201026154740-6978ee5d7387/go.mod h1:jHA1OHnPsuj3LLgMXmFopsKDt4ARHHUhrmT3JrGf71g= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.1.0/go.mod h1:f5nM7jw/oeRSadq3xCzHAvxcr8HZnzsqU6ILg/0NiiE= github.com/grpc-ecosystem/go-grpc-middleware v1.2.0/go.mod h1:mJzapYve32yjrKlk9GbyCZHuPgZsrbyIbyKhSzOpg6s= -github.com/grpc-ecosystem/go-grpc-middleware v1.2.1 h1:V59tBiPuMkySHwJkuq/OYkK0WnOLwCwD3UkTbEMr12U= -github.com/grpc-ecosystem/go-grpc-middleware v1.2.1/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.2 h1:FlFbCRLd5Jr4iYXZufAvgWN6Ao0JrI5chLINnUXDDr0= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= @@ -859,8 +868,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/jung-kurt/gofpdf v1.10.1 h1:mbprTswkr0n86clAmJ4NGCFC4fdGySCAshWzrYb3xbE= -github.com/jung-kurt/gofpdf v1.10.1/go.mod h1:s/VXv+TdctEOx2wCEguezYaR7f0OwUAd6H9VGfRkcSs= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= github.com/jwilder/encoding v0.0.0-20170811194829-b4e1701a28ef/go.mod h1:Ct9fl0F6iIOGgxJ5npU/IUOhOhqlVrGjyIZc8/MagT0= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= @@ -922,14 +931,16 @@ github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.0/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= -github.com/linkedin/goavro/v2 v2.9.7 h1:Vd++Rb/RKcmNJjM0HP/JJFMEWa21eUBVKPYlKehOGrM= -github.com/linkedin/goavro/v2 v2.9.7/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= +github.com/linkedin/goavro/v2 v2.10.0 h1:eTBIRoInBM88gITGXYtUSqqxLTFXfOsJBiX8ZMW0o4U= +github.com/linkedin/goavro/v2 v2.10.0/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/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g= github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= +github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -1185,6 +1196,8 @@ github.com/prometheus/common v0.14.0 h1:RHRyE8UocrbjU+6UvRzwi6HjiDfxrrBU91TtbKzk github.com/prometheus/common v0.14.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= github.com/prometheus/common v0.15.0 h1:4fgOnadei3EZvgRwxJ7RMpG1k1pOZth5Pc13tyspaKM= github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.17.0 h1:kDIZLI74SS+3tedSvEkykgBkD7txMxaJAPj8DtJUKYA= +github.com/prometheus/common v0.17.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289 h1:dTUS1vaLWq+Y6XKOTnrFpoVsQKLCbCp1OLj24TDi7oM= github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289/go.mod h1:FGbBv5OPKjch+jNUJmEQpMZytIdyW0NdBtWFcfSKusc= github.com/prometheus/procfs v0.0.0-20180612222113-7d6f385de8be/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= @@ -1505,8 +1518,8 @@ golang.org/x/exp v0.0.0-20200821190819-94841d0725da h1:vfV2BR+q1+/jmgJR30Ms3RHbr golang.org/x/exp v0.0.0-20200821190819-94841d0725da/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190507092727-e4e5bf290fec/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -1518,6 +1531,8 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= @@ -1527,6 +1542,10 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0 h1:8pl+sMODzuvGJkmj2W4kZihvVb5mKm8pB/X44PIQHv8= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1579,12 +1598,15 @@ golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 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-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11 h1:lwlPPsmjDKK0J6eG6xDWd5XPehI0R024zxjDnw3esPA= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1594,6 +1616,11 @@ golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4Iltr 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= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5 h1:Lm4OryKCca1vehdsWogr9N4t7NfZxLbJoc/H0w4K4S4= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3 h1:BaN3BAqnopnKjvl+15DYP6LLrbBHfbfmlFYzmFj/Q9Q= +golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1606,6 +1633,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200930132711-30421366ff76/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= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1688,15 +1717,22 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201008064518-c1f3e3309c71/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs= golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad h1:MCsdmFSdEd4UEa5TKS5JztCRHK/WtvNei1edOj5RSRo= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1705,6 +1741,8 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1790,14 +1828,18 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200822203824-307de81be3f4/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/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= golang.org/x/tools v0.0.0-20201008025239-9df69603baec/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= -golang.org/x/tools v0.0.0-20201017001424-6003fad69a88/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201226215659-b1c90890d22a h1:pdfjQ7VswBeGam3EpuEJ4e8EAb7JgaubV570LO/SIQM= golang.org/x/tools v0.0.0-20201226215659-b1c90890d22a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818 h1:u2ssHESKr0HP2d1wlnjMKH+V/22Vg1lGCVuXmOYU1qA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1832,10 +1874,12 @@ google.golang.org/api v0.26.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 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= google.golang.org/api v0.32.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.33.0 h1:+gL0XvACeMIvpwLZ5rQZzLn5cwOsgg8dIcfJ2SYfBVw= -google.golang.org/api v0.33.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.38.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.39.0 h1:zHCTXf0NeDdKTgcSQpT+ZflWAqHsEp1GmdpxW09f3YM= +google.golang.org/api v0.39.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1890,13 +1934,15 @@ google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70/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/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/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= -google.golang.org/genproto v0.0.0-20201022181438-0ff5f38871d5/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d h1:HV9Z9qMhQEsdlvxNFELgQ11RkMzO3CMkjEySjCtuLes= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210203152818-3206188e46ba h1:np3A9jnmE/eMtrOwwvUycmQ1XoLyj5nqZ41bAyYLqJ0= +google.golang.org/genproto v0.0.0-20210203152818-3206188e46ba/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -1921,6 +1967,8 @@ google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0 h1:TwIQcH3es+MojMVojxxfQ3l3OF2KzlRxML2xZq0kRo8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -2059,5 +2107,5 @@ xorm.io/builder v0.3.6/go.mod h1:LEFAPISnRzG+zxaxj2vPicRwz67BdhFreKg8yv8/TgU= xorm.io/core v0.7.2/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= xorm.io/core v0.7.3 h1:W8ws1PlrnkS1CZU1YWaYLMQcQilwAmQXU0BJDJon+H0= xorm.io/core v0.7.3/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= -xorm.io/xorm v0.8.1 h1:4f2KXuQxVdaX3RdI3Fw81NzMiSpZeyCZt8m3sEVeIkQ= -xorm.io/xorm v0.8.1/go.mod h1:ZkJLEYLoVyg7amJK/5r779bHyzs2AU8f8VMiP6BM7uY= +xorm.io/xorm v0.8.2 h1:nbg1AyWn7iLrwp0Dqg8IrYOBkBYYJ85ry9bvZLVl4Ok= +xorm.io/xorm v0.8.2/go.mod h1:ZkJLEYLoVyg7amJK/5r779bHyzs2AU8f8VMiP6BM7uY= diff --git a/latest.json b/latest.json index 74b065ab8ec..5ea01b4b9aa 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "7.4.0", - "testing": "7.4.0" + "stable": "7.4.2", + "testing": "7.4.2" } diff --git a/package.json b/package.json index 71242a47f8b..371f500ab32 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@emotion/core": "10.0.27", "@grafana/api-documenter": "7.11.2", "@grafana/api-extractor": "7.10.1", - "@grafana/eslint-config": "2.1.1", + "@grafana/eslint-config": "2.2.0", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@testing-library/jest-dom": "5.11.5", "@testing-library/react": "11.1.2", @@ -175,6 +175,7 @@ "postcss-reporter": "6.0.1", "prettier": "2.2.1", "react-hot-loader": "4.8.0", + "react-select-event": "^5.1.0", "react-test-renderer": "16.12.0", "redux-mock-store": "1.5.4", "regexp-replace-loader": "1.0.1", diff --git a/packages/grafana-data/src/events/EventBus.ts b/packages/grafana-data/src/events/EventBus.ts index b917cec40e3..a9d1020b626 100644 --- a/packages/grafana-data/src/events/EventBus.ts +++ b/packages/grafana-data/src/events/EventBus.ts @@ -1,7 +1,14 @@ import EventEmitter from 'eventemitter3'; import { Unsubscribable, Observable } from 'rxjs'; -import { AppEvent } from './types'; -import { EventBus, LegacyEmitter, BusEventHandler, BusEventType, LegacyEventHandler, BusEvent } from './types'; +import { + EventBus, + LegacyEmitter, + BusEventHandler, + BusEventType, + LegacyEventHandler, + BusEvent, + AppEvent, +} from './types'; /** * @alpha diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 2c4786ef97c..c5525217353 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -27,9 +27,11 @@ interface DisplayProcessorOptions { // Reasonable units for time const timeFormats: KeyValue = { dateTimeAsIso: true, - dateTimeAsIsoSmart: true, + dateTimeAsIsoNoDateIfToday: true, dateTimeAsUS: true, - dateTimeAsUSSmart: true, + dateTimeAsUSNoDateIfToday: true, + dateTimeAsLocal: true, + dateTimeAsLocalNoDateIfToday: true, dateTimeFromNow: true, }; diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts index 2d560730c4d..2da0b532a3b 100644 --- a/packages/grafana-data/src/field/fieldColor.ts +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -37,7 +37,7 @@ export const fieldColorModeRegistry = new Registry(() => { }, // new FieldColorSchemeMode({ // id: FieldColorModeId.PaletteSaturated, - // name: 'By series / Saturated palette', + // name: 'Saturated palette', // //description: 'Assigns color based on series or field index', // isContinuous: false, // isByValue: false, @@ -45,7 +45,7 @@ export const fieldColorModeRegistry = new Registry(() => { // 'blue', // 'red', // 'green', - // 'yellow', + // 'orange', // 'purple', // 'orange', // 'dark-blue', diff --git a/packages/grafana-data/src/field/fieldState.ts b/packages/grafana-data/src/field/fieldState.ts index 6393133e80e..255e2ddb91c 100644 --- a/packages/grafana-data/src/field/fieldState.ts +++ b/packages/grafana-data/src/field/fieldState.ts @@ -38,10 +38,8 @@ export function getFieldDisplayName(field: Field, frame?: DataFrame, allFrames?: } const displayName = calculateFieldDisplayName(field, frame, allFrames); - field.state = { - ...field.state, - displayName, - }; + field.state = field.state || {}; + field.state.displayName = displayName; return displayName; } diff --git a/packages/grafana-data/src/field/scale.ts b/packages/grafana-data/src/field/scale.ts index f041d041c4f..62280274224 100644 --- a/packages/grafana-data/src/field/scale.ts +++ b/packages/grafana-data/src/field/scale.ts @@ -65,14 +65,20 @@ function getMinMaxAndDelta(field: Field): NumericRange { }; } +/** + * @internal + */ export function getFieldConfigWithMinMax(field: Field, local?: boolean): FieldConfig { const { config } = field; let { min, max } = config; - if (isNumber(min) && !isNumber(max)) { - return config; // noop + + if (isNumber(min) && isNumber(max)) { + return config; } + if (local || !field.state?.range) { return { ...config, ...getMinMaxAndDelta(field) }; } + return { ...config, ...field.state.range }; } diff --git a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts index 0456b6d4df0..7b7d34954bf 100644 --- a/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts +++ b/packages/grafana-data/src/transformations/transformers/ensureColumns.test.ts @@ -68,9 +68,7 @@ describe('ensureColumns transformer', () => { "config": Object {}, "labels": Object {}, "name": "A", - "state": Object { - "displayName": "A", - }, + "state": Object {}, "type": "number", "values": Array [ 1, @@ -81,9 +79,7 @@ describe('ensureColumns transformer', () => { "config": Object {}, "labels": Object {}, "name": "B", - "state": Object { - "displayName": "B", - }, + "state": Object {}, "type": "number", "values": Array [ 2, @@ -94,9 +90,7 @@ describe('ensureColumns transformer', () => { "config": Object {}, "labels": Object {}, "name": "C", - "state": Object { - "displayName": "C", - }, + "state": Object {}, "type": "number", "values": Array [ 3, @@ -107,9 +101,7 @@ describe('ensureColumns transformer', () => { "config": Object {}, "labels": Object {}, "name": "D", - "state": Object { - "displayName": "D", - }, + "state": Object {}, "type": "string", "values": Array [ "first", diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts index c83613a3460..5f6a5a10018 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.test.ts @@ -107,7 +107,6 @@ describe('align frames', () => { Object { "name": "TheTime", "state": Object { - "displayName": "TheTime", "origin": Object { "fieldIndex": 0, "frameIndex": 0, @@ -122,7 +121,6 @@ describe('align frames', () => { Object { "name": "A1", "state": Object { - "displayName": "A1", "origin": Object { "fieldIndex": 1, "frameIndex": 0, @@ -137,7 +135,6 @@ describe('align frames', () => { Object { "name": "A2", "state": Object { - "displayName": "A2", "origin": Object { "fieldIndex": 1, "frameIndex": 1, @@ -164,7 +161,6 @@ describe('align frames', () => { Object { "name": "TheTime", "state": Object { - "displayName": "TheTime", "origin": Object { "fieldIndex": 0, "frameIndex": 0, @@ -174,7 +170,6 @@ describe('align frames', () => { Object { "name": "A1", "state": Object { - "displayName": "A1", "origin": Object { "fieldIndex": 1, "frameIndex": 0, diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index 26aa964d989..f5966b7b8fb 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -3,7 +3,6 @@ import { ArrayVector } from '../../vector'; import { fieldMatchers } from '../matchers'; import { FieldMatcherID } from '../matchers/ids'; import { getTimeField, sortDataFrame } from '../../dataframe'; -import { getFieldDisplayName } from '../../field'; export function pickBestJoinField(data: DataFrame[]): FieldMatcher { const { timeField } = getTimeField(data[0]); @@ -114,16 +113,18 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined for (let frameIndex = 0; frameIndex < options.frames.length; frameIndex++) { const frame = options.frames[frameIndex]; + if (!frame || !frame.fields?.length) { continue; // skip the frame } - const nullModesFrame: JoinNullMode[] = [NULL_REMOVE]; + const nullModesFrame: JoinNullMode[] = [NULL_REMOVE]; let join: Field | undefined = undefined; let fields: Field[] = []; + for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) { const field = frame.fields[fieldIndex]; - getFieldDisplayName(field, frame, options.frames); // cache displayName in state + field.state = field.state || {}; if (!join && joinFieldMatcher(field, frame, options.frames)) { join = field; @@ -147,7 +148,7 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined } if (options.keepOriginIndices) { - field.state!.origin = { + field.state.origin = { frameIndex, fieldIndex, }; @@ -161,13 +162,17 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined if (originalFields.length === 0) { originalFields.push(join); // first join field } - nullModes.push(nullModesFrame); + nullModes.push(nullModesFrame); const a: AlignedData = [join.values.toArray()]; // + for (const field of fields) { a.push(field.values.toArray()); originalFields.push(field); + // clear field displayName state + delete field.state?.displayName; } + allData.push(a); } diff --git a/packages/grafana-data/src/transformations/transformers/reduce.ts b/packages/grafana-data/src/transformations/transformers/reduce.ts index 72ed775fc6e..63a45492ccb 100644 --- a/packages/grafana-data/src/transformations/transformers/reduce.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.ts @@ -1,7 +1,7 @@ import { map } from 'rxjs/operators'; import { DataTransformerID } from './ids'; -import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { DataTransformerInfo, MatcherConfig, FieldMatcher } from '../../types/transformations'; import { fieldReducers, reduceField, ReducerID } from '../fieldReducer'; import { alwaysFieldMatcher, notTimeFieldMatcher } from '../matchers/predicates'; import { DataFrame, Field, FieldType } from '../../types/dataFrame'; @@ -10,7 +10,6 @@ import { KeyValue } from '../../types/data'; import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; import { getFieldMatcher } from '../matchers'; import { getFieldDisplayName } from '../../field'; -import { FieldMatcher } from '../../types/transformations'; export enum ReduceTransformerMode { SeriesToRows = 'seriesToRows', // default diff --git a/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts b/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts index 451277697c2..d97edad7ef0 100644 --- a/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts +++ b/packages/grafana-data/src/transformations/transformers/seriesToColumns.test.ts @@ -68,9 +68,7 @@ describe('SeriesToColumns Transformer', () => { "name": "even", }, "name": "temperature", - "state": Object { - "displayName": "even temperature", - }, + "state": Object {}, "type": "number", "values": Array [ undefined, @@ -87,9 +85,7 @@ describe('SeriesToColumns Transformer', () => { "name": "even", }, "name": "humidity", - "state": Object { - "displayName": "even humidity", - }, + "state": Object {}, "type": "number", "values": Array [ undefined, @@ -106,9 +102,7 @@ describe('SeriesToColumns Transformer', () => { "name": "odd", }, "name": "temperature", - "state": Object { - "displayName": "odd temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 11.1, @@ -125,9 +119,7 @@ describe('SeriesToColumns Transformer', () => { "name": "odd", }, "name": "humidity", - "state": Object { - "displayName": "odd humidity", - }, + "state": Object {}, "type": "number", "values": Array [ 11000.1, @@ -201,9 +193,7 @@ describe('SeriesToColumns Transformer', () => { "name": "even", }, "name": "temperature", - "state": Object { - "displayName": "even temperature", - }, + "state": Object {}, "type": "number", "values": Array [ undefined, @@ -220,9 +210,7 @@ describe('SeriesToColumns Transformer', () => { "name": "even", }, "name": "humidity", - "state": Object { - "displayName": "even humidity", - }, + "state": Object {}, "type": "number", "values": Array [ undefined, @@ -239,9 +227,7 @@ describe('SeriesToColumns Transformer', () => { "name": "odd", }, "name": "temperature", - "state": Object { - "displayName": "odd temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 11.1, @@ -258,9 +244,7 @@ describe('SeriesToColumns Transformer', () => { "name": "odd", }, "name": "humidity", - "state": Object { - "displayName": "odd humidity", - }, + "state": Object {}, "type": "number", "values": Array [ 11000.1, @@ -328,9 +312,7 @@ describe('SeriesToColumns Transformer', () => { "name": "temperature", }, "name": "temperature", - "state": Object { - "displayName": "temperature temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 1, @@ -345,9 +327,7 @@ describe('SeriesToColumns Transformer', () => { "name": "B", }, "name": "temperature", - "state": Object { - "displayName": "B temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 2, @@ -416,9 +396,7 @@ describe('SeriesToColumns Transformer', () => { "name": "A", }, "name": "temperature", - "state": Object { - "displayName": "A temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 10, @@ -432,9 +410,7 @@ describe('SeriesToColumns Transformer', () => { "name": "C", }, "name": "temperature", - "state": Object { - "displayName": "C temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 20, @@ -489,9 +465,7 @@ describe('SeriesToColumns Transformer', () => { "config": Object {}, "labels": Object {}, "name": "temperature", - "state": Object { - "displayName": "temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 10, @@ -501,9 +475,7 @@ describe('SeriesToColumns Transformer', () => { "config": Object {}, "labels": Object {}, "name": "temperature", - "state": Object { - "displayName": "temperature", - }, + "state": Object {}, "type": "number", "values": Array [ 20, diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 43efe6b1786..655b6396742 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -125,4 +125,5 @@ export interface GrafanaConfig { http2Enabled: boolean; dateFormats?: SystemDateFormatSettings; sentry: SentryConfig; + customTheme?: any; } diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 2a7dab4ea8c..b83f18277d1 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -33,7 +33,7 @@ export interface DataLink { onClick?: (event: DataLinkClickEvent) => void; // If dataLink represents internal link this has to be filled. Internal link is defined as a query in a particular - // datas ource that we want to show to the user. Usually this results in a link to explore but can also lead to + // data source that we want to show to the user. Usually this results in a link to explore but can also lead to // more custom onClick behaviour if needed. // @internal and subject to change in future releases internal?: InternalDataLink; diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index c5a8719ba85..6a7aff0487a 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -549,6 +549,7 @@ export interface DataSourceSettings { + it.each` + format | decimals | value | expected + ${'currencyUSD'} | ${2} | ${1532.82} | ${'$1.53K'} + ${'currencyKRW'} | ${2} | ${1532.82} | ${'₩1.53K'} + ${'currencyIDR'} | ${2} | ${1532.82} | ${'Rp1.53K'} + ${'none'} | ${undefined} | ${3.23} | ${'3.23'} + ${'none'} | ${undefined} | ${0.0245} | ${'0.0245'} + ${'none'} | ${undefined} | ${1 / 3} | ${'0.333'} + ${'ms'} | ${4} | ${0.0024} | ${'0.0024 ms'} + ${'ms'} | ${0} | ${100} | ${'100 ms'} + ${'ms'} | ${2} | ${1250} | ${'1.25 s'} + ${'ms'} | ${1} | ${10000086.123} | ${'2.8 hour'} + ${'ms'} | ${undefined} | ${1000} | ${'1 s'} + ${'ms'} | ${0} | ${1200} | ${'1 s'} + ${'short'} | ${undefined} | ${1000} | ${'1 K'} + ${'short'} | ${undefined} | ${1200} | ${'1.20 K'} + ${'short'} | ${undefined} | ${1250} | ${'1.25 K'} + ${'short'} | ${undefined} | ${1000000} | ${'1 Mil'} + ${'short'} | ${undefined} | ${1500000} | ${'1.50 Mil'} + ${'short'} | ${undefined} | ${1000120} | ${'1.00 Mil'} + ${'short'} | ${undefined} | ${98765} | ${'98.8 K'} + ${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'} + ${'short'} | ${undefined} | ${9876543} | ${'9.88 Mil'} + ${'kbytes'} | ${undefined} | ${10000000} | ${'9.54 GiB'} + ${'deckbytes'} | ${undefined} | ${10000000} | ${'10 GB'} + ${'megwatt'} | ${3} | ${1000} | ${'1.000 GW'} + ${'kohm'} | ${3} | ${1000} | ${'1.000 MΩ'} + ${'Mohm'} | ${3} | ${1000} | ${'1.000 GΩ'} + ${'farad'} | ${3} | ${1000} | ${'1.000 kF'} + ${'µfarad'} | ${3} | ${1000} | ${'1.000 mF'} + ${'nfarad'} | ${3} | ${1000} | ${'1.000 µF'} + ${'pfarad'} | ${3} | ${1000} | ${'1.000 nF'} + ${'ffarad'} | ${3} | ${1000} | ${'1.000 pF'} + ${'henry'} | ${3} | ${1000} | ${'1.000 kH'} + ${'mhenry'} | ${3} | ${1000} | ${'1.000 H'} + ${'µhenry'} | ${3} | ${1000} | ${'1.000 mH'} + ${'a'} | ${0} | ${1532.82} | ${'1533 a'} + ${'b'} | ${0} | ${1532.82} | ${'1533 b'} + ${'prefix:b'} | ${undefined} | ${1532.82} | ${'b1533'} + ${'suffix:d'} | ${undefined} | ${1532.82} | ${'1533 d'} + ${'si:µF'} | ${2} | ${1234} | ${'1.23 mF'} + ${'si:µF'} | ${2} | ${1234000000} | ${'1.23 kF'} + ${'si:µF'} | ${2} | ${1234000000000000} | ${'1.23 GF'} + ${'count:xpm'} | ${2} | ${1234567} | ${'1.23M xpm'} + ${'count:x/min'} | ${2} | ${1234} | ${'1.23K x/min'} + ${'currency:@'} | ${2} | ${1234567} | ${'@1.23M'} + ${'currency:@'} | ${2} | ${1234} | ${'@1.23K'} + ${'time:YYYY'} | ${0} | ${dateTime(new Date(1999, 6, 2)).valueOf()} | ${'1999'} + ${'time:YYYY.MM'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010.07'} + ${'dateTimeAsIso'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'} + ${'dateTimeAsUS'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'07/02/2010 12:00:00 am'} + ${'dateTimeAsSystem'} | ${0} | ${dateTime(new Date(2010, 6, 2)).valueOf()} | ${'2010-07-02 00:00:00'} + ${'dtdurationms'} | ${undefined} | ${100000} | ${'1 minute'} + `( + 'With format=$format decimals=$decimals and value=$value then result shoudl be = $expected', + async ({ format, value, decimals, expected }) => { + const result = getValueFormat(format)(value, decimals, undefined, undefined); + const full = formattedValueToString(result); + expect(full).toBe(expected); + } + ); + it('Manually check a format', () => { // helpful for adding tests one at a time with the debugger const tests: ValueFormatTest[] = [ @@ -104,16 +86,6 @@ describe('valueFormats', () => { expect(full).toBe(test.result); }); - for (const test of formatTests) { - describe(`value format: ${test.id}`, () => { - it(`should translate ${test.value} as ${test.result}`, () => { - const result = getValueFormat(test.id)(test.value, test.decimals, test.scaledDecimals); - const full = formattedValueToString(result); - expect(full).toBe(test.result); - }); - }); - } - describe('normal cases', () => { it('toFixed should handle number correctly if decimal is null', () => { expect(toFixed(100)).toBe('100'); diff --git a/packages/grafana-e2e/src/flows/addDashboard.ts b/packages/grafana-e2e/src/flows/addDashboard.ts index a94a679db2d..1dc1807c034 100644 --- a/packages/grafana-e2e/src/flows/addDashboard.ts +++ b/packages/grafana-e2e/src/flows/addDashboard.ts @@ -37,7 +37,6 @@ interface AddVariableRequired { export type PartialAddVariableConfig = Partial & AddVariableOptional & AddVariableRequired; export type AddVariableConfig = AddVariableDefault & AddVariableOptional & AddVariableRequired; -// @todo this actually returns type `Cypress.Chainable` export const addDashboard = (config?: Partial) => { const fullConfig: AddDashboardConfig = { annotations: [], @@ -64,7 +63,7 @@ export const addDashboard = (config?: Partial) => { fullConfig.variables = addVariables(variables); - e2e.components.BackButton.backArrow().click(); + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); } setDashboardTimeRange(timeRange); @@ -152,29 +151,32 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); } - const { constantValue, dataSource, hide, label, name, query, regex, type } = fullConfig; + const { constantValue, dataSource, label, name, query, regex, type } = fullConfig; // This field is key to many reactive changes if (type !== VARIABLE_TYPE_QUERY) { - e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect().select(type); - } - - // Avoid '', which is an accepted value - if (hide !== undefined) { - e2e.pages.Dashboard.Settings.Variables.Edit.General.generalHideSelect().select(hide); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.singleValue().should('have.text', 'Query').click().type(`${type}{enter}`); + }); } if (label) { e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInput().type(label); } - e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().type(name); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInput().clear().type(name); if ( dataSource && (type === VARIABLE_TYPE_AD_HOC_FILTERS || type === VARIABLE_TYPE_DATASOURCE || type === VARIABLE_TYPE_QUERY) ) { - e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect().select(dataSource); + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.Select.input().should('be.visible').type(`${dataSource}{enter}`); + }); } if (constantValue && type === VARIABLE_TYPE_CONSTANT) { @@ -193,13 +195,12 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar // Avoid flakiness e2e().focused().blur(); - e2e() - .contains('.gf-form-group', 'Preview of values') - .within(() => { + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('exist') + .within((previewOfValues) => { if (type === VARIABLE_TYPE_CONSTANT) { - e2e() - .root() - .contains(constantValue as string); + expect(previewOfValues.text()).equals(constantValue); } }); diff --git a/packages/grafana-e2e/src/flows/configurePanel.ts b/packages/grafana-e2e/src/flows/configurePanel.ts index 0c4673c36ad..07cedf08eb6 100644 --- a/packages/grafana-e2e/src/flows/configurePanel.ts +++ b/packages/grafana-e2e/src/flows/configurePanel.ts @@ -34,6 +34,7 @@ interface ConfigurePanelOptional { panelTitle?: string; timeRange?: TimeRangeConfig; visualizationName?: string; + matchExploreTable?: boolean; } interface ConfigurePanelRequired { @@ -75,6 +76,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC dataSourceName, isEdit, isExplore, + matchExploreTable, matchScreenshot, panelTitle, queriesForm, @@ -189,7 +191,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC } // Avoid annotations flakiness - e2e().get('.refresh-picker-buttons .btn').first().click(); + e2e.components.RefreshPicker.runButton().should('be.visible').click(); e2e().wait('@chartData'); @@ -200,7 +202,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC let visualization; if (isExplore) { - visualization = e2e.pages.Explore.General.graph(); + visualization = matchExploreTable ? e2e.pages.Explore.General.table() : e2e.pages.Explore.General.graph(); } else { visualization = e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content'); } diff --git a/packages/grafana-e2e/src/index.ts b/packages/grafana-e2e/src/index.ts index 03143650308..87a0a6a8f25 100644 --- a/packages/grafana-e2e/src/index.ts +++ b/packages/grafana-e2e/src/index.ts @@ -6,7 +6,7 @@ import { e2eScenario, ScenarioArguments } from './support/scenario'; import { getScenarioContext, setScenarioContext } from './support/scenarioContext'; import { e2eFactory } from './support'; -import { selectors } from '@grafana/e2e-selectors'; +import { E2ESelectors, Selectors, selectors } from '@grafana/e2e-selectors'; import * as flows from './flows'; import * as typings from './typings'; @@ -22,6 +22,7 @@ const e2eObject = { flows, getScenarioContext, setScenarioContext, + getSelectors: (selectors: E2ESelectors) => e2eFactory({ selectors }), }; export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject); diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 5685b0674ce..4eb8e96c0c2 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -69,6 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig { }; marketplaceUrl?: string; expressionsEnabled = false; + customTheme?: any; constructor(options: GrafanaBootConfig) { this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 1edc5b6f3b2..25094299e9e 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -28,12 +28,31 @@ export interface DataSourceSrv { /** @public */ export interface GetDataSourceListFilters { + /** Include mixed deta source by setting this to true */ mixed?: boolean; + + /** Only return data sources that support metrics response */ metrics?: boolean; + + /** Only return data sources that support tracing response */ tracing?: boolean; + + /** Only return data sources that support annotations */ annotations?: boolean; + + /** + * By default only data sources that can be queried will be returned. Meaning they have tracing, + * metrics, logs or annotations flag set in plugin.json file + * */ + all?: boolean; + + /** Set to true to return dashboard data source */ dashboard?: boolean; + + /** Set to true to return data source variables */ variables?: boolean; + + /** filter list by plugin */ pluginId?: string; } 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 b3919a27704..dc617a84e43 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.1.2-alpine" +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.2.0-alpine" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall index 06d07a8c2de..b82fd3e54af 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/install/bin/ginstall @@ -47,8 +47,8 @@ done # Make sure the script is being run as root if [ $EUID -ne 0 ]; then - echo "This script must be run as root" - exit 1 + echo "This script must be run as root" + exit 1 fi ## @@ -63,10 +63,11 @@ if [ "$version" == "latest" ]; then elif [ "$version" == "canary" ]; then version="$canary" wget -O - "https://dl.grafana.com/oss/master/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +else + wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - fi /bin/rm -rf /opt/grafana > /dev/null 2>&1 || true ln -s /opt/grafana-${version} /opt/grafana # nohup /opt/grafana/bin/grafana-server -config /opt/grafana/conf/defaults.ini -homepath /opt/grafana >/dev/null 2>&1 & - 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 9e384260503..f6ac93bc83c 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,17 +18,17 @@ apk add --no-cache curl 'nodejs-current=14.5.0-r0' npm yarn build-base openssh g # 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.6.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "3918e6cc85e7eaaa6f859f1bdbaac772e7a825b0eb423c63d3ae68b21f84b844" +filename="go1.15.7.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "0d142143794721bb63ce6c8a6180c4062bcf8ef4715e7d6d6609f3a8282629b3" untar_file "/tmp/$filename" # Install golangci-lint -GOLANGCILINT_VERSION=1.34.1 +GOLANGCILINT_VERSION=1.37.0 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" \ - "23e4a9d8f89729007c6d749c245f725c2dbcfb194f4099003f9b826f1d386ad1" -untar_file "/tmp/${filename}.tar.gz" + "/tmp/$filename.tar.gz" \ + "5fe9852e754b621c5264fb8ac810a75033e7f18e972315a60c5c3f8a37b3cb88" +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 ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt @@ -40,10 +40,10 @@ apk add --no-cache fontconfig zip jq # Install code climate get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ "/usr/local/bin/cc-test-reporter" \ - "b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32" -chmod +x /usr/local/bin/cc-test-reporter + "e1be1930379bd169d3a8e82135cf57216ad52ecfaf520b5804f269721e4dcc3d" +chmod 755 /usr/local/bin/cc-test-reporter -curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.36/grabpl" +curl -fL -o /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl" apk add --no-cache git # Install Mage diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh index 08da2709cc5..98d0f02ee2c 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh @@ -5,5 +5,5 @@ ## DOCKER_IMAGE_BASE_NAME="grafana/grafana-plugin-ci-e2e" -DOCKER_IMAGE_VERSION="1.1.0" +DOCKER_IMAGE_VERSION="1.2.0" DOCKER_IMAGE_NAME="${DOCKER_IMAGE_BASE_NAME}:${DOCKER_IMAGE_VERSION}" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall index 5846be8243f..13a30ffbd0b 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/install/ginstall @@ -47,8 +47,8 @@ done # Make sure the script is being run as root if [ $EUID -ne 0 ]; then - echo "This script must be run as root" - exit 1 + echo "This script must be run as root" + exit 1 fi ## @@ -63,10 +63,11 @@ if [ "$version" == "latest" ]; then elif [ "$version" == "canary" ]; then version="$canary" wget -O - "https://dl.grafana.com/oss/master/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - +else + wget -O - "https://dl.grafana.com/oss/release/grafana-${version}.linux-amd64.tar.gz" | tar -C /opt -zxf - fi /bin/rm -rf /opt/grafana > /dev/null 2>&1 || true ln -s /opt/grafana-${version} /opt/grafana # nohup /opt/grafana/bin/grafana-server -config /opt/grafana/conf/defaults.ini -homepath /opt/grafana >/dev/null 2>&1 & - 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 c446a420146..86416681463 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 @@ -27,11 +27,11 @@ get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "0d142143794721bb untar_file "/tmp/$filename" # Install golangci-lint -GOLANGCILINT_VERSION=1.36.0 +GOLANGCILINT_VERSION=1.37.0 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" \ - "9b8856b3a1c9bfbcf3a06b78e94611763b79abd9751c245246787cd3bf0e78a5" + "5fe9852e754b621c5264fb8ac810a75033e7f18e972315a60c5c3f8a37b3cb88" 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 @@ -44,7 +44,7 @@ get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-l "e1be1930379bd169d3a8e82135cf57216ad52ecfaf520b5804f269721e4dcc3d" 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.36/grabpl" +wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl" chmod +x /usr/local/bin/grabpl # Install Mage diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile index 430a0453bfb..46228193273 100644 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/Dockerfile @@ -1,6 +1,8 @@ -FROM circleci/node:12-browsers +FROM debian:testing-20210111-slim USER root COPY scripts scripts WORKDIR scripts -RUN ./deploy.sh +RUN apt-get update && \ + apt-get install -y wget && \ + ./deploy.sh COPY install/gget /usr/local/bin/gget diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh index 7ce5d0c276c..ebd1e7ad9e5 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.1.0" +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.2.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 0ed795132fd..3644bce14b9 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh @@ -2,17 +2,17 @@ source "./deploy-common.sh" # Install Go -filename="go1.15.3.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "010a88df924a81ec21b293b5da8f9b11c176d27c0ee3962dc1738d2352d3c02d" +filename="go1.15.7.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "0d142143794721bb63ce6c8a6180c4062bcf8ef4715e7d6d6609f3a8282629b3" untar_file "/tmp/$filename" # Install golangci-lint -GOLANGCILINT_VERSION=1.32.2 +GOLANGCILINT_VERSION=1.37.0 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" \ - "e7ab86d833bf9faed39801ab3b5cd294f026d26f9a7da63a42390943ead486cc" -untar_file "/tmp/${filename}.tar.gz" + "/tmp/$filename.tar.gz" \ + "5fe9852e754b621c5264fb8ac810a75033e7f18e972315a60c5c3f8a37b3cb88" +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 ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt @@ -24,10 +24,10 @@ apt-get update -y && apt-get install -y adduser libfontconfig1 locate && /bin/rm # Install code climate get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ "/usr/local/bin/cc-test-reporter" \ - "b4138199aa755ebfe171b57cc46910b13258ace5fbc4eaa099c42607cd0bff32" -chmod +x /usr/local/bin/cc-test-reporter + "e1be1930379bd169d3a8e82135cf57216ad52ecfaf520b5804f269721e4dcc3d" +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.36/grabpl" +wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.5.38/grabpl" chmod +x /usr/local/bin/grabpl # Install Mage diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index 4e0eb8e8d0c..31586691905 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -28,10 +28,10 @@ "dependencies": { "@babel/core": "7.9.0", "@babel/preset-env": "7.9.0", - "@grafana/data": "next", - "@grafana/eslint-config": "2.1.0", + "@grafana/data": "7.5.0-pre.0", + "@grafana/eslint-config": "2.2.0", "@grafana/tsconfig": "^1.0.0-rc1", - "@grafana/ui": "next", + "@grafana/ui": "7.5.0-pre.0", "@types/command-exists": "^1.2.0", "@types/execa": "^0.9.0", "@types/expect-puppeteer": "3.3.1", diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts index 2aad1198574..7d893834c56 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.test.ts @@ -1,5 +1,6 @@ import { findModuleFiles, loadWebpackConfig } from './webpack.plugin.config'; import fs from 'fs'; +// eslint-disable-next-line no-duplicate-imports import * as webpackConfig from './webpack.plugin.config'; jest.mock('./webpack/loaders', () => ({ diff --git a/packages/grafana-ui/.storybook/preview.ts b/packages/grafana-ui/.storybook/preview.ts index 9e40a52a344..50efa10cb74 100644 --- a/packages/grafana-ui/.storybook/preview.ts +++ b/packages/grafana-ui/.storybook/preview.ts @@ -59,3 +59,5 @@ export const parameters = { escapeHTML: false, }, }; + +export const NOOP_CONTROL = { control: { disable: true } }; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 0247c5a8c2c..c38eeaec9db 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -43,6 +43,11 @@ "@types/react-table": "7.0.12", "@types/slate": "0.47.1", "@types/slate-react": "0.22.5", + "@visx/event": "1.3.0", + "@visx/gradient": "1.0.0", + "@visx/scale": "1.4.0", + "@visx/shape": "1.4.0", + "@visx/tooltip": "1.3.0", "classnames": "2.2.6", "d3": "5.15.0", "emotion": "10.0.27", diff --git a/packages/grafana-ui/src/components/Alert/Alert.story.tsx b/packages/grafana-ui/src/components/Alert/Alert.story.tsx index d9517371f99..b459290ced4 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.story.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.story.tsx @@ -1,10 +1,14 @@ import React from 'react'; +import { Story } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { Alert, AlertVariant, VerticalGroup } from '@grafana/ui'; +import { Props } from './Alert'; import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; import mdx from '../Alert/Alert.mdx'; import { StoryExample } from '../../utils/storybook/StoryExample'; +const severities: AlertVariant[] = ['error', 'warning', 'info', 'success']; + export default { title: 'Overlays/Alert', component: Alert, @@ -13,26 +17,30 @@ export default { docs: { page: mdx, }, + knobs: { + disabled: true, + }, + }, + argTypes: { + severity: { control: { type: 'select', options: severities } }, }, }; -const severities: AlertVariant[] = ['error', 'warning', 'info', 'success']; - -export const Examples = () => { +export const Examples: Story = ({ severity, title, buttonContent }) => { return ( Close} + title={title} + severity={severity} + buttonContent={{buttonContent}} onRemove={action('Remove button clicked')} > Child content that includes some alert details, like maybe what actually happened. - + @@ -49,3 +57,9 @@ export const Examples = () => { ); }; + +Examples.args = { + severity: 'error', + title: 'Some very important message', + buttonContent: 'Close', +}; diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx index d200941b864..63aeaeeb36d 100644 --- a/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx +++ b/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx @@ -61,5 +61,5 @@ export const Basic: React.FC = () => { groupWidth: 0.7, }; - return ; + return ; }; diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.tsx index e46aea8afbe..38baeb67cd5 100644 --- a/packages/grafana-ui/src/components/BarChart/BarChart.tsx +++ b/packages/grafana-ui/src/components/BarChart/BarChart.tsx @@ -1,318 +1,138 @@ -import React, { useCallback, useMemo, useRef } from 'react'; -import { - compareDataFrameStructures, - DataFrame, - DefaultTimeZone, - formattedValueToString, - getFieldDisplayName, - getFieldSeriesColor, - getFieldColorModeForField, - TimeRange, - VizOrientation, - fieldReducers, - reduceField, - DisplayValue, -} from '@grafana/data'; - +import React from 'react'; +import { AlignedData } from 'uplot'; +import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data'; import { VizLayout } from '../VizLayout/VizLayout'; import { Themeable } from '../../types'; -import { useRevision } from '../uPlot/hooks'; import { UPlotChart } from '../uPlot/Plot'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; -import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config'; -import { useTheme } from '../../themes'; -import { GraphNGLegendEvent, GraphNGLegendEventMode } from '../GraphNG/types'; -import { FIXED_UNIT } from '../GraphNG/GraphNG'; -import { LegendDisplayMode, VizLegendItem } from '../VizLegend/types'; -import { VizLegend } from '../VizLegend/VizLegend'; - -import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types'; -import { BarsOptions, getConfig } from './bars'; +import { GraphNGLegendEvent } from '../GraphNG/types'; +import { BarChartOptions } from './types'; +import { withTheme } from '../../themes'; +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { preparePlotData } from '../uPlot/utils'; +import { LegendDisplayMode } from '../VizLegend/types'; +import { PlotLegend } from '../uPlot/PlotLegend'; /** * @alpha */ -export interface Props extends Themeable, BarChartOptions { +export interface BarChartProps extends Themeable, BarChartOptions { height: number; width: number; - data: DataFrame; + data: DataFrame[]; onLegendClick?: (event: GraphNGLegendEvent) => void; onSeriesColorChange?: (label: string, color: string) => void; } -/** - * @alpha - */ -export const BarChart: React.FunctionComponent = ({ - width, - height, - data, - orientation, - groupWidth, - barWidth, - showValue, - legend, - onLegendClick, - onSeriesColorChange, - ...plotProps -}) => { - if (!data || data.fields.length < 2) { - return
Missing data
; +interface BarChartState { + data: AlignedData; + alignedDataFrame: DataFrame; + config?: UPlotConfigBuilder; +} + +class UnthemedBarChart extends React.Component { + constructor(props: BarChartProps) { + super(props); + this.state = {} as BarChartState; } - // dominik? TODO? can this all be moved into `useRevision` - const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => { - if (a && b) { - return compareDataFrameStructures(a, b); - } - return false; - }, []); + static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) { + const frame = preparePlotFrame(props.data); - const configRev = useRevision(data, compareFrames); - - const theme = useTheme(); - - // Updates only when the structure changes - const configBuilder = useMemo(() => { - if (!orientation || orientation === VizOrientation.Auto) { - orientation = width < height ? VizOrientation.Horizontal : VizOrientation.Vertical; + if (!frame) { + return { ...state }; } - // bar orientation -> x scale orientation & direction - let xOri: ScaleOrientation, xDir: ScaleDirection, yOri: ScaleOrientation, yDir: ScaleDirection; - - if (orientation === VizOrientation.Vertical) { - xOri = ScaleOrientation.Horizontal; - xDir = ScaleDirection.Right; - yOri = ScaleOrientation.Vertical; - yDir = ScaleDirection.Up; - } else { - xOri = ScaleOrientation.Vertical; - xDir = ScaleDirection.Down; - yOri = ScaleOrientation.Horizontal; - yDir = ScaleDirection.Right; - } - - const formatValue = - showValue !== BarValueVisibility.Never - ? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value)) - : undefined; - - // Use bar width when only one field - if (data.fields.length === 2) { - groupWidth = barWidth; - barWidth = 1; - } - - const opts: BarsOptions = { - xOri, - xDir, - groupWidth, - barWidth, - formatValue, - onHover: (seriesIdx: number, valueIdx: number) => { - console.log('hover', { seriesIdx, valueIdx }); - }, - onLeave: (seriesIdx: number, valueIdx: number) => { - console.log('leave', { seriesIdx, valueIdx }); - }, + return { + ...state, + data: preparePlotData(frame), + alignedDataFrame: frame, }; - const config = getConfig(opts); + } - const builder = new UPlotConfigBuilder(); + componentDidMount() { + const { alignedDataFrame } = this.state; - builder.addHook('init', config.init); - builder.addHook('drawClear', config.drawClear); - builder.addHook('setCursor', config.setCursor); - - builder.setCursor(config.cursor); - builder.setSelect(config.select); - - builder.addScale({ - scaleKey: 'x', - isTime: false, - distribution: ScaleDistribution.Ordinal, - orientation: xOri, - direction: xDir, - }); - - builder.addAxis({ - scaleKey: 'x', - isTime: false, - placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, - splits: config.xSplits, - values: config.xValues, - grid: false, - ticks: false, - gap: 15, - theme, - }); - - let seriesIndex = 0; - - // iterate the y values - for (let i = 1; i < data.fields.length; i++) { - const field = data.fields[i]; - - field.state!.seriesIndex = seriesIndex++; - - const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; - - const scaleKey = field.config.unit || FIXED_UNIT; - const colorMode = getFieldColorModeForField(field); - const scaleColor = getFieldSeriesColor(field, theme); - const seriesColor = scaleColor.color; - - builder.addSeries({ - scaleKey, - pxAlign: false, - lineWidth: customConfig.lineWidth, - lineColor: seriesColor, - //lineStyle: customConfig.lineStyle, - fillOpacity: customConfig.fillOpacity, - theme, - colorMode, - pathBuilder: config.drawBars, - pointsBuilder: config.drawPoints, - show: !customConfig.hideFrom?.graph, - gradientMode: customConfig.gradientMode, - thresholds: field.config.thresholds, - - // The following properties are not used in the uPlot config, but are utilized as transport for legend config - dataFrameFieldIndex: { - fieldIndex: i, - frameIndex: 0, - }, - fieldName: getFieldDisplayName(field, data), - hideInLegend: customConfig.hideFrom?.legend, - }); - - // The builder will manage unique scaleKeys and combine where appropriate - builder.addScale({ - scaleKey, - min: field.config.min, - max: field.config.max, - softMin: customConfig.axisSoftMin, - softMax: customConfig.axisSoftMax, - orientation: yOri, - direction: yDir, - }); - - if (customConfig.axisPlacement !== AxisPlacement.Hidden) { - let placement = customConfig.axisPlacement; - if (!placement || placement === AxisPlacement.Auto) { - placement = AxisPlacement.Left; - } - if (xOri === 1) { - if (placement === AxisPlacement.Left) { - placement = AxisPlacement.Bottom; - } - if (placement === AxisPlacement.Right) { - placement = AxisPlacement.Top; - } - } - - builder.addAxis({ - scaleKey, - label: customConfig.axisLabel, - size: customConfig.axisWidth, - placement, - formatValue: (v) => formattedValueToString(field.display!(v)), - theme, - }); - } + if (!alignedDataFrame) { + return; } - return builder; - }, [data, configRev, orientation, width, height]); + this.setState({ + config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), + }); + } - const onLabelClick = useCallback( - (legend: VizLegendItem, event: React.MouseEvent) => { - const { fieldIndex } = legend; + componentDidUpdate(prevProps: BarChartProps) { + const { data, orientation, groupWidth, barWidth, showValue } = this.props; + const { alignedDataFrame } = this.state; + let shouldConfigUpdate = false; + let hasStructureChanged = false; - if (!onLegendClick || !fieldIndex) { + if ( + this.state.config === undefined || + orientation !== prevProps.orientation || + groupWidth !== prevProps.groupWidth || + barWidth !== prevProps.barWidth || + showValue !== prevProps.showValue + ) { + shouldConfigUpdate = true; + } + + if (data !== prevProps.data) { + if (!alignedDataFrame) { return; } + hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures); + } - onLegendClick({ - fieldIndex, - mode: GraphNGLegendEventMode.AppendToSelection, + if (shouldConfigUpdate || hasStructureChanged) { + this.setState({ + config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), }); - }, - [onLegendClick, data] - ); + } + } - const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden); + renderLegend() { + const { legend, onSeriesColorChange, onLegendClick, data } = this.props; + const { config } = this.state; - const legendItems = configBuilder - .getSeries() - .map((s) => { - const seriesConfig = s.props; - const fieldIndex = seriesConfig.dataFrameFieldIndex; - if (seriesConfig.hideInLegend || !fieldIndex) { - return undefined; - } - - const field = data.fields[fieldIndex.fieldIndex]; - if (!field) { - return undefined; - } - - return { - disabled: !seriesConfig.show ?? false, - fieldIndex, - color: seriesConfig.lineColor!, - label: seriesConfig.fieldName, - yAxis: 1, - getDisplayValues: () => { - if (!legend.calcs?.length) { - return []; - } - - const fieldCalcs = reduceField({ - field, - reducers: legend.calcs, - }); - - return legend.calcs.map((reducer) => { - return { - ...field.display!(fieldCalcs[reducer]), - title: fieldReducers.get(reducer).name, - }; - }); - }, - }; - }) - .filter((i) => i !== undefined) as VizLegendItem[]; - - let legendElement: React.ReactElement | undefined; - - if (hasLegend && legendItems.length > 0) { - legendElement = ( - - - + if (!config || legend.displayMode === LegendDisplayMode.Hidden) { + return; + } + return ( + ); } - return ( - - {(vizWidth: number, vizHeight: number) => ( - - )} - - ); -}; + render() { + const { width, height } = this.props; + const { config, data } = this.state; + + if (!config) { + return null; + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + + )} + + ); + } +} + +export const BarChart = withTheme(UnthemedBarChart); +BarChart.displayName = 'GraphNG'; diff --git a/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..c2cd121a0f5 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap @@ -0,0 +1,961 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphNG utils preparePlotConfigBuilder orientation 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder orientation 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder orientation 3`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "left", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "bottom", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "distribution": "ordinal", + "isTime": false, + "orientation": 0, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 1, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 3`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder value visibility 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder value visibility 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; diff --git a/packages/grafana-ui/src/components/BarChart/utils.test.ts b/packages/grafana-ui/src/components/BarChart/utils.test.ts new file mode 100644 index 00000000000..631d3b8d707 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/utils.test.ts @@ -0,0 +1,101 @@ +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { FieldConfig, FieldType, GrafanaTheme, MutableDataFrame, VizOrientation } from '@grafana/data'; +import { BarChartFieldConfig, BarChartOptions, BarStackingMode, BarValueVisibility } from './types'; +import { GraphGradientMode } from '../uPlot/config'; +import { LegendDisplayMode } from '../VizLegend/types'; + +function mockDataFrame() { + const df1 = new MutableDataFrame({ + refId: 'A', + fields: [{ name: 'ts', type: FieldType.string, values: ['a', 'b', 'c'] }], + }); + + const df2 = new MutableDataFrame({ + refId: 'B', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }], + }); + + const f1Config: FieldConfig = { + displayName: 'Metric 1', + decimals: 2, + unit: 'm/s', + custom: { + gradientMode: GraphGradientMode.Opacity, + lineWidth: 2, + fillOpacity: 0.1, + }, + }; + + const f2Config: FieldConfig = { + displayName: 'Metric 2', + decimals: 2, + unit: 'kWh', + custom: { + gradientMode: GraphGradientMode.Hue, + lineWidth: 2, + fillOpacity: 0.1, + }, + }; + + df1.addField({ + name: 'metric1', + type: FieldType.number, + config: f1Config, + state: {}, + }); + + df2.addField({ + name: 'metric2', + type: FieldType.number, + config: f2Config, + state: {}, + }); + + return preparePlotFrame([df1, df2]); +} + +describe('GraphNG utils', () => { + describe('preparePlotConfigBuilder', () => { + const frame = mockDataFrame(); + + const config: BarChartOptions = { + orientation: VizOrientation.Auto, + groupWidth: 20, + barWidth: 2, + showValue: BarValueVisibility.Always, + legend: { + displayMode: LegendDisplayMode.List, + placement: 'bottom', + calcs: [], + }, + stacking: BarStackingMode.None, + }; + + it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + orientation: v, + }) + ).toMatchSnapshot(); + }); + + it.each([BarValueVisibility.Always, BarValueVisibility.Auto])('value visibility', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + showValue: v, + }) + ).toMatchSnapshot(); + }); + + it.each([BarStackingMode.None, BarStackingMode.Percent, BarStackingMode.Standard])('stacking', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + stacking: v, + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/BarChart/utils.ts b/packages/grafana-ui/src/components/BarChart/utils.ts new file mode 100644 index 00000000000..f5ae05ddbca --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/utils.ts @@ -0,0 +1,190 @@ +import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; +import { + DataFrame, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getFieldDisplayName, + getFieldSeriesColor, + GrafanaTheme, + MutableDataFrame, + VizOrientation, +} from '@grafana/data'; +import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types'; +import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config'; +import { BarsOptions, getConfig } from './bars'; +import { FIXED_UNIT } from '../GraphNG/GraphNG'; + +/** @alpha */ +export function preparePlotConfigBuilder( + data: DataFrame, + theme: GrafanaTheme, + { orientation, showValue, groupWidth, barWidth }: BarChartOptions +) { + const builder = new UPlotConfigBuilder(); + + // bar orientation -> x scale orientation & direction + let xOri = ScaleOrientation.Vertical; + let xDir = ScaleDirection.Down; + let yOri = ScaleOrientation.Horizontal; + let yDir = ScaleDirection.Right; + + if (orientation === VizOrientation.Vertical) { + xOri = ScaleOrientation.Horizontal; + xDir = ScaleDirection.Right; + yOri = ScaleOrientation.Vertical; + yDir = ScaleDirection.Up; + } + + const formatValue = + showValue !== BarValueVisibility.Never + ? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value)) + : undefined; + + // Use bar width when only one field + if (data.fields.length === 2) { + groupWidth = barWidth; + barWidth = 1; + } + + const opts: BarsOptions = { + xOri, + xDir, + groupWidth, + barWidth, + formatValue, + onHover: (seriesIdx: number, valueIdx: number) => { + console.log('hover', { seriesIdx, valueIdx }); + }, + onLeave: (seriesIdx: number, valueIdx: number) => { + console.log('leave', { seriesIdx, valueIdx }); + }, + }; + + const config = getConfig(opts); + + builder.addHook('init', config.init); + builder.addHook('drawClear', config.drawClear); + builder.addHook('setCursor', config.setCursor); + + builder.setCursor(config.cursor); + builder.setSelect(config.select); + + builder.addScale({ + scaleKey: 'x', + isTime: false, + distribution: ScaleDistribution.Ordinal, + orientation: xOri, + direction: xDir, + }); + + builder.addAxis({ + scaleKey: 'x', + isTime: false, + placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, + splits: config.xSplits, + values: config.xValues, + grid: false, + ticks: false, + gap: 15, + theme, + }); + + let seriesIndex = 0; + + // iterate the y values + for (let i = 1; i < data.fields.length; i++) { + const field = data.fields[i]; + + field.state!.seriesIndex = seriesIndex++; + + const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; + + const scaleKey = field.config.unit || FIXED_UNIT; + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + builder.addSeries({ + scaleKey, + pxAlign: false, + lineWidth: customConfig.lineWidth, + lineColor: seriesColor, + //lineStyle: customConfig.lineStyle, + fillOpacity: customConfig.fillOpacity, + theme, + colorMode, + pathBuilder: config.drawBars, + pointsBuilder: config.drawPoints, + show: !customConfig.hideFrom?.graph, + gradientMode: customConfig.gradientMode, + thresholds: field.config.thresholds, + + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: { + fieldIndex: i, + frameIndex: 0, + }, + fieldName: getFieldDisplayName(field, data), + hideInLegend: customConfig.hideFrom?.legend, + }); + + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale({ + scaleKey, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + orientation: yOri, + direction: yDir, + }); + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + let placement = customConfig.axisPlacement; + if (!placement || placement === AxisPlacement.Auto) { + placement = AxisPlacement.Left; + } + if (xOri === 1) { + if (placement === AxisPlacement.Left) { + placement = AxisPlacement.Bottom; + } + if (placement === AxisPlacement.Right) { + placement = AxisPlacement.Top; + } + } + + builder.addAxis({ + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement, + formatValue: (v) => formattedValueToString(field.display!(v)), + theme, + }); + } + } + + return builder; +} + +/** @internal */ +export function preparePlotFrame(data: DataFrame[]) { + const firstFrame = data[0]; + const firstString = firstFrame.fields.find((f) => f.type === FieldType.string); + + if (!firstString) { + throw new Error('No string field in DF'); + } + + const resultFrame = new MutableDataFrame(); + resultFrame.addField(firstString); + + for (const f of firstFrame.fields) { + if (f.type === FieldType.number) { + resultFrame.addField(f); + } + } + + return resultFrame; +} diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx index 96238b2daec..6349d680fb2 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx @@ -1,25 +1,13 @@ import React from 'react'; -import { number, text } from '@storybook/addon-knobs'; +import { Story } from '@storybook/react'; import { BarGauge, BarGaugeDisplayMode } from '@grafana/ui'; +import { NOOP_CONTROL } from '@grafana/ui/.storybook/preview'; import { VizOrientation, ThresholdsMode, Field, FieldType, getDisplayProcessor } from '@grafana/data'; import { Props } from './BarGauge'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import mdx from './BarGauge.mdx'; import { useTheme } from '../../themes'; -const getKnobs = () => { - return { - value: number('value', 70), - title: text('title', 'Title'), - minValue: number('minValue', 0), - maxValue: number('maxValue', 100), - threshold1Value: number('threshold1Value', 40), - threshold1Color: text('threshold1Color', 'orange'), - threshold2Value: number('threshold2Value', 60), - threshold2Color: text('threshold2Color', 'red'), - }; -}; - export default { title: 'Visualizations/BarGauge', component: BarGauge, @@ -28,79 +16,109 @@ export default { docs: { page: mdx, }, + knobs: { + disabled: true, + }, + }, + args: { + numeric: 70, + title: 'Title', + minValue: 0, + maxValue: 100, + threshold1Value: 40, + threshold1Color: 'orange', + threshold2Value: 60, + threshold2Color: 'red', + displayMode: BarGaugeDisplayMode.Gradient, + lcdCellWidth: 12, + itemSpacing: 8, + showUnfilled: true, + }, + argTypes: { + displayMode: { + control: { + type: 'select', + options: [BarGaugeDisplayMode.Lcd, BarGaugeDisplayMode.Gradient, BarGaugeDisplayMode.Basic], + }, + }, + width: { control: { type: 'range', min: 200, max: 800 } }, + height: { control: { type: 'range', min: 200, max: 800 } }, + threshold1Color: { control: 'color' }, + threshold2Color: { control: 'color' }, + theme: NOOP_CONTROL, + field: NOOP_CONTROL, + value: NOOP_CONTROL, + display: NOOP_CONTROL, + orientation: NOOP_CONTROL, + text: NOOP_CONTROL, + onClick: NOOP_CONTROL, + className: NOOP_CONTROL, + alignmentFactors: NOOP_CONTROL, }, }; -function addBarGaugeStory(overrides: Partial) { - return () => { - const theme = useTheme(); - - const { - value, - title, - minValue, - maxValue, - threshold1Color, - threshold2Color, - threshold1Value, - threshold2Value, - } = getKnobs(); - - const field: Partial = { - type: FieldType.number, - config: { - min: minValue, - max: maxValue, - thresholds: { - mode: ThresholdsMode.Absolute, - steps: [ - { value: -Infinity, color: 'green' }, - { value: threshold1Value, color: threshold1Color }, - { value: threshold2Value, color: threshold2Color }, - ], - }, - }, - }; - field.display = getDisplayProcessor({ field }); - - const props: Props = { - theme, - width: 300, - height: 300, - value: { - text: value.toString(), - title: title, - numeric: value, - }, - orientation: VizOrientation.Vertical, - displayMode: BarGaugeDisplayMode.Basic, - field: field.config!, - display: field.display!, - }; - - Object.assign(props, overrides); - - return ; - }; +interface StoryProps extends Partial { + numeric: number; + title: string; + minValue: number; + maxValue: number; + threshold1Color: string; + threshold2Color: string; + threshold1Value: number; + threshold2Value: number; } -export const gradientVertical = addBarGaugeStory({ - displayMode: BarGaugeDisplayMode.Gradient, - orientation: VizOrientation.Vertical, +const AddBarGaugeStory = (storyProps: StoryProps) => { + const theme = useTheme(); + + const field: Partial = { + type: FieldType.number, + config: { + min: storyProps.minValue, + max: storyProps.maxValue, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: storyProps.threshold1Value, color: storyProps.threshold1Color }, + { value: storyProps.threshold2Value, color: storyProps.threshold2Color }, + ], + }, + }, + }; + field.display = getDisplayProcessor({ field }); + + const props: Partial = { + theme, + lcdCellWidth: storyProps.lcdCellWidth, + itemSpacing: storyProps.itemSpacing, + showUnfilled: storyProps.showUnfilled, + width: storyProps.width, + height: storyProps.height, + value: { + text: storyProps.numeric.toString(), + title: storyProps.title, + numeric: storyProps.numeric, + }, + displayMode: storyProps.displayMode, + orientation: storyProps.orientation, + field: field.config!, + display: field.display!, + }; + + return ; +}; + +export const barGaugeVertical: Story = AddBarGaugeStory.bind({}); +barGaugeVertical.args = { height: 500, width: 100, -}); + orientation: VizOrientation.Vertical, +}; -export const gradientHorizontal = addBarGaugeStory({ - displayMode: BarGaugeDisplayMode.Gradient, - orientation: VizOrientation.Horizontal, +export const barGaugeHorizontal: Story = AddBarGaugeStory.bind({}); +barGaugeHorizontal.args = { height: 100, width: 500, -}); - -export const lcdHorizontal = addBarGaugeStory({ - displayMode: BarGaugeDisplayMode.Lcd, - orientation: VizOrientation.Vertical, - height: 500, - width: 100, -}); + orientation: VizOrientation.Horizontal, +}; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx index 251f2ce9d4a..7eaa5dbe45d 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -10,6 +10,7 @@ import { getTitleStyles, getValuePercent, BarGaugeDisplayMode, + calculateBarAndValueDimensions, } from './BarGauge'; import { getTheme } from '../../themes'; @@ -211,4 +212,18 @@ describe('BarGauge', () => { expect(wrapper).toMatchSnapshot(); }); }); + + describe('calculateBarAndValueDimensions', () => { + it('valueWidth should including paddings in valueWidth', () => { + const result = calculateBarAndValueDimensions( + getProps({ + height: 30, + width: 100, + value: getValue(1, 'AA'), + orientation: VizOrientation.Horizontal, + }) + ); + expect(result.valueWidth).toBe(21); + }); + }); }); diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index e8d0ca0735b..66d1c738aff 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -15,22 +15,15 @@ import { getColorForTheme, FALLBACK_COLOR, TextDisplayOptions, + VizOrientation, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; - -// Components import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay'; - -// Utils import { measureText, calculateFontSize } from '../../utils/measureText'; - -// Types -import { VizOrientation } from '@grafana/data'; import { Themeable } from '../../types'; const MIN_VALUE_HEIGHT = 18; const MAX_VALUE_HEIGHT = 50; -const MIN_VALUE_WIDTH = 50; const MAX_VALUE_WIDTH = 150; const TITLE_LINE_HEIGHT = 1.5; const VALUE_LINE_HEIGHT = 1; @@ -373,9 +366,15 @@ interface BarAndValueDimensions { wrapperWidth: number; } -function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions { - const { height, width, orientation, text } = props; +/** + * @internal + * Only exported for unit tests + **/ +export function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions { + const { height, width, orientation, text, alignmentFactors } = props; const titleDim = calculateTitleDimensions(props); + const value = alignmentFactors ?? props.value; + const valueString = formattedValueToString(value); let maxBarHeight = 0; let maxBarWidth = 0; @@ -384,25 +383,27 @@ function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions { let wrapperWidth = 0; let wrapperHeight = 0; + // measure text with title font size or min 14px + const fontSizeToMeasureWith = text?.valueSize ?? Math.max(titleDim.fontSize, 12); + const realTextSize = measureText(valueString, fontSizeToMeasureWith); + const realValueWidth = realTextSize.width + VALUE_LEFT_PADDING * 2; + if (isVertical(orientation)) { if (text?.valueSize) { valueHeight = text.valueSize * VALUE_LINE_HEIGHT; } else { valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT); } + valueWidth = width; maxBarHeight = height - (titleDim.height + valueHeight); maxBarWidth = width; wrapperWidth = width; wrapperHeight = height - titleDim.height; } else { - if (text?.valueSize) { - valueHeight = text.valueSize * VALUE_LINE_HEIGHT; - } else { - valueHeight = height - titleDim.height; - } + valueHeight = height - titleDim.height; + valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), realValueWidth); - valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), MIN_VALUE_WIDTH); maxBarHeight = height - titleDim.height; maxBarWidth = width - valueWidth - titleDim.width; @@ -479,7 +480,6 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles if (isBasic) { // Basic styles barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`; - barStyles.borderTop = `2px solid ${valueColor}`; } else { // Gradient styles @@ -499,6 +499,7 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles // shift empty region back to fill gaps due to border radius emptyBar.left = '-3px'; + emptyBar.width = `${maxBarWidth - barWidth}px`; if (isBasic) { // Basic styles diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index cb2c15faf80..eed60db60c9 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -62,6 +62,7 @@ exports[`BarGauge Render with basic options should render 1`] = ` "flexGrow": 1, "left": "-3px", "position": "relative", + "width": "180px", } } /> diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx index 2122b6f466c..c74e9a82d70 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.story.tsx @@ -1,29 +1,19 @@ import React from 'react'; -import { color, number, select, text } from '@storybook/addon-knobs'; -import { BigValue, BigValueColorMode, BigValueGraphMode, BigValueJustifyMode, BigValueTextMode } from './BigValue'; +import { Story } from '@storybook/react'; +import { + BigValue, + BigValueColorMode, + BigValueGraphMode, + BigValueJustifyMode, + BigValueTextMode, + Props, +} from './BigValue'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import mdx from './BigValue.mdx'; import { useTheme } from '../../themes'; +import { NOOP_CONTROL } from '@grafana/ui/.storybook/preview'; import { ArrayVector, FieldSparkline, FieldType } from '@grafana/data'; -const getKnobs = () => { - return { - value: text('value', '$5022'), - title: text('title', 'Total Earnings'), - colorMode: select('Color mode', [BigValueColorMode.Value, BigValueColorMode.Background], BigValueColorMode.Value), - graphMode: select('Graph mode', [BigValueGraphMode.Area, BigValueGraphMode.None], BigValueGraphMode.Area), - justifyMode: select('Justify', [BigValueJustifyMode.Auto, BigValueJustifyMode.Center], BigValueJustifyMode.Auto), - width: number('Width', 400, { range: true, max: 800, min: 200 }), - height: number('Height', 300, { range: true, max: 800, min: 200 }), - color: color('Value color', 'red'), - textMode: select( - 'Text mode', - [BigValueTextMode.Auto, BigValueTextMode.Name, BigValueTextMode.ValueAndName, BigValueTextMode.None], - BigValueTextMode.Auto - ), - }; -}; - export default { title: 'Visualizations/BigValue', component: BigValue, @@ -32,11 +22,58 @@ export default { docs: { page: mdx, }, + knobs: { + disabled: true, + }, + }, + argTypes: { + width: { control: { type: 'range', min: 200, max: 800 } }, + height: { control: { type: 'range', min: 200, max: 800 } }, + colorMode: { control: { type: 'select', options: [BigValueColorMode.Value, BigValueColorMode.Background] } }, + graphMode: { control: { type: 'select', options: [BigValueGraphMode.Area, BigValueGraphMode.None] } }, + justifyMode: { control: { type: 'select', options: [BigValueJustifyMode.Auto, BigValueJustifyMode.Center] } }, + textMode: { + control: { + type: 'radio', + options: [ + BigValueTextMode.Auto, + BigValueTextMode.Name, + BigValueTextMode.ValueAndName, + BigValueTextMode.None, + BigValueTextMode.Value, + ], + }, + }, + color: { control: 'color' }, + value: NOOP_CONTROL, + sparkline: NOOP_CONTROL, + onClick: NOOP_CONTROL, + className: NOOP_CONTROL, + alignmentFactors: NOOP_CONTROL, + text: NOOP_CONTROL, + count: NOOP_CONTROL, + theme: NOOP_CONTROL, }, }; -export const Basic = () => { - const { value, title, colorMode, graphMode, height, width, color, textMode, justifyMode } = getKnobs(); +interface StoryProps extends Partial { + numeric: number; + title: string; + color: string; + valueText: string; +} + +export const Basic: Story = ({ + valueText, + title, + colorMode, + graphMode, + height, + width, + color, + textMode, + justifyMode, +}) => { const theme = useTheme(); const sparkline: FieldSparkline = { y: { @@ -57,7 +94,7 @@ export const Basic = () => { textMode={textMode} justifyMode={justifyMode} value={{ - text: value, + text: valueText, numeric: 5022, color: color, title, @@ -66,3 +103,15 @@ export const Basic = () => { /> ); }; + +Basic.args = { + valueText: '$5022', + title: 'Total Earnings', + colorMode: BigValueColorMode.Value, + graphMode: BigValueGraphMode.Area, + justifyMode: BigValueJustifyMode.Auto, + width: 400, + height: 300, + color: 'red', + textMode: BigValueTextMode.Auto, +}; diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx index 9849450b63a..2ff6f650f12 100644 --- a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx @@ -1,20 +1,23 @@ import React from 'react'; -import { withKnobs, text, boolean, object, select } from '@storybook/addon-knobs'; +import { Story } from '@storybook/react'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { ButtonCascader } from '@grafana/ui'; +import { NOOP_CONTROL } from '@grafana/ui/.storybook/preview'; +import { ButtonCascaderProps } from './ButtonCascader'; export default { title: 'Forms/Cascader/ButtonCascader', component: ButtonCascader, - decorators: [withKnobs, withCenteredStory], -}; - -const getKnobs = () => { - return { - disabled: boolean('Disabled', false), - text: text('Button Text', 'Click me!'), - icon: select('Icon', ['plus', 'minus', 'table'], 'plus'), - options: object('Options', [ + decorators: [withCenteredStory], + parameters: { + knobs: { + disabled: true, + }, + }, + args: { + disabled: false, + children: 'Click me!', + options: [ { label: 'A', value: 'A', @@ -24,24 +27,24 @@ const getKnobs = () => { ], }, { label: 'D', value: 'D' }, - ]), - }; + ], + }, + argTypes: { + icon: { control: { type: 'select', options: ['plus', 'minus', 'table'] } }, + options: { control: 'object' }, + className: NOOP_CONTROL, + value: NOOP_CONTROL, + fieldNames: NOOP_CONTROL, + }, }; -export const simple = () => { - const { disabled, text, options } = getKnobs(); - return ( - - {text} - - ); +const Template: Story = ({ children, ...args }) => { + return {children}; }; -export const withIcon = () => { - const { disabled, text, options, icon } = getKnobs(); - return ( - - {text} - - ); +export const simple = Template.bind({}); + +export const withIcon = Template.bind({}); +withIcon.args = { + icon: 'plus', }; diff --git a/packages/grafana-ui/src/components/Card/Card.story.tsx b/packages/grafana-ui/src/components/Card/Card.story.tsx index 1396506ccb9..0c0712d755e 100644 --- a/packages/grafana-ui/src/components/Card/Card.story.tsx +++ b/packages/grafana-ui/src/components/Card/Card.story.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { action } from '@storybook/addon-actions'; import { Story } from '@storybook/react'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { Card, Props } from './Card'; @@ -133,9 +132,6 @@ export const Full: Story = ({ disabled }) => { Prometheus Logo - - - diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx index 780a4e7c0e1..c47b6a18185 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -1,12 +1,11 @@ -import { selectThemeVariant, ThemeContext } from '../../index'; +import { ThemeContext } from '../../index'; import { GrafanaTheme, VariableSuggestion } from '@grafana/data'; import { css, cx } from 'emotion'; import _ from 'lodash'; import React, { useRef, useContext, useMemo } from 'react'; import useClickAway from 'react-use/lib/useClickAway'; import { List } from '../index'; -import tinycolor from 'tinycolor2'; -import { stylesFactory } from '../../themes'; +import { styleMixins, stylesFactory } from '../../themes'; interface DataLinkSuggestionsProps { suggestions: VariableSuggestion[]; @@ -16,61 +15,12 @@ interface DataLinkSuggestionsProps { } const getStyles = stylesFactory((theme: GrafanaTheme) => { - const wrapperBg = selectThemeVariant( - { - light: theme.palette.white, - dark: theme.palette.dark2, - }, - theme.type - ); - - const wrapperShadow = selectThemeVariant( - { - light: theme.palette.gray5, - dark: theme.palette.black, - }, - theme.type - ); - - const itemColor = selectThemeVariant( - { - light: theme.palette.black, - dark: theme.palette.white, - }, - theme.type - ); - - const itemDocsColor = selectThemeVariant( - { - light: theme.palette.dark3, - dark: theme.palette.gray2, - }, - theme.type - ); - - const itemBgHover = selectThemeVariant( - { - light: theme.palette.gray5, - dark: theme.palette.dark7, - }, - theme.type - ); - - const itemBgActive = selectThemeVariant( - { - light: theme.palette.gray6, - dark: theme.palette.dark9, - }, - theme.type - ); - - const separatorColor = selectThemeVariant( - { - light: tinycolor(wrapperBg.toString()).darken(10).toString(), - dark: tinycolor(wrapperBg.toString()).lighten(10).toString(), - }, - theme.type - ); + const wrapperBg = theme.colors.bg1; + const wrapperShadow = theme.colors.dropdownShadow; + const itemColor = theme.colors.text; + const itemBgHover = styleMixins.hoverColor(theme.colors.bg1, theme); + const itemBgActive = theme.colors.bg2; + const separatorColor = theme.colors.border2; return { list: css` @@ -107,10 +57,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { font-family: ${theme.typography.fontFamily.monospace}; font-size: ${theme.typography.size.sm}; `, - itemDocs: css` - margin-top: ${theme.spacing.xs}; - color: ${itemDocsColor}; - `, }; }); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx new file mode 100644 index 00000000000..f8df089bedf --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DataLinksListItem, DataLinksListItemProps } from './DataLinksListItem'; + +const baseLink = { + url: '', + title: '', + onBuildUrl: jest.fn(), + onClick: jest.fn(), +}; + +function setupTestContext(options: Partial) { + const defaults: DataLinksListItemProps = { + index: 0, + link: baseLink, + data: [], + onChange: jest.fn(), + onEdit: jest.fn(), + onRemove: jest.fn(), + suggestions: [], + }; + + const props = { ...defaults, ...options }; + const { rerender } = render(); + + return { rerender, props }; +} + +describe('DataLinksListItem', () => { + describe('when link has title', () => { + it('then the link title should be visible', () => { + const link = { + ...baseLink, + title: 'Some Data Link Title', + }; + setupTestContext({ link }); + + expect(screen.getByText(/some data link title/i)).toBeInTheDocument(); + }); + }); + + describe('when link has url', () => { + it('then the link url should be visible', () => { + const link = { + ...baseLink, + url: 'http://localhost:3000', + }; + setupTestContext({ link }); + + expect(screen.getByText(/http:\/\/localhost\:3000/i)).toBeInTheDocument(); + expect(screen.getByTitle(/http:\/\/localhost\:3000/i)).toBeInTheDocument(); + }); + }); + + describe('when link is missing title', () => { + it('then the link title should be replaced by [Data link title not provided]', () => { + const link = { + ...baseLink, + title: (undefined as unknown) as string, + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument(); + }); + }); + + describe('when link is missing url', () => { + it('then the link url should be replaced by [Data link url not provided]', () => { + const link = { + ...baseLink, + url: (undefined as unknown) as string, + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument(); + expect(screen.getByTitle('')).toBeInTheDocument(); + }); + }); + + describe('when link title is empty', () => { + it('then the link title should be replaced by [Data link title not provided]', () => { + const link = { + ...baseLink, + title: ' ', + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link title not provided/i)).toBeInTheDocument(); + }); + }); + + describe('when link url is empty', () => { + it('then the link url should be replaced by [Data link url not provided]', () => { + const link = { + ...baseLink, + url: ' ', + }; + setupTestContext({ link }); + + expect(screen.getByText(/data link url not provided/i)).toBeInTheDocument(); + expect(screen.getByTitle('')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx index 05b9558bef8..f3fdb13c232 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx @@ -5,7 +5,7 @@ import { stylesFactory, useTheme } from '../../../themes'; import { HorizontalGroup, VerticalGroup } from '../../Layout/Layout'; import { IconButton } from '../../IconButton/IconButton'; -interface DataLinksListItemProps { +export interface DataLinksListItemProps { index: number; link: DataLink; data: DataFrame[]; @@ -19,24 +19,25 @@ interface DataLinksListItemProps { export const DataLinksListItem: FC = ({ link, onEdit, onRemove }) => { const theme = useTheme(); const styles = getDataLinkListItemStyles(theme); + const { title = '', url = '' } = link; - const hasTitle = link.title.trim() !== ''; - const hasUrl = link.url.trim() !== ''; + const hasTitle = title.trim() !== ''; + const hasUrl = url.trim() !== ''; return (
- {hasTitle ? link.title : 'Data link title not provided'} + {hasTitle ? title : 'Data link title not provided'}
-
- {hasUrl ? link.url : 'Data link url not provided'} +
+ {hasUrl ? url : 'Data link url not provided'}
diff --git a/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx new file mode 100644 index 00000000000..1822820599e --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/FieldLinkList.tsx @@ -0,0 +1,71 @@ +import { Field, GrafanaTheme, LinkModel } from '@grafana/data'; +import { css } from 'emotion'; +import React from 'react'; +import { useStyles } from '../../themes'; +import { Icon } from '../Icon/Icon'; +import { DataLinkButton } from './DataLinkButton'; + +type Props = { + links: Array>; +}; + +/** + * @internal + */ +export function FieldLinkList({ links }: Props) { + const styles = useStyles(getStyles); + + if (links.length === 1) { + return ; + } + + const externalLinks = links.filter((link) => link.target === '_blank'); + const internalLinks = links.filter((link) => link.target === '_self'); + + return ( + <> + {internalLinks.map((link, i) => { + return ; + })} +
+

External links

+ {externalLinks.map((link, i) => ( + + + {link.title} + + ))} +
+ + ); +} + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + flex-basis: 150px; + width: 100px; + margin-top: ${theme.spacing.sm}; + `, + externalLinksHeading: css` + color: ${theme.colors.textWeak}; + font-weight: ${theme.typography.weight.regular}; + font-size: ${theme.typography.size.sm}; + margin: 0; + `, + externalLink: css` + color: ${theme.colors.linkExternal}; + font-weight: ${theme.typography.weight.regular}; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + + div { + margin-right: ${theme.spacing.sm}; + } + `, +}); diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx index 121645c3f40..2848cbdbfbf 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.test.tsx @@ -9,6 +9,7 @@ const setup = (propOverrides?: object) => { orgId: 1, name: 'gdev-influxdb', type: 'influxdb', + typeName: 'Influxdb', typeLogoUrl: '', access: 'direct', url: 'http://localhost:8086', diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx index 289486256a9..78c0f12e452 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx @@ -52,6 +52,7 @@ const getCustomHeaderRowStyles = stylesFactory(() => { `, }; }); + const CustomHeaderRow: React.FC = ({ header, onBlur, onChange, onRemove, onReset }) => { const styles = getCustomHeaderRowStyles(); return ( @@ -138,7 +139,7 @@ export class CustomHeadersSettings extends PureComponent { onHeaderAdd = () => { this.setState((prevState) => { return { headers: [...prevState.headers, { id: uniqueId(), name: '', value: '', configured: false }] }; - }, this.updateSettings); + }); }; onHeaderChange = (headerIndex: number, value: CustomHeader) => { diff --git a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.story.tsx b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.story.tsx index 8f562623a95..c847eaddaaf 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.story.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.story.tsx @@ -10,6 +10,7 @@ const settingsMock: DataSourceSettings = { orgId: 1, name: 'gdev-influxdb', type: 'influxdb', + typeName: 'Influxdb', typeLogoUrl: '', access: 'direct', url: 'http://localhost:8086', diff --git a/packages/grafana-ui/src/components/FieldConfigs/FieldConfigItemHeaderTitle.tsx b/packages/grafana-ui/src/components/FieldConfigs/FieldConfigItemHeaderTitle.tsx deleted file mode 100644 index 6d038c5ad8e..00000000000 --- a/packages/grafana-ui/src/components/FieldConfigs/FieldConfigItemHeaderTitle.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { GrafanaTheme } from '@grafana/data'; -import { css } from 'emotion'; -import { selectThemeVariant, stylesFactory, useTheme } from '../../themes'; -import { Label } from '../Forms/Label'; -import { Icon } from '../Icon/Icon'; - -interface FieldConfigItemHeaderTitleProps { - title: string; - description?: string; - transparent?: boolean; - onRemove: () => void; -} - -export const FieldConfigItemHeaderTitle: React.FC = ({ - title, - description, - onRemove, - children, - transparent, -}) => { - const theme = useTheme(); - const styles = getFieldConfigItemHeaderTitleStyles(theme); - return ( -
-
- -
onRemove()} aria-label="FieldConfigItemHeaderTitle remove button"> - -
-
- {children} -
- ); -}; - -const getFieldConfigItemHeaderTitleStyles = stylesFactory((theme: GrafanaTheme) => { - const headerBg = selectThemeVariant( - { - light: theme.palette.white, - dark: theme.palette.dark1, - }, - theme.type - ); - - return { - headerWrapper: css` - background: ${headerBg}; - padding: ${theme.spacing.xs} 0; - `, - header: css` - display: flex; - justify-content: space-between; - padding: ${theme.spacing.xs} ${theme.spacing.xs} 0 ${theme.spacing.xs}; - `, - remove: css` - flex-grow: 0; - flex-shrink: 0; - cursor: pointer; - color: ${theme.palette.red88}; - `, - }; -}); diff --git a/packages/grafana-ui/src/components/Forms/Field.test.tsx b/packages/grafana-ui/src/components/Forms/Field.test.tsx new file mode 100644 index 00000000000..970985fa95c --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Field.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Input } from '../Input/Input'; +import { Field } from './Field'; +import { Select } from '../Select/Select'; + +describe('Field', () => { + it('renders the label', () => { + render( + + + + ); + + expect(screen.getByText('My label')).toBeInTheDocument(); + }); + + it('renders with the id of its children', () => { + render( + + + + ); + + expect(screen.getByLabelText('My label')).toBeInTheDocument(); + }); + + it('renders with the inputId of its children', () => { + render( + + + + ); + + expect(screen.getByText('My label')).toBeInTheDocument(); + }); + + it('renders with the id of its children', () => { + render( + + + + ); + + expect(screen.getByLabelText('My label')).toBeInTheDocument(); + }); + + it('renders with the inputId of its children', () => { + render( + + ` using its matching label, such as the label assigned with the `inputId` prop. Use the `react-select-event` package to select values from the options. + +```tsx +import { render, screen } from '@testing-library/react'; +import selectEvent from 'react-select-event'; +import { Select } from '@grafana/ui'; + +it('should call onChange', () => { + const onChange = jest.fn(); + + render( + <> + + - -
-
- {tags && - tags.map((tag: string, index: number) => { - return ; - })} -
+ return ( +
+
+ {tags?.map((tag: string, index: number) => { + return ; + })}
- ); - } -} +
+ + Add + + } + /> +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + height: ${theme.spacing.formInputHeight}px; + align-items: center; + display: flex; + flex-wrap: wrap; + `, + tags: css` + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + margin-right: ${theme.spacing.xs}; + `, + addButtonStyle: css` + margin: 0 -${theme.spacing.sm}; + `, +}); diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx index 14a92c80f25..5efc8a2efbe 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker.tsx @@ -12,8 +12,16 @@ import { stylesFactory } from '../../themes/stylesFactory'; import { withTheme, useTheme } from '../../themes/ThemeContext'; // Types -import { isDateTime, rangeUtil, GrafanaTheme, dateTimeFormat, timeZoneFormatUserFriendly } from '@grafana/data'; -import { TimeRange, TimeZone, dateMath } from '@grafana/data'; +import { + isDateTime, + rangeUtil, + GrafanaTheme, + dateTimeFormat, + timeZoneFormatUserFriendly, + TimeRange, + TimeZone, + dateMath, +} from '@grafana/data'; import { Themeable } from '../../types'; import { otherOptions, quickOptions } from './rangeOptions'; import { ButtonGroup, ToolbarButton } from '../Button'; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx index 99756b3188c..e2b2af4450e 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx @@ -1,7 +1,6 @@ import React, { ReactNode } from 'react'; import { css } from 'emotion'; -import { TimeOption, TimeZone } from '@grafana/data'; -import { TimeRange } from '@grafana/data'; +import { TimeRange, TimeOption, TimeZone } from '@grafana/data'; import { TimePickerTitle } from './TimePickerTitle'; import { TimeRangeOption } from './TimeRangeOption'; import { mapOptionToTimeRange } from './mapper'; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx index 726d6dca7ad..b245174ca49 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeOption.tsx @@ -1,18 +1,10 @@ import React, { memo } from 'react'; import { css } from 'emotion'; import { GrafanaTheme, TimeOption } from '@grafana/data'; -import { useTheme, stylesFactory, selectThemeVariant } from '../../../themes'; +import { useTheme, stylesFactory, styleMixins } from '../../../themes'; import { Icon } from '../../Icon/Icon'; const getStyles = stylesFactory((theme: GrafanaTheme) => { - const background = selectThemeVariant( - { - light: theme.palette.gray7, - dark: theme.palette.dark3, - }, - theme.type - ); - return { container: css` display: flex; @@ -22,7 +14,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { border-left: 2px solid rgba(255, 255, 255, 0); &:hover { - background: ${background}; + background: ${styleMixins.hoverColor(theme.colors.bg1, theme)}; border-image: linear-gradient(#f05a28 30%, #fbca0a 99%); border-image-slice: 1; border-style: solid; diff --git a/packages/grafana-ui/src/components/Tooltip/Popover.tsx b/packages/grafana-ui/src/components/Tooltip/Popover.tsx index dd442380628..83469c7b7fd 100644 --- a/packages/grafana-ui/src/components/Tooltip/Popover.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Popover.tsx @@ -1,5 +1,5 @@ import React, { PureComponent } from 'react'; -import * as PopperJS from 'popper.js'; +import { Placement, VirtualElement } from '@popperjs/core'; import { Manager, Popper as ReactPopper, PopperArrowProps } from 'react-popper'; import { Portal } from '../Portal/Portal'; import Transition from 'react-transition-group/Transition'; @@ -23,9 +23,9 @@ export type RenderPopperArrowFn = (props: { arrowProps: PopperArrowProps; placem interface Props extends React.HTMLAttributes { show: boolean; - placement?: PopperJS.Placement; + placement?: Placement; content: PopoverContent; - referenceElement: PopperJS.ReferenceObject; + referenceElement: HTMLElement | VirtualElement; wrapperClassName?: string; renderArrow?: RenderPopperArrowFn; } diff --git a/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx b/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx index 8d3ee22a412..42ad4e43776 100644 --- a/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx +++ b/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import * as PopperJS from 'popper.js'; +import { Placement } from '@popperjs/core'; import { PopoverContent } from './Tooltip'; // This API allows popovers to update Popper's position when e.g. popover content changes @@ -34,13 +34,13 @@ type PopperControllerRenderProp = ( hidePopper: () => void, popperProps: { show: boolean; - placement: PopperJS.Placement; + placement: Placement; content: PopoverContent; } ) => JSX.Element; interface Props { - placement?: PopperJS.Placement; + placement?: Placement; content: PopoverContent; className?: string; children: PopperControllerRenderProp; diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index cd63616c72f..37a94d7767b 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -1,5 +1,5 @@ import React, { createRef, FC } from 'react'; -import * as PopperJS from 'popper.js'; +import { VirtualElement } from '@popperjs/core'; import { Popover } from './Popover'; import { PopoverController, UsingPopperProps } from './PopoverController'; @@ -14,7 +14,7 @@ export interface PopoverContentProps { export type PopoverContent = string | React.ReactElement | ((props: PopoverContentProps) => JSX.Element); export const Tooltip: FC = React.memo(({ children, theme, ...controllerProps }: TooltipProps) => { - const tooltipTriggerRef = createRef(); + const tooltipTriggerRef = createRef(); const popperBackgroundClassName = 'popper__background' + (theme ? ' popper__background--' + theme : ''); return ( diff --git a/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx b/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx index aa1f523542a..c63e3ce6964 100644 --- a/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx +++ b/packages/grafana-ui/src/components/Typeahead/TypeaheadInfo.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import { css, cx } from 'emotion'; -import { CompletionItem, selectThemeVariant, ThemeContext } from '../..'; +import { CompletionItem, ThemeContext } from '../..'; import { GrafanaTheme, renderMarkdown } from '@grafana/data'; const getStyles = (theme: GrafanaTheme, height: number, visible: boolean) => { @@ -11,19 +11,13 @@ const getStyles = (theme: GrafanaTheme, height: number, visible: boolean) => { z-index: 11; padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md}; border-radius: ${theme.border.radius.md}; - border: ${selectThemeVariant( - { light: `solid 1px ${theme.palette.gray5}`, dark: `solid 1px ${theme.palette.dark1}` }, - theme.type - )}; + border: ${theme.colors.border2}; overflow-y: scroll; overflow-x: hidden; outline: none; - background: ${selectThemeVariant({ light: theme.palette.white, dark: theme.palette.dark4 }, theme.type)}; + background: ${theme.colors.bg2}; color: ${theme.colors.text}; - box-shadow: ${selectThemeVariant( - { light: `0 5px 10px 0 ${theme.palette.gray5}`, dark: `0 5px 10px 0 ${theme.palette.black}` }, - theme.type - )}; + box-shadow: 0 0 20px ${theme.colors.dropdownShadow}; visibility: ${visible === true ? 'visible' : 'hidden'}; width: 250px; height: ${height + parseInt(theme.spacing.xxs, 10)}px; diff --git a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx index 3afec001c76..e4b20b0ca33 100644 --- a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx +++ b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx @@ -4,7 +4,6 @@ import React, { useContext } from 'react'; import Highlighter from 'react-highlight-words'; import { css, cx } from 'emotion'; import { GrafanaTheme } from '@grafana/data'; -import { selectThemeVariant } from '../../themes/selectThemeVariant'; import { CompletionItem, CompletionItemKind } from '../../types/completion'; import { ThemeContext } from '../../themes/ThemeContext'; @@ -38,7 +37,7 @@ const getStyles = (theme: GrafanaTheme) => ({ typeaheadItemSelected: css` label: type-ahead-item-selected; - background-color: ${selectThemeVariant({ light: theme.palette.gray6, dark: theme.palette.dark9 }, theme.type)}; + background-color: ${theme.colors.bg2}; `, typeaheadItemMatch: css` diff --git a/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx b/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx index 962f0ecff88..3f7a3b2f053 100644 --- a/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx +++ b/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx @@ -24,7 +24,7 @@ export const BottomLegend = () => { const items = Array.from({ length: legendItems }, (_, i) => i + 1); const legend = ( - + {items.map((_, index) => (
Legend item {index} @@ -47,7 +47,7 @@ export const RightLegend = () => { const items = Array.from({ length: legendItems }, (_, i) => i + 1); const legend = ( - + {items.map((_, index) => (
Legend item {index} diff --git a/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx index b8d5ae64422..f574066dab7 100644 --- a/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx +++ b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx @@ -1,6 +1,7 @@ import React, { FC, CSSProperties, ComponentType } from 'react'; import { useMeasure } from 'react-use'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; +import { LegendPlacement } from '..'; /** * @beta @@ -33,7 +34,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child return
{children(width, height)}
; } - const { position, maxHeight, maxWidth } = legend.props; + const { placement, maxHeight, maxWidth } = legend.props; const [legendRef, legendMeasure] = useMeasure(); let size: VizSize | null = null; @@ -43,7 +44,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child const legendStyle: CSSProperties = {}; - switch (position) { + switch (placement) { case 'bottom': containerStyle.flexDirection = 'column'; legendStyle.maxHeight = maxHeight; @@ -91,7 +92,7 @@ interface VizSize { * @beta */ export interface VizLayoutLegendProps { - position: 'bottom' | 'right'; + placement: LegendPlacement; maxHeight?: string; maxWidth?: string; children: React.ReactNode; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx index b820e04382a..dfdf4942ab6 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { css, cx } from 'emotion'; import { VizLegendSeriesIcon } from './VizLegendSeriesIcon'; -import { VizLegendItem } from './types'; -import { SeriesColorChangeHandler } from './types'; +import { VizLegendItem, SeriesColorChangeHandler } from './types'; import { VizLegendStatsList } from './VizLegendStatsList'; import { useStyles } from '../../themes'; import { GrafanaTheme } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 7b74c0968d5..7b0185f1913 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { css, cx } from 'emotion'; import { VizLegendSeriesIcon } from './VizLegendSeriesIcon'; -import { VizLegendItem } from './types'; -import { SeriesColorChangeHandler } from './types'; +import { VizLegendItem, SeriesColorChangeHandler } from './types'; import { useStyles } from '../../themes/ThemeContext'; import { styleMixins } from '../../themes'; import { GrafanaTheme, formattedValueToString } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss index fb0306ce69b..7be51f5b148 100644 --- a/packages/grafana-ui/src/components/index.scss +++ b/packages/grafana-ui/src/components/index.scss @@ -1,5 +1,4 @@ @import 'ButtonCascader/ButtonCascader'; -@import 'ColorPicker/ColorPicker'; @import 'Drawer/Drawer'; @import 'RefreshPicker/RefreshPicker'; @import 'Forms/Legacy/Select/Select'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 126587861b6..e277e7dcc4e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -17,7 +17,7 @@ export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholde export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker'; export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover'; export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult'; -export { PieChart, PieChartType } from './PieChart/PieChart'; +export { PieChart, PieChartType, PieChartLabelOptions } from './PieChart/PieChart'; export { UnitPicker } from './UnitPicker/UnitPicker'; export { StatsPicker } from './StatsPicker/StatsPicker'; export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker'; @@ -87,7 +87,8 @@ export { LogLabels } from './Logs/LogLabels'; export { LogMessageAnsi } from './Logs/LogMessageAnsi'; export { LogRows } from './Logs/LogRows'; export { getLogRowStyles } from './Logs/getLogRowStyles'; -export { FieldLink } from './Logs/FieldLink'; +export { DataLinkButton } from './DataLinks/DataLinkButton'; +export { FieldLinkList } from './DataLinks/FieldLinkList'; export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup'; // Panel editors export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer'; @@ -103,7 +104,6 @@ export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu'; export { SeriesIcon } from './VizLegend/SeriesIcon'; export { InfoBox } from './InfoBox/InfoBox'; export { FeatureBadge, FeatureInfoBox } from './InfoBox/FeatureInfoBox'; -export { DismissableFeatureInfoBox } from './InfoBox/DismissableFeatureInfoBox'; export { JSONFormatter } from './JSONFormatter/JSONFormatter'; export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer'; @@ -129,7 +129,6 @@ 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'; // Next-gen forms export { Form } from './Forms/Form'; @@ -206,8 +205,9 @@ export { UPlotChart } from './uPlot/Plot'; export * from './uPlot/geometries'; export * from './uPlot/plugins'; export { useRefreshAfterGraphRendered } from './uPlot/hooks'; -export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; +export { usePlotContext, usePlotPluginContext } from './uPlot/context'; export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG'; +export { useGraphNGContext } from './GraphNG/hooks'; export { BarChart } from './BarChart/BarChart'; export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types'; export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx index 327b3b8e0d4..2e543ac5f46 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -6,6 +6,7 @@ import { GraphFieldConfig, DrawStyle } from '../uPlot/config'; import uPlot from 'uplot'; import createMockRaf from 'mock-raf'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; +import { preparePlotData } from './utils'; const mockRaf = createMockRaf(); const setDataMock = jest.fn(); @@ -71,10 +72,9 @@ describe('UPlotChart', () => { const { unmount } = render( @@ -96,10 +96,9 @@ describe('UPlotChart', () => { const { rerender } = render( @@ -116,10 +115,9 @@ describe('UPlotChart', () => { rerender( @@ -134,7 +132,7 @@ describe('UPlotChart', () => { const { data, timeRange, config } = mockData(); const { queryAllByTestId } = render( - + ); expect(queryAllByTestId('uplot-main-div')).toHaveLength(1); @@ -146,10 +144,9 @@ describe('UPlotChart', () => { const { rerender } = render( @@ -164,10 +161,9 @@ describe('UPlotChart', () => { rerender( @@ -182,10 +178,9 @@ describe('UPlotChart', () => { const { rerender } = render( @@ -198,10 +193,9 @@ describe('UPlotChart', () => { rerender( diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 831458f808e..bf6acad03cb 100755 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -4,7 +4,6 @@ import { buildPlotContext, PlotContext } from './context'; import { pluginLog } from './utils'; import { usePlotConfig } from './hooks'; import { PlotProps } from './types'; -import { DataFrame } from '@grafana/data'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import usePrevious from 'react-use/lib/usePrevious'; @@ -19,12 +18,7 @@ export const UPlotChart: React.FC = (props) => { const plotInstance = useRef(); const [isPlotReady, setIsPlotReady] = useState(false); const prevProps = usePrevious(props); - const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig( - props.width, - props.height, - props.timeZone, - props.config - ); + const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config); const getPlotInstance = useCallback(() => { return plotInstance.current; @@ -39,7 +33,7 @@ export const UPlotChart: React.FC = (props) => { // 1. When config is ready and there is no uPlot instance, create new uPlot and return if (isConfigReady && !plotInstance.current) { - plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current); + plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); setIsPlotReady(true); return; } @@ -54,18 +48,18 @@ export const UPlotChart: React.FC = (props) => { return; } - // 3. When config or timezone has changed, re-initialize plot - if (isConfigReady && (props.config !== prevProps.config || props.timeZone !== prevProps.timeZone)) { + // 3. When config has changed re-initialize plot + if (isConfigReady && props.config !== prevProps.config) { if (plotInstance.current) { pluginLog('uPlot core', false, 'destroying instance'); plotInstance.current.destroy(); } - plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current); + plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); return; } // 4. Otherwise, assume only data has changed and update uPlot data - updateData(props.data, props.config, plotInstance.current, prepareData(props.data)); + updateData(props.config, props.data, plotInstance.current); }, [props, isConfigReady]); // When component unmounts, clean the existing uPlot instance @@ -86,16 +80,12 @@ export const UPlotChart: React.FC = (props) => { ); }; -function prepareData(frame: DataFrame): AlignedData { - return frame.fields.map((f) => f.values.toArray()) as AlignedData; -} - -function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement) { +function initializePlot(data: AlignedData | null, config: Options, el: HTMLDivElement) { pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config); return new uPlot(config, data, el); } -function updateData(frame: DataFrame, config: UPlotConfigBuilder, plotInstance?: uPlot, data?: AlignedData | null) { +function updateData(config: UPlotConfigBuilder, data?: AlignedData | null, plotInstance?: uPlot) { if (!plotInstance || !data) { return; } diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx new file mode 100644 index 00000000000..f9beaf2aacc --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from 'react'; +import { DataFrame, DisplayValue, fieldReducers, reduceField } from '@grafana/data'; +import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; +import { VizLegendItem, VizLegendOptions } from '../VizLegend/types'; +import { AxisPlacement } from './config'; +import { VizLayout } from '../VizLayout/VizLayout'; +import { mapMouseEventToMode } from '../GraphNG/utils'; +import { VizLegend } from '../VizLegend/VizLegend'; +import { GraphNGLegendEvent } from '..'; + +const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); + +interface PlotLegendProps extends VizLegendOptions { + data: DataFrame[]; + config: UPlotConfigBuilder; + onSeriesColorChange?: (label: string, color: string) => void; + onLegendClick?: (event: GraphNGLegendEvent) => void; +} + +export const PlotLegend: React.FC = ({ + data, + config, + onSeriesColorChange, + onLegendClick, + ...legend +}) => { + const onLegendLabelClick = useCallback( + (legend: VizLegendItem, event: React.MouseEvent) => { + const { fieldIndex } = legend; + + if (!onLegendClick || !fieldIndex) { + return; + } + + onLegendClick({ + fieldIndex, + mode: mapMouseEventToMode(event), + }); + }, + [onLegendClick] + ); + + const legendItems = config + .getSeries() + .map((s) => { + const seriesConfig = s.props; + const fieldIndex = seriesConfig.dataFrameFieldIndex; + const axisPlacement = config.getAxisPlacement(s.props.scaleKey); + + if (seriesConfig.hideInLegend || !fieldIndex) { + return undefined; + } + + const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex]; + + return { + disabled: !seriesConfig.show ?? false, + fieldIndex, + color: seriesConfig.lineColor!, + label: seriesConfig.fieldName, + yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2, + getDisplayValues: () => { + if (!legend.calcs?.length) { + return []; + } + + const fmt = field.display ?? defaultFormatter; + const fieldCalcs = reduceField({ + field, + reducers: legend.calcs, + }); + + return legend.calcs.map((reducer) => { + return { + ...fmt(fieldCalcs[reducer]), + title: fieldReducers.get(reducer).name, + }; + }); + }, + }; + }) + .filter((i) => i !== undefined) as VizLegendItem[]; + + return ( + + + + ); +}; + +PlotLegend.displayName = 'PlotLegend'; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index 7e27027595e..49b1b2eac64 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -99,13 +99,24 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { /* Minimum grid & tick spacing in CSS pixels */ function calculateSpace(self: uPlot, axisIdx: number, scaleMin: number, scaleMax: number, plotDim: number): number { const axis = self.axes[axisIdx]; + const scale = self.scales[axis.scale!]; - // For x-axis (bottom) we need bigger spacing between labels - if (axis.side === 2) { - return 55; + // for axis left & right + if (axis.side !== 2 || !scale) { + return 30; } - return 30; + const defaultSpacing = 40; + + if (scale.time) { + const maxTicks = plotDim / defaultSpacing; + const increment = (scaleMax - scaleMin) / maxTicks; + const sample = formatTime(self, [scaleMin], axisIdx, defaultSpacing, increment); + const width = measureText(sample[0], 12).width + 18; + return width; + } + + return defaultSpacing; } /** height of x axis or width of y axis in CSS pixels alloted for values, gap & ticks, but excluding axis label */ @@ -129,29 +140,38 @@ function calculateAxisSize(self: uPlot, values: string[], axisIdx: number) { return measureText(maxLength, 12).width + 18; } +const timeUnitSize = { + second: 1000, + minute: 60 * 1000, + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + month: 28 * 24 * 60 * 60 * 1000, + year: 365 * 24 * 60 * 60 * 1000, +}; + /** Format time axis ticks */ function formatTime(self: uPlot, splits: number[], axisIdx: number, foundSpace: number, foundIncr: number): string[] { const timeZone = (self.axes[axisIdx] as any).timeZone; const scale = self.scales.x; - const range = ((scale?.max ?? 0) - (scale?.min ?? 0)) / 1e3; - const oneDay = 86400; - const oneYear = 31536000; - - foundIncr /= 1e3; + const range = (scale?.max ?? 0) - (scale?.min ?? 0); + const yearRoundedToDay = Math.round(timeUnitSize.year / timeUnitSize.day) * timeUnitSize.day; + const incrementRoundedToDay = Math.round(foundIncr / timeUnitSize.day) * timeUnitSize.day; let format = systemDateFormats.interval.minute; - if (foundIncr < 1) { + if (foundIncr < timeUnitSize.second) { format = systemDateFormats.interval.second.replace('ss', 'ss.SS'); - } else if (foundIncr <= 45) { + } else if (foundIncr <= timeUnitSize.minute) { format = systemDateFormats.interval.second; - } else if (foundIncr <= 7200 || range <= oneDay) { + } else if (foundIncr <= timeUnitSize.hour || range <= timeUnitSize.day) { format = systemDateFormats.interval.minute; - } else if (foundIncr <= 80000) { + } else if (foundIncr <= timeUnitSize.day) { format = systemDateFormats.interval.hour; - } else if (foundIncr <= 2419200 || range <= oneYear) { + } else if (foundIncr <= timeUnitSize.month || range < timeUnitSize.year) { format = systemDateFormats.interval.day; - } else if (foundIncr <= 31536000) { + } else if (incrementRoundedToDay === yearRoundedToDay) { + format = systemDateFormats.interval.year; + } else if (foundIncr <= timeUnitSize.year) { format = systemDateFormats.interval.month; } diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index 82211932072..ad911915f3e 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -40,6 +40,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -103,6 +104,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -171,6 +173,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -219,6 +222,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -268,6 +272,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -341,6 +346,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -477,6 +483,7 @@ describe('UPlotConfigBuilder', () => { "width": 1, }, ], + "tzDate": [Function], } `); }); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 2f0da9bfa0c..07f37b5de63 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -3,8 +3,9 @@ import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder'; import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder'; import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder'; import { AxisPlacement } from '../config'; -import { Cursor, Band, Hooks, BBox } from 'uplot'; +import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot'; import { defaultsDeep } from 'lodash'; +import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data'; type valueof = T[keyof T]; @@ -20,6 +21,8 @@ export class UPlotConfigBuilder { private hasBottomAxis = false; private hooks: Hooks.Arrays = {}; + constructor(private getTimeZone = () => DefaultTimeZone) {} + addHook(type: keyof Hooks.Defs, hook: valueof) { if (!this.hooks[type]) { this.hooks[type] = []; @@ -110,6 +113,8 @@ export class UPlotConfigBuilder { config.cursor = this.cursor || {}; + config.tzDate = this.tzDate; + // When bands exist, only keep fill when defined if (this.bands?.length) { config.bands = this.bands; @@ -159,4 +164,17 @@ export class UPlotConfigBuilder { return axes; } + + private tzDate = (ts: number) => { + if (!this.getTimeZone) { + return new Date(ts); + } + const tz = getTimeZoneInfo(this.getTimeZone(), Date.now())?.ianaName; + + if (!tz) { + return new Date(ts); + } + + return uPlot.tzDate(new Date(ts), tz); + }; } diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts index 13956dac779..24e743dfd2d 100644 --- a/packages/grafana-ui/src/components/uPlot/context.ts +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -1,7 +1,6 @@ -import React, { useCallback, useContext } from 'react'; -import uPlot, { Series } from 'uplot'; +import React, { useContext } from 'react'; +import uPlot, { AlignedData, Series } from 'uplot'; import { PlotPlugin } from './types'; -import { DataFrame, Field, FieldConfig } from '@grafana/data'; interface PlotCanvasContextType { // canvas size css pxs @@ -26,7 +25,7 @@ interface PlotContextType extends PlotPluginsContextType { getSeries: () => Series[]; getCanvas: () => PlotCanvasContextType; canvasRef: any; - data: DataFrame; + data: AlignedData; } export const PlotContext = React.createContext({} as PlotContextType); @@ -51,85 +50,10 @@ export const usePlotPluginContext = (): PlotPluginsContextType => { }; }; -// Exposes API for building uPlot config - -interface PlotDataAPI { - /** Data frame passed to graph, x-axis aligned */ - data: DataFrame; - /** Returns field by index */ - getField: (idx: number) => Field; - /** Returns x-axis fields */ - getXAxisFields: () => Field[]; - /** Returns x-axis fields */ - getYAxisFields: () => Field[]; - /** Returns field value by field and value index */ - getFieldValue: (fieldIdx: number, rowIdx: number) => any; - /** Returns field config by field index */ - getFieldConfig: (fieldIdx: number) => FieldConfig; -} - -export const usePlotData = (): PlotDataAPI => { - const ctx = usePlotContext(); - - const getField = useCallback( - (idx: number) => { - if (!ctx) { - throwWhenNoContext('usePlotData'); - } - return ctx!.data.fields[idx]; - }, - [ctx] - ); - - const getFieldConfig = useCallback( - (idx: number) => { - const field: Field = getField(idx); - return field.config; - }, - [ctx] - ); - - const getFieldValue = useCallback( - (fieldIdx: number, rowIdx: number) => { - const field: Field = getField(fieldIdx); - return field.values.get(rowIdx); - }, - [ctx] - ); - - const getXAxisFields = useCallback(() => { - // by uPlot convention x-axis is always first field - // this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html) - return [getField(0)]; - }, [ctx]); - - const getYAxisFields = useCallback(() => { - if (!ctx) { - throwWhenNoContext('usePlotData'); - } - // by uPlot convention x-axis is always first field - // this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html) - return ctx!.data.fields.slice(1); - }, [ctx]); - - if (!ctx) { - throwWhenNoContext('usePlotData'); - } - - return { - data: ctx.data, - getField, - getFieldValue, - getFieldConfig, - getXAxisFields, - getYAxisFields, - }; -}; - export const buildPlotContext = ( isPlotReady: boolean, canvasRef: any, - data: DataFrame, + data: AlignedData, registerPlugin: any, getPlotInstance: () => uPlot | undefined ): PlotContextType => { diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts index 5d7f1b040c5..4589fe28526 100644 --- a/packages/grafana-ui/src/components/uPlot/hooks.ts +++ b/packages/grafana-ui/src/components/uPlot/hooks.ts @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { PlotPlugin } from './types'; import { pluginLog } from './utils'; -import uPlot, { Options, PaddingSide } from 'uplot'; -import { getTimeZoneInfo, TimeZone } from '@grafana/data'; +import { Options, PaddingSide } from 'uplot'; import { usePlotPluginContext } from './context'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import usePrevious from 'react-use/lib/usePrevious'; +import useMountedState from 'react-use/lib/useMountedState'; export const usePlotPlugins = () => { /** @@ -108,22 +108,11 @@ export const DEFAULT_PLOT_CONFIG: Partial = { hooks: {}, }; -export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => { +export const usePlotConfig = (width: number, height: number, configBuilder: UPlotConfigBuilder) => { const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins(); const [isConfigReady, setIsConfigReady] = useState(false); const currentConfig = useRef(); - const tzDate = useMemo(() => { - let fmt = undefined; - - const tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName; - - if (tz) { - fmt = (ts: number) => uPlot.tzDate(new Date(ts), tz); - } - - return fmt; - }, [timeZone]); useLayoutEffect(() => { if (!arePluginsReady) { @@ -137,12 +126,11 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, plugins: Object.entries(plugins).map((p) => ({ hooks: p[1].hooks, })), - tzDate, ...configBuilder.getConfig(), }; setIsConfigReady(true); - }, [arePluginsReady, plugins, width, height, tzDate, configBuilder]); + }, [arePluginsReady, plugins, width, height, configBuilder]); return { isConfigReady, @@ -158,6 +146,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, */ export const useRefreshAfterGraphRendered = (pluginId: string) => { const pluginsApi = usePlotPluginContext(); + const isMounted = useMountedState(); const [renderToken, setRenderToken] = useState(0); useEffect(() => { @@ -166,7 +155,9 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => { hooks: { // refresh events when uPlot draws draw: () => { - setRenderToken((c) => c + 1); + if (isMounted()) { + setRenderToken((c) => c + 1); + } return; }, }, diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx index 580b2372f92..85aa77fc773 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx @@ -1,5 +1,6 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { Global, css as cssCore } from '@emotion/core'; +import React, { useCallback, useEffect, useState } from 'react'; +import { css as cssCore, Global } from '@emotion/core'; +import { CartesianCoords2D } from '@grafana/data'; import { PlotPluginProps } from '../types'; import { usePlotPluginContext } from '../context'; @@ -10,9 +11,9 @@ interface ClickPluginAPI { point: { seriesIdx: number | null; dataIdx: number | null }; coords: { // coords relative to plot canvas, css px - plotCanvas: Coords; + plotCanvas: CartesianCoords2D; // coords relative to viewport , css px - viewport: Coords; + viewport: CartesianCoords2D; }; // coords relative to plot canvas, css px clearSelection: () => void; @@ -26,11 +27,6 @@ interface ClickPluginProps extends PlotPluginProps { children: (api: ClickPluginAPI) => React.ReactElement | null; } -interface Coords { - x: number; - y: number; -} - // Exposes API for Graph click interactions export const ClickPlugin: React.FC = ({ id, onClick, children }) => { const pluginId = `ClickPlugin:${id}`; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index 0d738799937..fb085c469f8 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -1,29 +1,40 @@ import React from 'react'; import { Portal } from '../../Portal/Portal'; -import { usePlotContext, usePlotData } from '../context'; +import { usePlotContext } from '../context'; import { CursorPlugin } from './CursorPlugin'; import { SeriesTable, SeriesTableRowProps } from '../../Graph/GraphTooltip/SeriesTable'; -import { FieldType, formattedValueToString, getDisplayProcessor, getFieldDisplayName, TimeZone } from '@grafana/data'; +import { + DataFrame, + FieldType, + formattedValueToString, + getDisplayProcessor, + getFieldDisplayName, + TimeZone, +} from '@grafana/data'; import { TooltipContainer } from '../../Chart/TooltipContainer'; import { TooltipMode } from '../../Chart/Tooltip'; +import { useGraphNGContext } from '../../GraphNG/hooks'; interface TooltipPluginProps { mode?: TooltipMode; timeZone: TimeZone; + data: DataFrame[]; } /** * @alpha */ -export const TooltipPlugin: React.FC = ({ mode = 'single', timeZone }) => { +export const TooltipPlugin: React.FC = ({ mode = 'single', timeZone, ...otherProps }) => { const pluginId = 'PlotTooltip'; const plotContext = usePlotContext(); - const { data, getField, getXAxisFields } = usePlotData(); + const graphContext = useGraphNGContext(); - const xAxisFields = getXAxisFields(); - // assuming single x-axis - const xAxisField = xAxisFields[0]; - const xAxisFmt = xAxisField.display || getDisplayProcessor({ field: xAxisField, timeZone }); + let xField = graphContext.getXAxisField(otherProps.data); + if (!xField) { + return null; + } + + const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); return ( @@ -31,7 +42,6 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t if (!plotContext.getPlotInstance()) { return null; } - let tooltip = null; // when no no cursor interaction @@ -39,10 +49,17 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t return null; } + const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text; + + // origin field/frame indexes for inspecting the data + const originFieldIndex = focusedSeriesIdx + ? graphContext.mapSeriesIndexToDataFrameFieldIndex(focusedSeriesIdx) + : null; + // when interacting with a point in single mode - if (mode === 'single' && focusedSeriesIdx !== null) { - const xVal = xAxisFmt(xAxisFields[0]!.values.get(focusedPointIdx)).text; - const field = getField(focusedSeriesIdx); + if (mode === 'single' && originFieldIndex !== null) { + const field = otherProps.data[originFieldIndex.frameIndex].fields[originFieldIndex.fieldIndex]; + const fieldFmt = field.display || getDisplayProcessor({ field, timeZone }); tooltip = ( = ({ mode = 'single', t { // TODO: align with uPlot typings color: (plotContext.getSeries()[focusedSeriesIdx!].stroke as any)(), - label: getFieldDisplayName(field, data), + label: getFieldDisplayName(field, otherProps.data[originFieldIndex.frameIndex]), value: fieldFmt(field.values.get(focusedPointIdx)).text, }, ]} @@ -60,10 +77,11 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t } if (mode === 'multi') { - const xVal = xAxisFmt(xAxisFields[0].values.get(focusedPointIdx)).text; - tooltip = ( - ((agg, f, i) => { + let series: SeriesTableRowProps[] = []; + + for (let i = 0; i < otherProps.data.length; i++) { + series = series.concat( + otherProps.data[i].fields.reduce((agg, f, j) => { // skipping time field and non-numeric fields if (f.type === FieldType.time || f.type !== FieldType.number) { return agg; @@ -77,16 +95,19 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t ...agg, { // TODO: align with uPlot typings - color: (plotContext.getSeries()[i].stroke as any)!(), - label: getFieldDisplayName(f, data), + color: (plotContext.getSeries()[j].stroke as any)!(), + label: getFieldDisplayName(f, otherProps.data[i]), value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))), - isActive: focusedSeriesIdx === i, + isActive: originFieldIndex + ? originFieldIndex.frameIndex === i && originFieldIndex.fieldIndex === j + : false, }, ]; - }, [])} - timestamp={xVal} - /> - ); + }, []) + ); + } + + tooltip = ; } if (!tooltip) { diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 62ce4e981b3..e894ae49256 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -1,9 +1,12 @@ import React from 'react'; -import uPlot, { Options, Hooks } from 'uplot'; -import { DataFrame, TimeRange, TimeZone } from '@grafana/data'; +import uPlot, { Options, Hooks, AlignedData } from 'uplot'; +import { TimeRange } from '@grafana/data'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; -export type PlotConfig = Pick; +export type PlotConfig = Pick< + Options, + 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' +>; export type PlotPlugin = { id: string; @@ -17,12 +20,11 @@ export interface PlotPluginProps { } export interface PlotProps { - data: DataFrame; - timeRange: TimeRange; - timeZone: TimeZone; + data: AlignedData; width: number; height: number; config: UPlotConfigBuilder; + timeRange: TimeRange; children?: React.ReactNode; } diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 760b0d52e71..a0207af1818 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -1,5 +1,6 @@ +import { DataFrame, dateTime, FieldType } from '@grafana/data'; import throttle from 'lodash/throttle'; -import { Options } from 'uplot'; +import { AlignedData, Options } from 'uplot'; import { PlotPlugin, PlotProps } from './types'; const LOGGING_ENABLED = false; @@ -31,29 +32,32 @@ export function buildPlotConfig(props: PlotProps, plugins: Record { + if (f.type === FieldType.time) { + if (f.values.length > 0 && typeof f.values.get(0) === 'string') { + const timestamps = []; + for (let i = 0; i < f.values.length; i++) { + timestamps.push(dateTime(f.values.get(i)).valueOf()); + } + return timestamps; + } + return f.values.toArray(); } - } - return isTimeSeries; + return f.values.toArray(); + }) as AlignedData; } // Dev helpers + +/** @internal */ export const throttledLog = throttle((...t: any[]) => { console.log(...t); }, 500); +/** @internal */ export function pluginLog(id: string, throttle = false, ...t: any[]) { if (process.env.NODE_ENV === 'production' || !LOGGING_ENABLED) { return; diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index 72eead3f81c..03a6156aa9b 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -122,6 +122,7 @@ export type IconName = | 'cloud' | 'draggabledots' | 'folder-upload' + | 'palette' | 'gf-interpolation-linear' | 'gf-interpolation-smooth' | 'gf-interpolation-step-before' @@ -246,6 +247,7 @@ export const getAvailableIcons = (): IconName[] => [ 'cloud', 'draggabledots', 'folder-upload', + 'palette', 'gf-interpolation-linear', 'gf-interpolation-smooth', 'gf-interpolation-step-before', diff --git a/packages/grafana-ui/src/utils/children.ts b/packages/grafana-ui/src/utils/children.ts new file mode 100644 index 00000000000..d2556ea23e1 --- /dev/null +++ b/packages/grafana-ui/src/utils/children.ts @@ -0,0 +1,19 @@ +import React, { ReactElement } from 'react'; + +/** Returns the ID value of the first, and only, child element */ +export function getChildId(children: ReactElement): string | undefined { + let inputId: unknown; + + // Get the first, and only, child to retrieve form input's id + const child = React.Children.only(children); + + // Retrieve input's id to apply on the label for correct click interaction + // For some components (like Select), we want to get the ID from a different prop + if ('id' in child?.props) { + inputId = child.props.id; + } else if ('inputId' in child.props) { + inputId = child?.props.inputId; + } + + return typeof inputId === 'string' ? inputId : undefined; +} diff --git a/packages/grafana-ui/src/utils/scrollbar.ts b/packages/grafana-ui/src/utils/scrollbar.ts index 55bd84f62e9..21a07ec99aa 100644 --- a/packages/grafana-ui/src/utils/scrollbar.ts +++ b/packages/grafana-ui/src/utils/scrollbar.ts @@ -29,5 +29,6 @@ export function getScrollbarWidth() { } else { scrollbarWidth = 0; } + return scrollbarWidth || 0; } diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx index c048ed3de8b..8b42780a158 100644 --- a/packages/grafana-ui/src/utils/standardEditors.tsx +++ b/packages/grafana-ui/src/utils/standardEditors.tsx @@ -77,7 +77,7 @@ export const getStandardFieldConfigs = () => { placeholder: 'none', }, - shouldApply: (field) => field.type === FieldType.number, + shouldApply: () => true, category, }; diff --git a/packages/grafana-ui/src/utils/storybook/CombinationsRowRenderer.tsx b/packages/grafana-ui/src/utils/storybook/CombinationsRowRenderer.tsx deleted file mode 100644 index 00d77c4d2e4..00000000000 --- a/packages/grafana-ui/src/utils/storybook/CombinationsRowRenderer.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import { css } from 'emotion'; -import { withTheme } from '../../themes'; -import { Themeable } from '../../types'; -import { selectThemeVariant } from '../../themes/selectThemeVariant'; -import prettyFormat from 'pretty-format'; - -const detailsRenderer: (combinationProps: any) => JSX.Element = (props) => { - const listStyle = css` - padding: 0; - margin: 0; - list-style: none; - `; - - return ( -
    -
  • - {Object.keys(props).map((key, i) => { - return ( -
  • - {key}: {props[key]} -
  • - ); - })} - -
- ); -}; - -interface CombinationsRowRendererProps extends Themeable { - Component: React.ComponentType; - props: any; - options: any; -} - -const CombinationsRowRenderer: React.FunctionComponent = ({ - Component, - props, - theme, -}) => { - const el = React.createElement(Component, props); - - const borderColor = selectThemeVariant( - { - dark: theme.palette.dark8, - light: theme.palette.gray5, - }, - theme.type - ); - - const rowStyle = css` - display: flex; - width: 100%; - flex-direction: row; - border: 1px solid ${borderColor}; - border-bottom: none; - - &:last-child { - border-bottom: 1px solid ${borderColor}; - } - `; - const cellStyle = css` - padding: 10px; - `; - const previewCellStyle = css` - display: flex; - align-items: center; - justify-content: center; - width: 200px; - flex-shrink: 1; - border-right: 1px solid ${borderColor}; - ${cellStyle}; - `; - const variantsCellStyle = css` - width: 200px; - border-right: 1px solid ${borderColor}; - ${cellStyle}; - `; - - return ( -
-
{el}
-
{detailsRenderer(props)}
-
- {prettyFormat(el, { - plugins: [prettyFormat.plugins.ReactElement], - printFunctionName: true, - })} -
-
- ); -}; - -export const ThemeableCombinationsRowRenderer = withTheme(CombinationsRowRenderer); diff --git a/packages/grafana-ui/src/utils/useComponetInstanceId.ts b/packages/grafana-ui/src/utils/useComponetInstanceId.ts new file mode 100644 index 00000000000..c8e85950478 --- /dev/null +++ b/packages/grafana-ui/src/utils/useComponetInstanceId.ts @@ -0,0 +1,14 @@ +import { useRef } from 'react'; + +let uniqueId = 0; +const getUniqueId = () => uniqueId++; + +export function useComponentInstanceId(prefix: string): string { + const idRef = useRef(null); + + if (idRef.current === null) { + idRef.current = prefix + getUniqueId(); + } + + return idRef.current!.toString(); +} diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx index 61364e37dda..017ff5ea4d4 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx @@ -19,50 +19,56 @@ import { createStyle } from '../../Theme'; import { css } from 'emotion'; export const getStyles = createStyle(() => { - const ScrubberHandleExpansion = css` - label: ScrubberHandleExpansion; - cursor: col-resize; - fill-opacity: 0; - fill: #44f; - `; - const ScrubberHandle = css` - label: ScrubberHandle; - cursor: col-resize; - fill: #555; - `; - const ScrubberLine = css` - label: ScrubberLine; - pointer-events: none; - stroke: #555; - `; return { + ScrubberHandleExpansion: cx( + css` + label: ScrubberHandleExpansion; + cursor: col-resize; + fill-opacity: 0; + fill: #44f; + `, + 'scrubber-handle-expansion' + ), + ScrubberHandle: cx( + css` + label: ScrubberHandle; + cursor: col-resize; + fill: #555; + `, + 'scrubber-handle' + ), + ScrubberLine: cx( + css` + label: ScrubberLine; + pointer-events: none; + stroke: #555; + `, + 'scrubber-line' + ), ScrubberDragging: css` label: ScrubberDragging; - & .${ScrubberHandleExpansion} { + & .scrubber-handle-expansion { fill-opacity: 1; } - & .${ScrubberHandle} { + & .scrubber-handle { fill: #44f; } - & > .${ScrubberLine} { + & > .scrubber-line { stroke: #44f; } `, ScrubberHandles: css` label: ScrubberHandles; - &:hover > .${ScrubberHandleExpansion} { + &:hover > .scrubber-handle-expansion { fill-opacity: 1; } - &:hover > .${ScrubberHandle} { + &:hover > .scrubber-handle { fill: #44f; } - &:hover + .${ScrubberLine} { + &:hover + .scrubber.line { stroke: #44f; } `, - ScrubberHandleExpansion, - ScrubberHandle, - ScrubberLine, }; }); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx index 4371c12ef14..8bbb4f1f5f6 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx @@ -18,13 +18,10 @@ import { css } from 'emotion'; import GraphTicks from './GraphTicks'; import Scrubber from './Scrubber'; -import { TUpdateViewRangeTimeFunction, UIButton, ViewRange, ViewRangeTimeUpdate } from '../..'; -import { withTheme, Theme, autoColor } from '../../Theme'; -import { TNil } from '../..'; +import { TUpdateViewRangeTimeFunction, UIButton, ViewRange, ViewRangeTimeUpdate, TNil } from '../..'; +import { withTheme, Theme, autoColor, createStyle } from '../../Theme'; import DraggableManager, { DraggableBounds, DraggingUpdate, EUpdateTypes } from '../../utils/DraggableManager'; -import { createStyle } from '../../Theme'; - export const getStyles = createStyle((theme: Theme) => { // Need this cause emotion will merge emotion generated classes into single className if used with cx from emotion // package and the selector won't work diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx index e7b23ddadcf..ffcc1743e4b 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx @@ -34,13 +34,8 @@ import { getTraceLinks } from '../model/link-patterns'; import ExternalLinks from '../common/ExternalLinks'; import { createStyle } from '../Theme'; import { uTxMuted } from '../uberUtilityStyles'; -import { useMemo } from 'react'; const getStyles = createStyle((theme: Theme) => { - const TracePageHeaderOverviewItemValueDetail = css` - label: TracePageHeaderOverviewItemValueDetail; - color: #aaa; - `; return { TracePageHeader: css` label: TracePageHeader; @@ -118,10 +113,16 @@ const getStyles = createStyle((theme: Theme) => { border-bottom: 1px solid #e4e4e4; padding: 0.25rem 0.5rem !important; `, - TracePageHeaderOverviewItemValueDetail, + TracePageHeaderOverviewItemValueDetail: cx( + css` + label: TracePageHeaderOverviewItemValueDetail; + color: #aaa; + `, + 'trace-item-value-detail' + ), TracePageHeaderOverviewItemValue: css` label: TracePageHeaderOverviewItemValue; - &:hover > .${TracePageHeaderOverviewItemValueDetail} { + &:hover > .trace-item-value-detail { color: unset; } `, @@ -164,13 +165,13 @@ export const HEADER_ITEMS = [ { key: 'timestamp', label: 'Trace Start', - renderer(trace: Trace, styles?: ReturnType) { + renderer(trace: Trace, styles: ReturnType) { const dateStr = formatDatetime(trace.startTime); const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/); return match ? ( - + {match[1]} - {match[2]} + {match[2]} ) : ( dateStr @@ -223,7 +224,7 @@ export default function TracePageHeader(props: TracePageHeaderEmbedProps) { } = props; const styles = getStyles(useTheme()); - const links = useMemo(() => { + const links = React.useMemo(() => { if (!trace) { return []; } diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx index d3e923a4e1a..7af95d6e707 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx @@ -24,6 +24,7 @@ import { TNil } from '../types'; import { UIButton, UIInputGroup } from '../uiElementsContext'; import { createStyle } from '../Theme'; import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles'; +// eslint-disable-next-line no-duplicate-imports import { memo } from 'react'; export const getStyles = createStyle(() => { diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx index f17bc030b43..bfde2b39915 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx @@ -30,7 +30,7 @@ import AccordianReferences from './AccordianReferences'; import { autoColor, createStyle, Theme, useTheme } from '../../Theme'; import { UIDivider } from '../../uiElementsContext'; import { ubFlex, ubFlexAuto, ubItemsCenter, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles'; -import { FieldLink, TextArea } from '@grafana/ui'; +import { DataLinkButton, TextArea } from '@grafana/ui'; import { CreateSpanLink } from '../types'; const getStyles = createStyle((theme: Theme) => { @@ -183,7 +183,7 @@ export default function SpanDetail(props: SpanDetailProps) {
{link ? ( - + ) : null}
diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 32a6d031940..0b23cc40171 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -91,8 +91,8 @@ func GetAlerts(c *models.ReqContext) response.Response { } for _, d := range searchQuery.Result { - if d.Type == search.DashHitDB && d.Id > 0 { - dashboardIDs = append(dashboardIDs, d.Id) + if d.Type == search.DashHitDB && d.ID > 0 { + dashboardIDs = append(dashboardIDs, d.ID) } } diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go index 3cedc534c50..739eb1b3beb 100644 --- a/pkg/api/alerting_test.go +++ b/pkg/api/alerting_test.go @@ -112,8 +112,8 @@ func TestAlertingAPIEndpoint(t *testing.T) { bus.AddHandler("test", func(query *search.Query) error { searchQuery = query query.Result = search.HitList{ - &search.Hit{Id: 1}, - &search.Hit{Id: 2}, + &search.Hit{ID: 1}, + &search.Hit{ID: 2}, } return nil }) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index c7ef7ca995e..d72e4cb3853 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -149,7 +149,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { if hs.Cfg.IsPanelLibraryEnabled() { // load library panels JSON for this dashboard - err = hs.LibraryPanelService.LoadLibraryPanelsForDashboard(dash) + err = hs.LibraryPanelService.LoadLibraryPanelsForDashboard(c, dash) if err != nil { return response.Error(500, "Error while loading library panels", err) } @@ -220,7 +220,7 @@ func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response { if hs.Cfg.IsPanelLibraryEnabled() { // disconnect all library panels for this dashboard - err := hs.LibraryPanelService.DisconnectLibraryPanelsForDashboard(dash) + err := hs.LibraryPanelService.DisconnectLibraryPanelsForDashboard(c, dash) if err != nil { hs.log.Error("Failed to disconnect library panels", "dashboard", dash.Id, "user", c.SignedInUser.UserId, "error", err) } diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index 3d5c8553ecc..df9dae8e705 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -32,7 +32,7 @@ func (hs *HTTPServer) GetDashboardPermissionList(c *models.ReqContext) response. filteredAcls := make([]*models.DashboardAclInfoDTO, 0, len(acl)) for _, perm := range acl { - if dtos.IsHiddenUser(perm.UserLogin, c.SignedInUser, hs.Cfg) { + if perm.UserId > 0 && dtos.IsHiddenUser(perm.UserLogin, c.SignedInUser, hs.Cfg) { continue } diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 9a84c8caec4..3d96f3b7948 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -36,6 +36,7 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { Name: ds.Name, Url: ds.Url, Type: ds.Type, + TypeName: ds.Type, Access: ds.Access, Password: ds.Password, Database: ds.Database, @@ -48,6 +49,7 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { if plugin, exists := plugins.DataSources[ds.Type]; exists { dsItem.TypeLogoUrl = plugin.Info.Logos.Small + dsItem.TypeName = plugin.Name } else { dsItem.TypeLogoUrl = "public/img/icn-datasource.svg" } diff --git a/pkg/api/dtos/datasource.go b/pkg/api/dtos/datasource.go index 9ab568e4e9d..58a40d8d327 100644 --- a/pkg/api/dtos/datasource.go +++ b/pkg/api/dtos/datasource.go @@ -36,6 +36,7 @@ type DataSourceListItemDTO struct { OrgId int64 `json:"orgId"` Name string `json:"name"` Type string `json:"type"` + TypeName string `json:"typeName"` TypeLogoUrl string `json:"typeLogoUrl"` Access models.DsAccess `json:"access"` Url string `json:"url"` diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 00ddf796241..da40cea68ab 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -62,3 +62,6 @@ type NavLink struct { HideFromTabs bool `json:"hideFromTabs,omitempty"` Children []*NavLink `json:"children,omitempty"` } + +// NavIDCfg is the id for org configuration navigation node +const NavIDCfg = "cfg" diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 48131ccfab5..a884627b8e6 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -77,7 +77,7 @@ func GetGravatarUrlWithDefault(text string, defaultText string) string { } func IsHiddenUser(userLogin string, signedInUser *models.SignedInUser, cfg *setting.Cfg) bool { - if signedInUser.IsGrafanaAdmin || userLogin == signedInUser.Login { + if userLogin == "" || signedInUser.IsGrafanaAdmin || userLogin == signedInUser.Login { return false } diff --git a/pkg/api/dtos/models_test.go b/pkg/api/dtos/models_test.go new file mode 100644 index 00000000000..522ea8ba944 --- /dev/null +++ b/pkg/api/dtos/models_test.go @@ -0,0 +1,86 @@ +package dtos + +import ( + "testing" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" +) + +func TestIsHiddenUser(t *testing.T) { + emptyHiddenUsers := map[string]struct{}{} + hiddenUser := map[string]struct{}{ + "user": {}, + } + + testcases := []struct { + desc string + userLogin string + signedInUser *models.SignedInUser + hiddenUsers map[string]struct{} + expected bool + }{ + { + desc: "non-server admin user should see non-hidden user", + userLogin: "user", + signedInUser: &models.SignedInUser{ + IsGrafanaAdmin: false, + Login: "admin", + }, + hiddenUsers: emptyHiddenUsers, + expected: false, + }, + { + desc: "non-server admin user should not see hidden user", + userLogin: "user", + signedInUser: &models.SignedInUser{ + IsGrafanaAdmin: false, + Login: "admin", + }, + hiddenUsers: hiddenUser, + expected: true, + }, + { + desc: "non-server admin user should see himself, even if he's hidden", + userLogin: "admin", + signedInUser: &models.SignedInUser{ + IsGrafanaAdmin: false, + Login: "admin", + }, + hiddenUsers: map[string]struct{}{ + "admin": {}, + }, + expected: false, + }, + { + desc: "server admin user should see hidden user", + userLogin: "user", + signedInUser: &models.SignedInUser{ + IsGrafanaAdmin: true, + Login: "admin", + }, + hiddenUsers: hiddenUser, + expected: false, + }, + { + desc: "server admin user should see non-hidden user", + userLogin: "user", + signedInUser: &models.SignedInUser{ + IsGrafanaAdmin: true, + Login: "admin", + }, + hiddenUsers: emptyHiddenUsers, + expected: false, + }, + } + + for _, c := range testcases { + t.Run(c.desc, func(t *testing.T) { + isHidden := IsHiddenUser(c.userLogin, c.signedInUser, &setting.Cfg{ + HiddenUsers: c.hiddenUsers, + }) + assert.Equal(t, c.expected, isHidden) + }) + } +} diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index d7f64643ddb..21c380e30ae 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -34,7 +34,7 @@ func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Res filteredAcls := make([]*models.DashboardAclInfoDTO, 0, len(acl)) for _, perm := range acl { - if dtos.IsHiddenUser(perm.UserLogin, c.SignedInUser, hs.Cfg) { + if perm.UserId > 0 && dtos.IsHiddenUser(perm.UserLogin, c.SignedInUser, hs.Cfg) { continue } diff --git a/pkg/api/frontend_logging_test.go b/pkg/api/frontend_logging_test.go index f04f35e42ce..ee78f694a86 100644 --- a/pkg/api/frontend_logging_test.go +++ b/pkg/api/frontend_logging_test.go @@ -4,6 +4,7 @@ import ( "errors" "io/ioutil" "net/http" + "net/url" "os" "strings" "testing" @@ -45,8 +46,12 @@ func logSentryEventScenario(t *testing.T, desc string, event frontendlogging.Fro sc := setupScenarioContext(t, "/log") + cdnRootURL, e := url.Parse("https://storage.googleapis.com/grafana-static-assets") + require.NoError(t, e) + cfg := &setting.Cfg{ StaticRootPath: "/staticroot", + CDNRootURL: cdnRootURL, } readSourceMap := func(dir string, path string) ([]byte, error) { @@ -252,6 +257,12 @@ func TestFrontendLoggingEndpoint(t *testing.T) { Lineno: 3, Colno: 10, }, + { + Function: "cdn", + Filename: "https://storage.googleapis.com/grafana-static-assets/grafana-oss/pre-releases/7.5.0-11925pre/public/build/foo.js", // source map found and mapped + Lineno: 3, + Colno: 10, + }, }, }, }, @@ -268,8 +279,9 @@ func TestFrontendLoggingEndpoint(t *testing.T) { at explode (http://localhost:3000/public/build/error.js:3:10) at wat (http://localhost:3000/public/build/bar.js:3:10) at nope (http://localhost:3000/baz.js:3:10) - at fake (http://localhost:3000/public/build/../../secrets.txt:3:10)`) - assert.Len(t, sourceMapReads, 5) + at fake (http://localhost:3000/public/build/../../secrets.txt:3:10) + at ? (core|webpack:///./some_source.ts:3:2)`) + assert.Len(t, sourceMapReads, 6) assert.Equal(t, "/staticroot", sourceMapReads[0].dir) assert.Equal(t, "build/moo/foo.js.map", sourceMapReads[0].path) assert.Equal(t, "/usr/local/telepathic-panel", sourceMapReads[1].dir) @@ -280,6 +292,8 @@ func TestFrontendLoggingEndpoint(t *testing.T) { assert.Equal(t, "build/bar.js.map", sourceMapReads[3].path) assert.Equal(t, "/staticroot", sourceMapReads[4].dir) assert.Equal(t, "secrets.txt.map", sourceMapReads[4].path) + assert.Equal(t, "/staticroot", sourceMapReads[5].dir) + assert.Equal(t, "build/foo.js.map", sourceMapReads[5].path) }) }) } diff --git a/pkg/api/frontendlogging/source_maps.go b/pkg/api/frontendlogging/source_maps.go index 151903662df..818bf7649cd 100644 --- a/pkg/api/frontendlogging/source_maps.go +++ b/pkg/api/frontendlogging/source_maps.go @@ -68,13 +68,16 @@ func (store *SourceMapStore) guessSourceMapLocation(sourceURL string) (*sourceMa return nil, err } - // determine if source comes from grafana core, look in public build dir - if strings.HasPrefix(u.Path, "/public/build/") { - return &sourceMapLocation{ - dir: store.cfg.StaticRootPath, - path: filepath.Join("build", u.Path[len("/public/build/"):]) + ".map", - pluginID: "", - }, nil + // determine if source comes from grafana core, locally or CDN, look in public build dir on fs + if strings.HasPrefix(u.Path, "/public/build/") || (store.cfg.CDNRootURL != nil && strings.HasPrefix(sourceURL, store.cfg.CDNRootURL.String()) && strings.Contains(u.Path, "/public/build/")) { + pathParts := strings.SplitN(u.Path, "/public/build/", 2) + if len(pathParts) == 2 { + return &sourceMapLocation{ + dir: store.cfg.StaticRootPath, + path: filepath.Join("build", pathParts[1]+".map"), + pluginID: "", + }, nil + } // if source comes from a plugin, look in plugin dir } else if strings.HasPrefix(u.Path, "/public/plugins/") { for _, route := range plugins.StaticRoutes { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 78f9c2b7a47..16433b94be9 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -224,8 +224,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "commit": commit, "buildstamp": buildstamp, "edition": hs.License.Edition(), - "latestVersion": plugins.GrafanaLatestVersion, - "hasUpdate": plugins.GrafanaHasUpdate, + "latestVersion": hs.PluginManager.GrafanaLatestVersion, + "hasUpdate": hs.PluginManager.GrafanaHasUpdate, "env": setting.Env, "isEnterprise": hs.License.HasValidLicense(), }, diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 1e9bf4e1e09..a1c720ef6fe 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/licensing" @@ -47,8 +48,9 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg) (*macaron.Macaron, *HT hs := &HTTPServer{ Cfg: cfg, Bus: bus.GetBus(), - License: &licensing.OSSLicensingService{}, + License: &licensing.OSSLicensingService{Cfg: cfg}, RenderService: r, + PluginManager: &plugins.PluginManager{Cfg: cfg}, } m := macaron.New() @@ -74,13 +76,17 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonyomus(t *testing.T) { } cfg := setting.NewCfg() + cfg.Env = "testing" + cfg.BuildVersion = "7.8.9" + cfg.BuildCommit = "01234567" m, hs := setupTestEnvironment(t, cfg) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) - setting.BuildVersion = "7.8.9" - setting.BuildCommit = "01234567" - setting.Env = "testing" + // TODO: Remove + setting.BuildVersion = cfg.BuildVersion + setting.BuildCommit = cfg.BuildCommit + setting.Env = cfg.Env tests := []struct { desc string diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 521989a2014..85e3c232b82 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -10,6 +10,7 @@ import ( "os" "path" "path/filepath" + "strings" "sync" "github.com/grafana/grafana/pkg/services/live" @@ -103,8 +104,10 @@ func (hs *HTTPServer) Run(ctx context.Context) error { hs.applyRoutes() + // Remove any square brackets enclosing IPv6 addresses, a format we support for backwards compatibility + host := strings.TrimSuffix(strings.TrimPrefix(setting.HttpAddr, "["), "]") hs.httpSrv = &http.Server{ - Addr: net.JoinHostPort(setting.HttpAddr, setting.HttpPort), + Addr: net.JoinHostPort(host, setting.HttpPort), Handler: hs.macaron, } switch hs.Cfg.Protocol { diff --git a/pkg/api/index.go b/pkg/api/index.go index 57b782017cb..adf939e06c9 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -282,7 +282,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto if len(configNodes) > 0 { navTree = append(navTree, &dtos.NavLink{ - Id: "cfg", + Id: dtos.NavIDCfg, Text: "Configuration", SubTitle: "Organization: " + c.OrgName, Icon: "cog", @@ -408,8 +408,8 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat GoogleTagManagerId: setting.GoogleTagManagerId, BuildVersion: setting.BuildVersion, BuildCommit: setting.BuildCommit, - NewGrafanaVersion: plugins.GrafanaLatestVersion, - NewGrafanaVersionExists: plugins.GrafanaHasUpdate, + NewGrafanaVersion: hs.PluginManager.GrafanaLatestVersion, + NewGrafanaVersionExists: hs.PluginManager.GrafanaHasUpdate, AppName: setting.ApplicationName, AppNameBodyClass: getAppNameBodyClass(hs.License.HasValidLicense()), FavIcon: "public/img/fav32.png", diff --git a/pkg/api/login.go b/pkg/api/login.go index 30c303a530b..2b1d6f67d67 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -273,7 +273,7 @@ func (hs *HTTPServer) loginUserWithUser(user *models.User, c *models.ReqContext) } func (hs *HTTPServer) Logout(c *models.ReqContext) { - if hs.Cfg.SAMLEnabled && hs.Cfg.SAMLSingleLogoutEnabled { + if hs.Cfg.SAMLEnabled && hs.Cfg.SAMLSingleLogoutEnabled && hs.License.HasValidLicense() { c.Redirect(setting.AppSubUrl + "/logout/saml") return } diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index 647a6734cf9..b550f855847 100644 --- a/pkg/api/playlist_play.go +++ b/pkg/api/playlist_play.go @@ -51,11 +51,11 @@ func populateDashboardsByTag(orgID int64, signedInUser *models.SignedInUser, das if err := bus.Dispatch(&searchQuery); err == nil { for _, item := range searchQuery.Result { result = append(result, dtos.PlaylistDashboard{ - Id: item.Id, + Id: item.ID, Slug: item.Slug, Title: item.Title, - Uri: item.Uri, - Url: item.Url, + Uri: item.URI, + Url: item.URL, Order: dashboardTagOrder[tag], }) } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 6e58f1881fc..c7f5b08692b 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -368,7 +368,7 @@ func (hs *HTTPServer) getCachedPluginSettings(pluginID string, user *models.Sign } func (hs *HTTPServer) GetPluginErrorsList(c *models.ReqContext) response.Response { - return response.JSON(200, plugins.ScanningErrors()) + return response.JSON(200, hs.PluginManager.ScanningErrors()) } func translatePluginRequestErrorToAPIError(err error) response.Response { diff --git a/pkg/api/search.go b/pkg/api/search.go index 4f095086766..c98cdfed4d9 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -80,6 +80,7 @@ func (hs *HTTPServer) ListSortOptions(c *models.ReqContext) response.Response { "name": o.Name, "displayName": o.DisplayName, "description": o.Description, + "meta": o.MetaName, }) } diff --git a/pkg/infra/log/file.go b/pkg/infra/log/file.go index 745e737c961..761d96752b2 100644 --- a/pkg/infra/log/file.go +++ b/pkg/infra/log/file.go @@ -57,8 +57,8 @@ func (l *MuxWriter) Write(b []byte) (int, error) { // set os.File in writer. func (l *MuxWriter) setFD(fd *os.File) error { if l.fd != nil { - if err := l.fd.Close(); err != nil { - return err + if err := l.fd.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + return fmt.Errorf("closing old file in MuxWriter failed: %w", err) } } @@ -143,16 +143,18 @@ func (w *FileLogWriter) lineCounter() (int, error) { count := 0 for { c, err := r.Read(buf) - count += bytes.Count(buf[:c], []byte{'\n'}) - switch { - case errors.Is(err, io.EOF): - if err := r.Close(); err != nil { - return count, err + if err != nil { + if errors.Is(err, io.EOF) { + if err := r.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + return 0, fmt.Errorf("closing %q failed: %w", w.Filename, err) + } + return count, nil } - return count, nil - case err != nil: - return count, err + + return 0, err } + + count += bytes.Count(buf[:c], []byte{'\n'}) } } diff --git a/pkg/infra/metrics/metrics.go b/pkg/infra/metrics/metrics.go index edaf70ceef5..ebdbf5adb4d 100644 --- a/pkg/infra/metrics/metrics.go +++ b/pkg/infra/metrics/metrics.go @@ -126,6 +126,9 @@ var ( // MStatTotalDashboards is a metric total amount of dashboards MStatTotalDashboards prometheus.Gauge + // MStatTotalDashboards is a metric total amount of dashboards + MStatTotalFolders prometheus.Gauge + // MStatTotalUsers is a metric total amount of users MStatTotalUsers prometheus.Gauge @@ -412,6 +415,12 @@ func init() { Namespace: ExporterName, }) + MStatTotalFolders = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_totals_folder", + Help: "total amount of folders", + Namespace: ExporterName, + }) + MStatTotalUsers = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "stat_total_users", Help: "total amount of users", @@ -575,6 +584,7 @@ func initMetricVars() { MRenderingQueue, MAlertingActiveAlerts, MStatTotalDashboards, + MStatTotalFolders, MStatTotalUsers, MStatActiveUsers, MStatTotalOrgs, diff --git a/pkg/infra/usagestats/service.go b/pkg/infra/usagestats/service.go index d4b5a574c29..c3f4653345a 100644 --- a/pkg/infra/usagestats/service.go +++ b/pkg/infra/usagestats/service.go @@ -54,18 +54,18 @@ func (uss *UsageStatsService) Init() error { func (uss *UsageStatsService) Run(ctx context.Context) error { uss.updateTotalStats() - onceEveryDayTick := time.NewTicker(time.Hour * 24) - everyMinuteTicker := time.NewTicker(time.Minute) - defer onceEveryDayTick.Stop() - defer everyMinuteTicker.Stop() + sendReportTicker := time.NewTicker(time.Hour * 24) + updateStatsTicker := time.NewTicker(time.Minute * 30) + defer sendReportTicker.Stop() + defer updateStatsTicker.Stop() for { select { - case <-onceEveryDayTick.C: + case <-sendReportTicker.C: if err := uss.sendUsageStats(ctx); err != nil { metricsLogger.Warn("Failed to send usage stats", "err", err) } - case <-everyMinuteTicker.C: + case <-updateStatsTicker.C: uss.updateTotalStats() case <-ctx.Done(): return ctx.Err() diff --git a/pkg/infra/usagestats/usage_stats.go b/pkg/infra/usagestats/usage_stats.go index 2fcf58f9b44..e3e01c1a8f2 100644 --- a/pkg/infra/usagestats/usage_stats.go +++ b/pkg/infra/usagestats/usage_stats.go @@ -103,6 +103,7 @@ func (uss *UsageStatsService) GetUsageReport(ctx context.Context) (UsageReport, metrics["stats.ds.other.count"] = dsOtherCount metrics["stats.packaging."+setting.Packaging+".count"] = 1 + metrics["stats.distributor."+setting.ReportingDistributor+".count"] = 1 // Alerting stats alertingUsageStats, err := uss.AlertingUsageStats.QueryUsageStats() @@ -264,6 +265,7 @@ func (uss *UsageStatsService) updateTotalStats() { } metrics.MStatTotalDashboards.Set(float64(statsQuery.Result.Dashboards)) + metrics.MStatTotalFolders.Set(float64(statsQuery.Result.Folders)) metrics.MStatTotalUsers.Set(float64(statsQuery.Result.Users)) metrics.MStatActiveUsers.Set(float64(statsQuery.Result.ActiveUsers)) metrics.MStatTotalPlaylists.Set(float64(statsQuery.Result.Playlists)) diff --git a/pkg/infra/usagestats/usage_stats_test.go b/pkg/infra/usagestats/usage_stats_test.go index 8a0acefbd55..6d96b6df57a 100644 --- a/pkg/infra/usagestats/usage_stats_test.go +++ b/pkg/infra/usagestats/usage_stats_test.go @@ -212,6 +212,7 @@ func TestMetrics(t *testing.T) { setting.LDAPEnabled = true setting.AuthProxyEnabled = true setting.Packaging = "deb" + setting.ReportingDistributor = "hosted-grafana" wg.Add(1) err := uss.sendUsageStats(context.Background()) @@ -293,6 +294,7 @@ func TestMetrics(t *testing.T) { assert.Equal(t, 1, metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt()) assert.Equal(t, 1, metrics.Get("stats.packaging.deb.count").MustInt()) + assert.Equal(t, 1, metrics.Get("stats.distributor.hosted-grafana.count").MustInt()) assert.Equal(t, 1, metrics.Get("stats.auth_token_per_user_le_3").MustInt()) assert.Equal(t, 2, metrics.Get("stats.auth_token_per_user_le_6").MustInt()) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 1492fc8f660..9da43b7fc17 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -119,15 +119,17 @@ func AdminOrFeatureEnabled(enabled bool) macaron.Handler { } } +// SnapshotPublicModeOrSignedIn creates a middleware that allows access +// if snapshot public mode is enabled or if user is signed in. func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) macaron.Handler { return func(c *models.ReqContext) { if cfg.SnapshotPublicMode { return } - _, err := c.Invoke(ReqSignedIn) - if err != nil { - c.JsonApiErr(500, "Failed to invoke required signed in middleware", err) + if !c.IsSignedIn { + notAuthorized(c) + return } } } diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index c464db681f7..a1dced4e56b 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -87,11 +87,22 @@ func TestMiddlewareAuth(t *testing.T) { middlewareScenario(t, "Snapshot public mode disabled and unauthenticated request should return 401", func( t *testing.T, sc *scenarioContext) { - sc.m.Get("/api/snapshot", SnapshotPublicModeOrSignedIn(sc.cfg), sc.defaultHandler) + sc.m.Get("/api/snapshot", func(c *models.ReqContext) { + c.IsSignedIn = false + }, SnapshotPublicModeOrSignedIn(sc.cfg), sc.defaultHandler) sc.fakeReq("GET", "/api/snapshot").exec() assert.Equal(t, 401, sc.resp.Code) }) + middlewareScenario(t, "Snapshot public mode disabled and authenticated request should return 200", func( + t *testing.T, sc *scenarioContext) { + sc.m.Get("/api/snapshot", func(c *models.ReqContext) { + c.IsSignedIn = true + }, SnapshotPublicModeOrSignedIn(sc.cfg), sc.defaultHandler) + sc.fakeReq("GET", "/api/snapshot").exec() + assert.Equal(t, 200, sc.resp.Code) + }) + middlewareScenario(t, "Snapshot public mode enabled and unauthenticated request should return 200", func( t *testing.T, sc *scenarioContext) { sc.cfg.SnapshotPublicMode = true diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 593e74b736d..e6370a29e75 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -32,11 +32,7 @@ var ( PluginTypes map[string]interface{} Renderer *RendererPlugin - GrafanaLatestVersion string - GrafanaHasUpdate bool - plog log.Logger - - pluginScanningErrors map[string]*PluginError + plog log.Logger ) type unsignedPluginConditionFunc = func(plugin *PluginBase) bool @@ -61,6 +57,9 @@ type PluginManager struct { // 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 + GrafanaLatestVersion string + GrafanaHasUpdate bool + pluginScanningErrors map[string]PluginError } func init() { @@ -82,11 +81,11 @@ func (pm *PluginManager) Init() error { "app": AppPlugin{}, "renderer": RendererPlugin{}, } - pluginScanningErrors = map[string]*PluginError{} + pm.pluginScanningErrors = map[string]PluginError{} pm.log.Info("Starting plugin search") - plugDir := filepath.Join(setting.StaticRootPath, "app/plugins") + plugDir := filepath.Join(pm.Cfg.StaticRootPath, "app/plugins") pm.log.Debug("Scanning core plugin directory", "dir", plugDir) if err := pm.scan(plugDir, false); err != nil { return errutil.Wrapf(err, "failed to scan core plugin directory '%s'", plugDir) @@ -105,21 +104,21 @@ func (pm *PluginManager) Init() error { } // check if plugins dir exists - exists, err = fs.Exists(setting.PluginsPath) + exists, err = fs.Exists(pm.Cfg.PluginsPath) if err != nil { return err } if !exists { - if err = os.MkdirAll(setting.PluginsPath, os.ModePerm); err != nil { - pm.log.Error("failed to create external plugins directory", "dir", setting.PluginsPath, "error", err) + if err = os.MkdirAll(pm.Cfg.PluginsPath, os.ModePerm); err != nil { + pm.log.Error("failed to create external plugins directory", "dir", pm.Cfg.PluginsPath, "error", err) } else { - pm.log.Info("External plugins directory created", "directory", setting.PluginsPath) + pm.log.Info("External plugins directory created", "directory", pm.Cfg.PluginsPath) } } else { - pm.log.Debug("Scanning external plugins directory", "dir", setting.PluginsPath) - if err := pm.scan(setting.PluginsPath, true); err != nil { + pm.log.Debug("Scanning external plugins directory", "dir", pm.Cfg.PluginsPath) + if err := pm.scan(pm.Cfg.PluginsPath, true); err != nil { return errutil.Wrapf(err, "failed to scan external plugins directory '%s'", - setting.PluginsPath) + pm.Cfg.PluginsPath) } } @@ -241,7 +240,7 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error { if signingError != nil { pm.log.Debug("Failed to validate plugin signature. Will skip loading", "id", plugin.Id, "signature", plugin.Signature, "status", signingError.ErrorCode) - pluginScanningErrors[plugin.Id] = signingError + pm.pluginScanningErrors[plugin.Id] = *signingError continue } @@ -255,7 +254,7 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error { jsonFPath := filepath.Join(plugin.PluginDir, "plugin.json") // External plugins need a module.js file for SystemJS to load - if !strings.HasPrefix(jsonFPath, setting.StaticRootPath) && !scanner.IsBackendOnlyPlugin(plugin.Type) { + if !strings.HasPrefix(jsonFPath, pm.Cfg.StaticRootPath) && !scanner.IsBackendOnlyPlugin(plugin.Type) { module := filepath.Join(plugin.PluginDir, "module.js") exists, err := fs.Exists(module) if err != nil { @@ -460,7 +459,7 @@ func (s *PluginScanner) allowUnsigned(plugin *PluginBase) bool { return s.allowUnsignedPluginsCondition(plugin) } - if setting.Env == setting.Dev { + if s.cfg.Env == setting.Dev { return true } @@ -473,9 +472,10 @@ func (s *PluginScanner) allowUnsigned(plugin *PluginBase) bool { return false } -func ScanningErrors() []PluginError { +// ScanningErrors returns plugin scanning errors encountered. +func (pm *PluginManager) ScanningErrors() []PluginError { scanningErrs := make([]PluginError, 0) - for id, e := range pluginScanningErrors { + for id, e := range pm.pluginScanningErrors { scanningErrs = append(scanningErrs, PluginError{ ErrorCode: e.ErrorCode, PluginID: id, @@ -533,7 +533,7 @@ func collectPluginFilesWithin(rootDir string) ([]string, error) { if err != nil { return err } - files = append(files, file) + files = append(files, filepath.ToSlash(file)) } return nil }) diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index a9879e93021..3ea2caa527b 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -17,6 +17,9 @@ import ( ) func TestPluginManager_Init(t *testing.T) { + staticRootPath, err := filepath.Abs("../../public/") + require.NoError(t, err) + origRootPath := setting.StaticRootPath origRaw := setting.Raw origEnv := setting.Env @@ -25,16 +28,16 @@ func TestPluginManager_Init(t *testing.T) { setting.Raw = origRaw setting.Env = origEnv }) - - var err error - setting.StaticRootPath, err = filepath.Abs("../../public/") - require.NoError(t, err) + setting.StaticRootPath = staticRootPath setting.Raw = ini.Empty() setting.Env = setting.Prod t.Run("Base case", func(t *testing.T) { pm := &PluginManager{ Cfg: &setting.Cfg{ + Raw: ini.Empty(), + Env: setting.Prod, + StaticRootPath: staticRootPath, PluginSettings: setting.PluginSettings{ "nginx-app": map[string]string{ "path": "testdata/test-app", @@ -55,14 +58,8 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("With external back-end plugin lacking signature", func(t *testing.T) { - origPluginsPath := setting.PluginsPath - t.Cleanup(func() { - setting.PluginsPath = origPluginsPath - }) - setting.PluginsPath = "testdata/unsigned" - pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{PluginsPath: "testdata/unsigned"}, } err := pm.Init() require.NoError(t, err) @@ -71,14 +68,9 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("With external unsigned back-end plugin and configuration disabling signature check of this plugin", func(t *testing.T) { - origPluginsPath := setting.PluginsPath - t.Cleanup(func() { - setting.PluginsPath = origPluginsPath - }) - setting.PluginsPath = "testdata/unsigned" - pm := &PluginManager{ Cfg: &setting.Cfg{ + PluginsPath: "testdata/unsigned", PluginsAllowUnsigned: []string{"test"}, }, BackendPluginManager: &fakeBackendPluginManager{}, @@ -90,14 +82,10 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("With external back-end plugin with invalid v1 signature", func(t *testing.T) { - origPluginsPath := setting.PluginsPath - t.Cleanup(func() { - setting.PluginsPath = origPluginsPath - }) - setting.PluginsPath = "testdata/invalid-v1-signature" - pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/invalid-v1-signature", + }, } err := pm.Init() require.NoError(t, err) @@ -106,15 +94,11 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("With external back-end plugin lacking files listed in manifest", func(t *testing.T) { - origPluginsPath := setting.PluginsPath - t.Cleanup(func() { - setting.PluginsPath = origPluginsPath - }) - setting.PluginsPath = "testdata/lacking-files" - fm := &fakeBackendPluginManager{} pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/lacking-files", + }, BackendPluginManager: fm, } err := pm.Init() @@ -124,15 +108,11 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("Transform plugins should be ignored when expressions feature is off", func(t *testing.T) { - origPluginsPath := setting.PluginsPath - t.Cleanup(func() { - setting.PluginsPath = origPluginsPath - }) - setting.PluginsPath = "testdata/behind-feature-flag" - fm := fakeBackendPluginManager{} pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/behind-feature-flag", + }, BackendPluginManager: &fm, } err := pm.Init() @@ -143,14 +123,10 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("With nested plugin duplicating parent", func(t *testing.T) { - origPluginsPath := setting.PluginsPath - t.Cleanup(func() { - setting.PluginsPath = origPluginsPath - }) - setting.PluginsPath = "testdata/duplicate-plugins" - pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/duplicate-plugins", + }, } err := pm.Init() require.NoError(t, err) @@ -160,14 +136,10 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("With external back-end plugin with valid v2 signature", func(t *testing.T) { - origPluginsPath := setting.PluginsPath - t.Cleanup(func() { - setting.PluginsPath = origPluginsPath - }) - setting.PluginsPath = "testdata/valid-v2-signature" - pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/valid-v2-signature", + }, BackendPluginManager: &fakeBackendPluginManager{}, } err := pm.Init() @@ -188,16 +160,15 @@ func TestPluginManager_Init(t *testing.T) { t.Run("With back-end plugin with invalid v2 private signature (mismatched root URL)", func(t *testing.T) { origAppURL := setting.AppUrl - origPluginsPath := setting.PluginsPath t.Cleanup(func() { setting.AppUrl = origAppURL - setting.PluginsPath = origPluginsPath }) setting.AppUrl = "http://localhost:1234" - setting.PluginsPath = "testdata/valid-v2-pvt-signature" pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/valid-v2-pvt-signature", + }, } err := pm.Init() require.NoError(t, err) @@ -208,16 +179,15 @@ func TestPluginManager_Init(t *testing.T) { t.Run("With back-end plugin with valid v2 private signature", func(t *testing.T) { origAppURL := setting.AppUrl - origPluginsPath := setting.PluginsPath t.Cleanup(func() { setting.AppUrl = origAppURL - setting.PluginsPath = origPluginsPath }) setting.AppUrl = "http://localhost:3000/" - setting.PluginsPath = "testdata/valid-v2-pvt-signature" pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/valid-v2-pvt-signature", + }, BackendPluginManager: &fakeBackendPluginManager{}, } err := pm.Init() @@ -238,16 +208,15 @@ func TestPluginManager_Init(t *testing.T) { t.Run("With back-end plugin with modified v2 signature (missing file from plugin dir)", func(t *testing.T) { origAppURL := setting.AppUrl - origPluginsPath := setting.PluginsPath t.Cleanup(func() { setting.AppUrl = origAppURL - setting.PluginsPath = origPluginsPath }) setting.AppUrl = "http://localhost:3000/" - setting.PluginsPath = "testdata/invalid-v2-signature" pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/invalid-v2-signature", + }, BackendPluginManager: &fakeBackendPluginManager{}, } err := pm.Init() @@ -258,16 +227,15 @@ func TestPluginManager_Init(t *testing.T) { t.Run("With back-end plugin with modified v2 signature (unaccounted file in plugin dir)", func(t *testing.T) { origAppURL := setting.AppUrl - origPluginsPath := setting.PluginsPath t.Cleanup(func() { setting.AppUrl = origAppURL - setting.PluginsPath = origPluginsPath }) setting.AppUrl = "http://localhost:3000/" - setting.PluginsPath = "testdata/invalid-v2-signature-2" pm := &PluginManager{ - Cfg: &setting.Cfg{}, + Cfg: &setting.Cfg{ + PluginsPath: "testdata/invalid-v2-signature-2", + }, BackendPluginManager: &fakeBackendPluginManager{}, } err := pm.Init() diff --git a/pkg/plugins/update_checker.go b/pkg/plugins/update_checker.go index d1e60e05208..0209975117f 100644 --- a/pkg/plugins/update_checker.go +++ b/pkg/plugins/update_checker.go @@ -112,17 +112,16 @@ func (pm *PluginManager) checkForUpdates() { } if strings.Contains(setting.BuildVersion, "-") { - GrafanaLatestVersion = githubLatest.Testing - GrafanaHasUpdate = !strings.HasPrefix(setting.BuildVersion, githubLatest.Testing) + pm.GrafanaLatestVersion = githubLatest.Testing + pm.GrafanaHasUpdate = !strings.HasPrefix(setting.BuildVersion, githubLatest.Testing) } else { - GrafanaLatestVersion = githubLatest.Stable - GrafanaHasUpdate = githubLatest.Stable != setting.BuildVersion + pm.GrafanaLatestVersion = githubLatest.Stable + pm.GrafanaHasUpdate = githubLatest.Stable != setting.BuildVersion } currVersion, err1 := version.NewVersion(setting.BuildVersion) - latestVersion, err2 := version.NewVersion(GrafanaLatestVersion) - + latestVersion, err2 := version.NewVersion(pm.GrafanaLatestVersion) if err1 == nil && err2 == nil { - GrafanaHasUpdate = currVersion.LessThan(latestVersion) + pm.GrafanaHasUpdate = currVersion.LessThan(latestVersion) } } diff --git a/pkg/services/alerting/notifiers/alertmanager.go b/pkg/services/alerting/notifiers/alertmanager.go index bc0b9ea6f25..fc11b9153af 100644 --- a/pkg/services/alerting/notifiers/alertmanager.go +++ b/pkg/services/alerting/notifiers/alertmanager.go @@ -2,6 +2,7 @@ package notifiers import ( "context" + "fmt" "regexp" "strings" "time" @@ -170,6 +171,7 @@ func (am *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error bodyJSON := simplejson.NewFromAny(alerts) body, _ := bodyJSON.MarshalJSON() + errCnt := 0 for _, url := range am.URL { cmd := &models.SendWebhookSync{ @@ -182,10 +184,15 @@ func (am *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { am.log.Error("Failed to send alertmanager", "error", err, "alertmanager", am.Name, "url", url) - return err + errCnt++ } } + // This happens when every dispatch return error + if errCnt == len(am.URL) { + return fmt.Errorf("failed to send alert to alertmanager") + } + return nil } diff --git a/pkg/services/alerting/notifiers/discord_test.go b/pkg/services/alerting/notifiers/discord_test.go index f31027b2512..fe373c42d1f 100644 --- a/pkg/services/alerting/notifiers/discord_test.go +++ b/pkg/services/alerting/notifiers/discord_test.go @@ -9,7 +9,7 @@ import ( ) func TestDiscordNotifier(t *testing.T) { - Convey("Telegram notifier tests", t, func() { + Convey("Discord notifier tests", t, func() { Convey("Parsing alert notification from settings", func() { Convey("empty settings should return error", func() { json := `{ }` diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/folder_service.go index c2692dd7c1c..df7fb229601 100644 --- a/pkg/services/dashboards/folder_service.go +++ b/pkg/services/dashboards/folder_service.go @@ -46,8 +46,8 @@ func (dr *dashboardServiceImpl) GetFolders(limit int64) ([]*models.Folder, error for _, hit := range searchQuery.Result { folders = append(folders, &models.Folder{ - Id: hit.Id, - Uid: hit.Uid, + Id: hit.ID, + Uid: hit.UID, Title: hit.Title, }) } diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index c7084839b04..6caf5494205 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -114,6 +114,8 @@ func (server *Server) Dial() error { } } for _, host := range strings.Split(server.Config.Host, " ") { + // Remove any square brackets enclosing IPv6 addresses, a format we support for backwards compatibility + host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") address := net.JoinHostPort(host, strconv.Itoa(server.Config.Port)) if server.Config.UseSSL { tlsCfg := &tls.Config{ diff --git a/pkg/services/librarypanels/database.go b/pkg/services/librarypanels/database.go index 9a94abc32a3..6d3256e98de 100644 --- a/pkg/services/librarypanels/database.go +++ b/pkg/services/librarypanels/database.go @@ -1,7 +1,7 @@ package librarypanels import ( - "context" + "encoding/json" "fmt" "time" @@ -27,6 +27,23 @@ FROM library_panel AS lp ` ) +func syncTitleWithName(libraryPanel *LibraryPanel) error { + var model map[string]interface{} + if err := json.Unmarshal(libraryPanel.Model, &model); err != nil { + return err + } + + model["title"] = libraryPanel.Name + syncedModel, err := json.Marshal(&model) + if err != nil { + return err + } + + libraryPanel.Model = syncedModel + + return nil +} + // createLibraryPanel adds a Library Panel. func (lps *LibraryPanelService) createLibraryPanel(c *models.ReqContext, cmd createLibraryPanelCommand) (LibraryPanelDTO, error) { libraryPanel := LibraryPanel{ @@ -42,7 +59,12 @@ func (lps *LibraryPanelService) createLibraryPanel(c *models.ReqContext, cmd cre CreatedBy: c.SignedInUser.UserId, UpdatedBy: c.SignedInUser.UserId, } - err := lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + + if err := syncTitleWithName(&libraryPanel); err != nil { + return LibraryPanelDTO{}, err + } + + err := lps.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { if _, err := session.Insert(&libraryPanel); err != nil { if lps.SQLStore.Dialect.IsUniqueConstraintViolation(err) { return errLibraryPanelAlreadyExists @@ -105,7 +127,7 @@ func connectDashboard(session *sqlstore.DBSession, dialect migrator.Dialect, use // connectDashboard adds a connection between a Library Panel and a Dashboard. func (lps *LibraryPanelService) connectDashboard(c *models.ReqContext, uid string, dashboardID int64) error { - err := lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + err := lps.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { return connectDashboard(session, lps.SQLStore.Dialect, c.SignedInUser, uid, dashboardID) }) @@ -114,7 +136,11 @@ func (lps *LibraryPanelService) connectDashboard(c *models.ReqContext, uid strin // connectLibraryPanelsForDashboard adds connections for all Library Panels in a Dashboard. func (lps *LibraryPanelService) connectLibraryPanelsForDashboard(c *models.ReqContext, uids []string, dashboardID int64) error { - err := lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + err := lps.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { + _, err := session.Exec("DELETE FROM library_panel_dashboard WHERE dashboard_id=?", dashboardID) + if err != nil { + return err + } for _, uid := range uids { err := connectDashboard(session, lps.SQLStore.Dialect, c.SignedInUser, uid, dashboardID) if err != nil { @@ -129,7 +155,7 @@ func (lps *LibraryPanelService) connectLibraryPanelsForDashboard(c *models.ReqCo // deleteLibraryPanel deletes a Library Panel. func (lps *LibraryPanelService) deleteLibraryPanel(c *models.ReqContext, uid string) error { - return lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + return lps.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { panel, err := getLibraryPanel(session, uid, c.SignedInUser.OrgId) if err != nil { return err @@ -155,7 +181,7 @@ func (lps *LibraryPanelService) deleteLibraryPanel(c *models.ReqContext, uid str // disconnectDashboard deletes a connection between a Library Panel and a Dashboard. func (lps *LibraryPanelService) disconnectDashboard(c *models.ReqContext, uid string, dashboardID int64) error { - return lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + return lps.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { panel, err := getLibraryPanel(session, uid, c.SignedInUser.OrgId) if err != nil { return err @@ -177,8 +203,8 @@ func (lps *LibraryPanelService) disconnectDashboard(c *models.ReqContext, uid st } // disconnectLibraryPanelsForDashboard deletes connections for all Library Panels in a Dashboard. -func (lps *LibraryPanelService) disconnectLibraryPanelsForDashboard(dashboardID int64, panelCount int64) error { - return lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { +func (lps *LibraryPanelService) disconnectLibraryPanelsForDashboard(c *models.ReqContext, dashboardID int64, panelCount int64) error { + return lps.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { result, err := session.Exec("DELETE FROM library_panel_dashboard WHERE dashboard_id=?", dashboardID) if err != nil { return err @@ -214,7 +240,7 @@ func getLibraryPanel(session *sqlstore.DBSession, uid string, orgID int64) (Libr // getLibraryPanel gets a Library Panel. func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string) (LibraryPanelDTO, error) { var libraryPanel LibraryPanelWithMeta - err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error { + err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { var err error libraryPanel, err = getLibraryPanel(session, uid, c.SignedInUser.OrgId) return err @@ -252,7 +278,7 @@ func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext) ([]LibraryPanelDTO, error) { orgID := c.SignedInUser.OrgId libraryPanels := make([]LibraryPanelWithMeta, 0) - err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error { + err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { sql := sqlStatmentLibrayPanelDTOWithMeta + "WHERE lp.org_id=?" sess := session.SQL(sql, orgID) err := sess.Find(&libraryPanels) @@ -297,7 +323,7 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext) ([]Lib // getConnectedDashboards gets all dashboards connected to a Library Panel. func (lps *LibraryPanelService) getConnectedDashboards(c *models.ReqContext, uid string) ([]int64, error) { connectedDashboardIDs := make([]int64, 0) - err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error { + err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { panel, err := getLibraryPanel(session, uid, c.SignedInUser.OrgId) if err != nil { return err @@ -321,9 +347,9 @@ func (lps *LibraryPanelService) getConnectedDashboards(c *models.ReqContext, uid return connectedDashboardIDs, err } -func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(dashboardID int64) (map[string]LibraryPanelDTO, error) { +func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(c *models.ReqContext, dashboardID int64) (map[string]LibraryPanelDTO, error) { libraryPanelMap := make(map[string]LibraryPanelDTO) - err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error { + err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { var libraryPanels []LibraryPanelWithMeta sql := sqlStatmentLibrayPanelDTOWithMeta + "INNER JOIN library_panel_dashboard AS lpd ON lpd.librarypanel_id = lp.id AND lpd.dashboard_id=?" sess := session.SQL(sql, dashboardID) @@ -368,7 +394,7 @@ func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(dashboardID int64 // patchLibraryPanel updates a Library Panel. func (lps *LibraryPanelService) patchLibraryPanel(c *models.ReqContext, cmd patchLibraryPanelCommand, uid string) (LibraryPanelDTO, error) { var dto LibraryPanelDTO - err := lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + err := lps.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { panelInDB, err := getLibraryPanel(session, uid, c.SignedInUser.OrgId) if err != nil { return err @@ -396,6 +422,9 @@ func (lps *LibraryPanelService) patchLibraryPanel(c *models.ReqContext, cmd patc if cmd.Model == nil { libraryPanel.Model = panelInDB.Model } + if err := syncTitleWithName(&libraryPanel); err != nil { + return err + } if rowsAffected, err := session.ID(panelInDB.ID).Update(&libraryPanel); err != nil { if lps.SQLStore.Dialect.IsUniqueConstraintViolation(err) { diff --git a/pkg/services/librarypanels/librarypanels.go b/pkg/services/librarypanels/librarypanels.go index 02b89206345..62bcf6e7655 100644 --- a/pkg/services/librarypanels/librarypanels.go +++ b/pkg/services/librarypanels/librarypanels.go @@ -45,12 +45,12 @@ func (lps *LibraryPanelService) IsEnabled() bool { // LoadLibraryPanelsForDashboard loops through all panels in dashboard JSON and replaces any library panel JSON // with JSON stored for library panel in db. -func (lps *LibraryPanelService) LoadLibraryPanelsForDashboard(dash *models.Dashboard) error { +func (lps *LibraryPanelService) LoadLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard) error { if !lps.IsEnabled() { return nil } - libraryPanels, err := lps.getLibraryPanelsForDashboardID(dash.Id) + libraryPanels, err := lps.getLibraryPanelsForDashboardID(c, dash.Id) if err != nil { return err } @@ -194,7 +194,7 @@ func (lps *LibraryPanelService) ConnectLibraryPanelsForDashboard(c *models.ReqCo } // DisconnectLibraryPanelsForDashboard loops through all panels in dashboard JSON and disconnects any library panels from the dashboard. -func (lps *LibraryPanelService) DisconnectLibraryPanelsForDashboard(dash *models.Dashboard) error { +func (lps *LibraryPanelService) DisconnectLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard) error { if !lps.IsEnabled() { return nil } @@ -216,7 +216,7 @@ func (lps *LibraryPanelService) DisconnectLibraryPanelsForDashboard(dash *models panelCount++ } - return lps.disconnectLibraryPanelsForDashboard(dash.Id, panelCount) + return lps.disconnectLibraryPanelsForDashboard(c, dash.Id, panelCount) } // AddMigration defines database migrations. diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index e49f9526b7b..82bfbc77c28 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "testing" "time" @@ -73,6 +74,51 @@ func TestCreateLibraryPanel(t *testing.T) { t.Fatalf("Result mismatch (-want +got):\n%s", diff) } }) + + testScenario(t, "When an admin tries to create a library panel where name and panel title differ, it should update panel title", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Library Panel Name") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var result libraryPanelResult + err := json.Unmarshal(response.Body(), &result) + require.NoError(t, err) + var expected = libraryPanelResult{ + Result: libraryPanel{ + ID: 1, + OrgID: 1, + FolderID: 1, + UID: result.Result.UID, + Name: "Library Panel Name", + Model: map[string]interface{}{ + "datasource": "${DS_GDEV-TESTDATA}", + "id": float64(1), + "title": "Library Panel Name", + "type": "text", + }, + Meta: LibraryPanelDTOMeta{ + CanEdit: true, + ConnectedDashboards: 0, + Created: result.Result.Meta.Created, + Updated: result.Result.Meta.Updated, + CreatedBy: LibraryPanelDTOMetaUser{ + ID: 1, + Name: "signed_in_user", + AvatarUrl: "/avatar/37524e1eb8b3e32850b57db0a19af93b", + }, + UpdatedBy: LibraryPanelDTOMetaUser{ + ID: 1, + Name: "signed_in_user", + AvatarUrl: "/avatar/37524e1eb8b3e32850b57db0a19af93b", + }, + }, + }, + } + if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) } func TestConnectLibraryPanel(t *testing.T) { @@ -354,7 +400,7 @@ func TestGetAllLibraryPanels(t *testing.T) { Model: map[string]interface{}{ "datasource": "${DS_GDEV-TESTDATA}", "id": float64(1), - "title": "Text - Library Panel", + "title": "Text - Library Panel2", "type": "text", }, Meta: LibraryPanelDTOMeta{ @@ -555,7 +601,7 @@ func TestPatchLibraryPanel(t *testing.T) { Model: map[string]interface{}{ "datasource": "${DS_GDEV-TESTDATA}", "id": float64(1), - "title": "Model - New name", + "title": "Panel - New name", "type": "text", }, Meta: LibraryPanelDTOMeta{ @@ -609,7 +655,7 @@ func TestPatchLibraryPanel(t *testing.T) { } }) - testScenario(t, "When an admin tries to patch a library panel with name only, it should change name successfully and return correct result", + testScenario(t, "When an admin tries to patch a library panel with name only, it should change name successfully, sync title and return correct result", func(t *testing.T, sc scenarioContext) { command := getCreateCommand(1, "Text - Library Panel") response := sc.service.createHandler(sc.reqContext, command) @@ -625,12 +671,15 @@ func TestPatchLibraryPanel(t *testing.T) { sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID}) response = sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, 200, response.Status()) + var result libraryPanelResult err = json.Unmarshal(response.Body(), &result) require.NoError(t, err) + existing.Result.Name = "New Name" existing.Result.Meta.CreatedBy.Name = "user_in_db" existing.Result.Meta.CreatedBy.AvatarUrl = "/avatar/402d08de060496d6b6874495fe20f5ad" + existing.Result.Model["title"] = "New Name" if diff := cmp.Diff(existing.Result, result.Result, getCompareOptions()...); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) } @@ -647,7 +696,7 @@ func TestPatchLibraryPanel(t *testing.T) { require.NoError(t, err) cmd := patchLibraryPanelCommand{ - Model: []byte(`{ "name": "New Model Name" }`), + Model: []byte(`{ "title": "New Model Title", "name": "New Model Name" }`), } sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID}) response = sc.service.patchHandler(sc.reqContext, cmd) @@ -656,7 +705,8 @@ func TestPatchLibraryPanel(t *testing.T) { err = json.Unmarshal(response.Body(), &result) require.NoError(t, err) existing.Result.Model = map[string]interface{}{ - "name": "New Model Name", + "title": "Text - Library Panel", + "name": "New Model Name", } existing.Result.Meta.CreatedBy.Name = "user_in_db" existing.Result.Meta.CreatedBy.AvatarUrl = "/avatar/402d08de060496d6b6874495fe20f5ad" @@ -801,7 +851,7 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } - err = sc.service.LoadLibraryPanelsForDashboard(&dash) + err = sc.service.LoadLibraryPanelsForDashboard(sc.reqContext, &dash) require.NoError(t, err) expectedJSON := map[string]interface{}{ "panels": []interface{}{ @@ -843,7 +893,7 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) { }, }, }, - "title": "Text - Library Panel", + "title": "Text - Library Panel1", "type": "text", }, }, @@ -898,7 +948,7 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } - err = sc.service.LoadLibraryPanelsForDashboard(&dash) + err = sc.service.LoadLibraryPanelsForDashboard(sc.reqContext, &dash) require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) }) @@ -943,7 +993,7 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } - err = sc.service.LoadLibraryPanelsForDashboard(&dash) + err = sc.service.LoadLibraryPanelsForDashboard(sc.reqContext, &dash) require.NoError(t, err) expectedJSON := map[string]interface{}{ "panels": []interface{}{ @@ -1258,6 +1308,85 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { err = sc.service.ConnectLibraryPanelsForDashboard(sc.reqContext, &dash) require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) }) + + testScenario(t, "When an admin tries to store a dashboard with unusused/removed library panels, it should disconnect unusused/removed library panels", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Unused Libray Panel") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var unused libraryPanelResult + err := json.Unmarshal(response.Body(), &unused) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": unused.Result.UID, ":dashboardId": "1"}) + response = sc.service.connectHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + command = getCreateCommand(1, "Text - Library Panel1") + response = sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err = json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: int64(1), + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.ConnectLibraryPanelsForDashboard(sc.reqContext, &dash) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID}) + response = sc.service.getConnectedDashboardsHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + var existingResult libraryPanelDashboardsResult + err = json.Unmarshal(response.Body(), &existingResult) + require.NoError(t, err) + require.Len(t, existingResult.Result, 1) + require.Equal(t, int64(1), existingResult.Result[0]) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": unused.Result.UID}) + response = sc.service.getConnectedDashboardsHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + var unusedResult libraryPanelDashboardsResult + err = json.Unmarshal(response.Body(), &unusedResult) + require.NoError(t, err) + require.Len(t, unusedResult.Result, 0) + }) } func TestDisconnectLibraryPanelsForDashboard(t *testing.T) { @@ -1309,7 +1438,7 @@ func TestDisconnectLibraryPanelsForDashboard(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } - err = sc.service.DisconnectLibraryPanelsForDashboard(&dash) + err = sc.service.DisconnectLibraryPanelsForDashboard(sc.reqContext, &dash) require.NoError(t, err) sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID}) @@ -1369,7 +1498,7 @@ func TestDisconnectLibraryPanelsForDashboard(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } - err = sc.service.DisconnectLibraryPanelsForDashboard(&dash) + err = sc.service.DisconnectLibraryPanelsForDashboard(sc.reqContext, &dash) require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) }) } @@ -1449,7 +1578,9 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo t.Run(desc, func(t *testing.T) { t.Cleanup(registry.ClearOverrides) - ctx := macaron.Context{} + ctx := macaron.Context{ + Req: macaron.Request{Request: &http.Request{}}, + } orgID := int64(1) role := models.ROLE_ADMIN diff --git a/pkg/services/search/hits.go b/pkg/services/search/hits.go index 8da535d7005..fbc9a473f8d 100644 --- a/pkg/services/search/hits.go +++ b/pkg/services/search/hits.go @@ -11,19 +11,21 @@ const ( ) type Hit struct { - Id int64 `json:"id"` - Uid string `json:"uid"` - Title string `json:"title"` - Uri string `json:"uri"` - Url string `json:"url"` - Slug string `json:"slug"` - Type HitType `json:"type"` - Tags []string `json:"tags"` - IsStarred bool `json:"isStarred"` - FolderId int64 `json:"folderId,omitempty"` - FolderUid string `json:"folderUid,omitempty"` - FolderTitle string `json:"folderTitle,omitempty"` - FolderUrl string `json:"folderUrl,omitempty"` + ID int64 `json:"id"` + UID string `json:"uid"` + Title string `json:"title"` + URI string `json:"uri"` + URL string `json:"url"` + Slug string `json:"slug"` + Type HitType `json:"type"` + Tags []string `json:"tags"` + IsStarred bool `json:"isStarred"` + FolderID int64 `json:"folderId,omitempty"` + FolderUID string `json:"folderUid,omitempty"` + FolderTitle string `json:"folderTitle,omitempty"` + FolderURL string `json:"folderUrl,omitempty"` + SortMeta int64 `json:"sortMeta"` + SortMetaName string `json:"sortMetaName,omitempty"` } type HitList []*Hit diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go index 7bfae6cf02f..ab34b89116b 100644 --- a/pkg/services/search/service.go +++ b/pkg/services/search/service.go @@ -43,6 +43,7 @@ type FindPersistedDashboardsQuery struct { Limit int64 Page int64 Permission models.PermissionType + Sort SortOption Filters []interface{} @@ -81,9 +82,7 @@ func (s *SearchService) searchHandler(query *Query) error { } if sortOpt, exists := s.sortOptions[query.Sort]; exists { - for _, filter := range sortOpt.Filter { - dashboardQuery.Filters = append(dashboardQuery.Filters, filter) - } + dashboardQuery.Sort = sortOpt } if err := bus.Dispatch(&dashboardQuery); err != nil { @@ -127,7 +126,7 @@ func setStarredDashboards(userID int64, hits []*Hit) error { } for _, dashboard := range hits { - if _, ok := query.Result[dashboard.Id]; ok { + if _, ok := query.Result[dashboard.ID]; ok { dashboard.IsStarred = true } } diff --git a/pkg/services/search/service_test.go b/pkg/services/search/service_test.go index 120b4530b83..aa3dc844982 100644 --- a/pkg/services/search/service_test.go +++ b/pkg/services/search/service_test.go @@ -12,11 +12,11 @@ import ( func TestSearch_SortedResults(t *testing.T) { bus.AddHandler("test", func(query *FindPersistedDashboardsQuery) error { query.Result = HitList{ - &Hit{Id: 16, Title: "CCAA", Type: "dash-db", Tags: []string{"BB", "AA"}}, - &Hit{Id: 10, Title: "AABB", Type: "dash-db", Tags: []string{"CC", "AA"}}, - &Hit{Id: 15, Title: "BBAA", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}}, - &Hit{Id: 25, Title: "bbAAa", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}}, - &Hit{Id: 17, Title: "FOLDER", Type: "dash-folder"}, + &Hit{ID: 16, Title: "CCAA", Type: "dash-db", Tags: []string{"BB", "AA"}}, + &Hit{ID: 10, Title: "AABB", Type: "dash-db", Tags: []string{"CC", "AA"}}, + &Hit{ID: 15, Title: "BBAA", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}}, + &Hit{ID: 25, Title: "bbAAa", Type: "dash-db", Tags: []string{"EE", "AA", "BB"}}, + &Hit{ID: 17, Title: "FOLDER", Type: "dash-folder"}, } return nil }) diff --git a/pkg/services/search/sorting.go b/pkg/services/search/sorting.go index e67d98af02d..dfa2e6fe2f9 100644 --- a/pkg/services/search/sorting.go +++ b/pkg/services/search/sorting.go @@ -9,16 +9,18 @@ import ( var ( sortAlphaAsc = SortOption{ Name: "alpha-asc", - DisplayName: "Alphabetically (A-Z)", + DisplayName: "Alphabetically (A–Z)", Description: "Sort results in an alphabetically ascending order", + Index: 0, Filter: []SortOptionFilter{ searchstore.TitleSorter{}, }, } sortAlphaDesc = SortOption{ Name: "alpha-desc", - DisplayName: "Alphabetically (Z-A)", + DisplayName: "Alphabetically (Z–A)", Description: "Sort results in an alphabetically descending order", + Index: 0, Filter: []SortOptionFilter{ searchstore.TitleSorter{Descending: true}, }, @@ -29,6 +31,8 @@ type SortOption struct { Name string DisplayName string Description string + Index int + MetaName string Filter []SortOptionFilter } @@ -48,7 +52,7 @@ func (s *SearchService) SortOptions() []SortOption { opts = append(opts, o) } sort.Slice(opts, func(i, j int) bool { - return opts[i].Name < opts[j].Name + return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name) }) return opts } diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 15216a35db0..81d458f0ba2 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -203,16 +203,17 @@ func GetDashboard(query *models.GetDashboardQuery) error { } type DashboardSearchProjection struct { - Id int64 - Uid string + ID int64 `xorm:"id"` + UID string `xorm:"uid"` Title string Slug string Term string IsFolder bool - FolderId int64 - FolderUid string + FolderID int64 `xorm:"folder_id"` + FolderUID string `xorm:"folder_uid"` FolderSlug string FolderTitle string + SortMeta int64 } func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { @@ -226,7 +227,9 @@ func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSear }, } - filters = append(filters, query.Filters...) + for _, filter := range query.Sort.Filter { + filters = append(filters, filter) + } if query.OrgId != 0 { filters = append(filters, searchstore.OrgFilter{OrgId: query.OrgId}) @@ -307,27 +310,32 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard hits := make(map[int64]*search.Hit) for _, item := range res { - hit, exists := hits[item.Id] + hit, exists := hits[item.ID] if !exists { hit = &search.Hit{ - Id: item.Id, - Uid: item.Uid, + ID: item.ID, + UID: item.UID, Title: item.Title, - Uri: "db/" + item.Slug, - Url: models.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug), + URI: "db/" + item.Slug, + URL: models.GetDashboardFolderUrl(item.IsFolder, item.UID, item.Slug), Type: getHitType(item), - FolderId: item.FolderId, - FolderUid: item.FolderUid, + FolderID: item.FolderID, + FolderUID: item.FolderUID, FolderTitle: item.FolderTitle, Tags: []string{}, } - if item.FolderId > 0 { - hit.FolderUrl = models.GetFolderUrl(item.FolderUid, item.FolderSlug) + if item.FolderID > 0 { + hit.FolderURL = models.GetFolderUrl(item.FolderUID, item.FolderSlug) + } + + if query.Sort.MetaName != "" { + hit.SortMeta = item.SortMeta + hit.SortMetaName = query.Sort.MetaName } query.Result = append(query.Result, hit) - hits[item.Id] = hit + hits[item.ID] = hit } if len(item.Term) > 0 { hit.Tags = append(hit.Tags, item.Term) diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/sqlstore/dashboard_folder_test.go index e19e37ec968..572014b3829 100644 --- a/pkg/services/sqlstore/dashboard_folder_test.go +++ b/pkg/services/sqlstore/dashboard_folder_test.go @@ -33,8 +33,8 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, folder.Id) + So(query.Result[1].ID, ShouldEqual, dashInRoot.Id) }) }) @@ -57,7 +57,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, dashInRoot.Id) }) Convey("when the user is given permission", func() { @@ -75,8 +75,8 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, folder.Id) + So(query.Result[1].ID, ShouldEqual, dashInRoot.Id) }) }) @@ -94,8 +94,8 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, folder.Id) + So(query.Result[1].ID, ShouldEqual, dashInRoot.Id) }) }) }) @@ -116,7 +116,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, dashInRoot.Id) }) Convey("when the user is given permission to child", func() { @@ -128,8 +128,8 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, childDash.Id) - So(query.Result[1].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, childDash.Id) + So(query.Result[1].ID, ShouldEqual, dashInRoot.Id) }) }) @@ -147,9 +147,9 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 3) - So(query.Result[0].Id, ShouldEqual, folder.Id) - So(query.Result[1].Id, ShouldEqual, childDash.Id) - So(query.Result[2].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, folder.Id) + So(query.Result[1].ID, ShouldEqual, childDash.Id) + So(query.Result[2].ID, ShouldEqual, dashInRoot.Id) }) }) }) @@ -171,10 +171,10 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 4) - So(query.Result[0].Id, ShouldEqual, folder1.Id) - So(query.Result[1].Id, ShouldEqual, folder2.Id) - So(query.Result[2].Id, ShouldEqual, childDash1.Id) - So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, folder1.Id) + So(query.Result[1].ID, ShouldEqual, folder2.Id) + So(query.Result[2].ID, ShouldEqual, childDash1.Id) + So(query.Result[3].ID, ShouldEqual, dashInRoot.Id) }) }) @@ -197,7 +197,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, dashInRoot.Id) }) }) Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() { @@ -212,10 +212,10 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 4) - So(query.Result[0].Id, ShouldEqual, folder2.Id) - So(query.Result[1].Id, ShouldEqual, childDash1.Id) - So(query.Result[2].Id, ShouldEqual, childDash2.Id) - So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, folder2.Id) + So(query.Result[1].ID, ShouldEqual, childDash1.Id) + So(query.Result[2].ID, ShouldEqual, childDash2.Id) + So(query.Result[3].ID, ShouldEqual, dashInRoot.Id) }) }) @@ -236,10 +236,10 @@ func TestDashboardFolderDataAccess(t *testing.T) { err := SearchDashboards(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 4) - So(query.Result[0].Id, ShouldEqual, folder2.Id) - So(query.Result[1].Id, ShouldEqual, childDash1.Id) - So(query.Result[2].Id, ShouldEqual, childDash2.Id) - So(query.Result[3].Id, ShouldEqual, dashInRoot.Id) + So(query.Result[0].ID, ShouldEqual, folder2.Id) + So(query.Result[1].ID, ShouldEqual, childDash1.Id) + So(query.Result[2].ID, ShouldEqual, childDash2.Id) + So(query.Result[3].ID, ShouldEqual, dashInRoot.Id) }) }) }) @@ -267,8 +267,8 @@ func TestDashboardFolderDataAccess(t *testing.T) { So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder1.Id) - So(query.Result[1].Id, ShouldEqual, folder2.Id) + So(query.Result[0].ID, ShouldEqual, folder1.Id) + So(query.Result[1].ID, ShouldEqual, folder2.Id) }) Convey("should have write access to all folders and dashboards", func() { @@ -320,8 +320,8 @@ func TestDashboardFolderDataAccess(t *testing.T) { So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 2) - So(query.Result[0].Id, ShouldEqual, folder1.Id) - So(query.Result[1].Id, ShouldEqual, folder2.Id) + So(query.Result[0].ID, ShouldEqual, folder1.Id) + So(query.Result[1].ID, ShouldEqual, folder2.Id) }) Convey("should have edit access to folders with default ACL", func() { @@ -352,7 +352,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, folder2.Id) + So(query.Result[0].ID, ShouldEqual, folder2.Id) }) Convey("should have edit permission in folders", func() { @@ -416,7 +416,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Id, ShouldEqual, folder1.Id) + So(query.Result[0].ID, ShouldEqual, folder1.Id) }) Convey("should not have edit permission in folders", func() { diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index 70d007ea5fe..8076b098075 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -106,7 +106,10 @@ func GetDashboardSnapshot(query *models.GetDashboardSnapshotQuery) error { func SearchDashboardSnapshots(query *models.GetDashboardSnapshotsQuery) error { var snapshots = make(models.DashboardSnapshotsList, 0) - sess := x.Limit(query.Limit) + sess := x.NewSession() + if query.Limit > 0 { + sess.Limit(query.Limit) + } sess.Table("dashboard_snapshot") if query.Name != "" { diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index bfdf903bead..21cf0ab4fe0 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -277,7 +277,7 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 1) hit := query.Result[0] So(hit.Type, ShouldEqual, search.DashHitFolder) - So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) + So(hit.URL, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) So(hit.FolderTitle, ShouldEqual, "") }) @@ -337,12 +337,12 @@ func TestDashboardDataAccess(t *testing.T) { So(len(query.Result), ShouldEqual, 2) hit := query.Result[0] - So(hit.Id, ShouldEqual, savedDash.Id) - So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug)) - So(hit.FolderId, ShouldEqual, savedFolder.Id) - So(hit.FolderUid, ShouldEqual, savedFolder.Uid) + So(hit.ID, ShouldEqual, savedDash.Id) + So(hit.URL, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug)) + So(hit.FolderID, ShouldEqual, savedFolder.Id) + So(hit.FolderUID, ShouldEqual, savedFolder.Uid) So(hit.FolderTitle, ShouldEqual, savedFolder.Title) - So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) + So(hit.FolderURL, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug)) }) Convey("Should be able to search for dashboard by dashboard ids", func() { @@ -436,8 +436,8 @@ func TestDashboard_SortingOptions(t *testing.T) { require.NoError(t, err) require.Len(t, dashboards, 2) - assert.Equal(t, dashA.Id, dashboards[0].Id) - assert.Equal(t, dashB.Id, dashboards[1].Id) + assert.Equal(t, dashA.Id, dashboards[0].ID) + assert.Equal(t, dashB.Id, dashboards[1].ID) }) } diff --git a/pkg/services/sqlstore/database_wrapper.go b/pkg/services/sqlstore/database_wrapper.go index 172654a55d1..331c833e246 100644 --- a/pkg/services/sqlstore/database_wrapper.go +++ b/pkg/services/sqlstore/database_wrapper.go @@ -27,7 +27,7 @@ func init() { Namespace: "grafana", Name: "database_queries_duration_seconds", Help: "Database query histogram", - Buckets: prometheus.ExponentialBuckets(0.0001, 4, 9), + Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10), }, []string{"status"}) prometheus.MustRegister(databaseQueryHistogram) diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index fba8c2f92b2..d190068590e 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -39,7 +39,10 @@ func SearchOrgs(query *models.SearchOrgsQuery) error { sess.In("id", query.Ids) } - sess.Limit(query.Limit, query.Limit*query.Page) + if query.Limit > 0 { + sess.Limit(query.Limit, query.Limit*query.Page) + } + sess.Cols("id", "name") err := sess.Find(&query.Result) return err diff --git a/pkg/services/sqlstore/searchstore/builder.go b/pkg/services/sqlstore/searchstore/builder.go index 9a24c932baf..6f084b62ba7 100644 --- a/pkg/services/sqlstore/searchstore/builder.go +++ b/pkg/services/sqlstore/searchstore/builder.go @@ -55,8 +55,15 @@ func (b *Builder) buildSelect() { dashboard.folder_id, folder.uid AS folder_uid, folder.slug AS folder_slug, - folder.title AS folder_title - FROM `) + folder.title AS folder_title `) + + for _, f := range b.Filters { + if f, ok := f.(FilterSelect); ok { + b.sql.WriteString(fmt.Sprintf(", %s", f.Select())) + } + } + + b.sql.WriteString(` FROM `) } func (b *Builder) applyFilters() (ordering string) { diff --git a/pkg/services/sqlstore/searchstore/filters.go b/pkg/services/sqlstore/searchstore/filters.go index ca7cebf92ee..fc20ee494bc 100644 --- a/pkg/services/sqlstore/searchstore/filters.go +++ b/pkg/services/sqlstore/searchstore/filters.go @@ -33,6 +33,10 @@ type FilterLeftJoin interface { LeftJoin() string } +type FilterSelect interface { + Select() string +} + const ( TypeFolder = "dash-folder" TypeDashboard = "dash-db" diff --git a/pkg/services/sqlstore/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go index 49eccb1fbae..9a799e40891 100644 --- a/pkg/services/sqlstore/searchstore/search_test.go +++ b/pkg/services/sqlstore/searchstore/search_test.go @@ -58,10 +58,10 @@ func TestBuilder_EqualResults_Basic(t *testing.T) { require.NoError(t, err) assert.Len(t, res, 1) - res[0].Uid = "" + res[0].UID = "" assert.EqualValues(t, []sqlstore.DashboardSearchProjection{ { - Id: 1, + ID: 1, Title: "A", Slug: "a", Term: "templated", diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 85637bdbe07..6331be78a8d 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -117,8 +117,10 @@ func (ss *SQLStore) Init() error { return err } // Make sure the changes are synced, so they get shared with eventual other DB connections - if err := ss.Sync(); err != nil { - return err + if !ss.dbCfg.SkipMigrations { + if err := ss.Sync(); err != nil { + return err + } } return nil diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index b8e296f917d..8a19a54311f 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -46,7 +46,6 @@ func GetSystemStats(query *models.GetSystemStatsQuery) error { sb.Write("SELECT ") sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + `) AS users,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("dashboard") + `) AS dashboards,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`) sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`) @@ -55,6 +54,7 @@ func GetSystemStats(query *models.GetSystemStatsQuery) error { activeUserDeadlineDate := time.Now().Add(-activeUserTimeLimit) sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS active_users,`, activeUserDeadlineDate) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false)) sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true)) sb.Write(`( @@ -119,7 +119,7 @@ func GetAdminStats(query *models.GetAdminStatsQuery) error { ) AS orgs, ( SELECT COUNT(*) - FROM ` + dialect.Quote("dashboard") + ` + FROM ` + dialect.Quote("dashboard") + `WHERE is_folder=` + dialect.BooleanStr(false) + ` ) AS dashboards, ( SELECT COUNT(*) diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 3b8d3f96611..22bf350ef52 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -614,8 +614,11 @@ func SearchUsers(query *models.SearchUsersQuery) error { sess.Where(strings.Join(whereConditions, " AND "), whereParams...) } - offset := query.Limit * (query.Page - 1) - sess.Limit(query.Limit, offset) + if query.Limit > 0 { + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) + } + sess.Cols("u.id", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module") sess.Asc("u.login", "u.email") if err := sess.Find(&query.Result.Users); err != nil { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 59f9e84dc25..bd8c2c211cf 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -67,7 +67,6 @@ var ( // Paths HomePath string - PluginsPath string CustomInitPath = "conf/custom.ini" // HTTP server options @@ -141,10 +140,12 @@ var ( appliedCommandLineProperties []string appliedEnvOverrides []string - ReportingEnabled bool - CheckForUpdates bool - GoogleAnalyticsId string - GoogleTagManagerId string + // analytics + ReportingEnabled bool + ReportingDistributor string + CheckForUpdates bool + GoogleAnalyticsId string + GoogleTagManagerId string // LDAP LDAPEnabled bool @@ -213,6 +214,7 @@ type Cfg struct { ProvisioningPath string DataPath string LogsPath string + PluginsPath string BundledPluginsPath string // SMTP email settings @@ -763,7 +765,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.Env = Env InstanceName = valueAsString(iniFile.Section(""), "instance_name", "unknown_instance_name") plugins := valueAsString(iniFile.Section("paths"), "plugins", "") - PluginsPath = makeAbsolute(plugins, HomePath) + cfg.PluginsPath = makeAbsolute(plugins, HomePath) cfg.BundledPluginsPath = makeAbsolute("plugins-bundled", HomePath) provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "") cfg.ProvisioningPath = makeAbsolute(provisioning, HomePath) @@ -815,10 +817,14 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.MetricsEndpointDisableTotalStats = iniFile.Section("metrics").Key("disable_total_stats").MustBool(false) analytics := iniFile.Section("analytics") - ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) CheckForUpdates = analytics.Key("check_for_updates").MustBool(true) GoogleAnalyticsId = analytics.Key("google_analytics_ua_id").String() GoogleTagManagerId = analytics.Key("google_tag_manager_id").String() + ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) + ReportingDistributor = analytics.Key("reporting_distributor").MustString("grafana-labs") + if len(ReportingDistributor) >= 100 { + ReportingDistributor = ReportingDistributor[:100] + } if err := readAlertingSettings(iniFile); err != nil { return err @@ -963,7 +969,7 @@ func (cfg *Cfg) LogConfigSources() { cfg.Logger.Info("Path Home", "path", HomePath) cfg.Logger.Info("Path Data", "path", cfg.DataPath) cfg.Logger.Info("Path Logs", "path", cfg.LogsPath) - cfg.Logger.Info("Path Plugins", "path", PluginsPath) + cfg.Logger.Info("Path Plugins", "path", cfg.PluginsPath) cfg.Logger.Info("Path Provisioning", "path", cfg.ProvisioningPath) cfg.Logger.Info("App mode " + cfg.Env) } @@ -1190,7 +1196,9 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { hiddenUsers := users.Key("hidden_users").MustString("") for _, user := range strings.Split(hiddenUsers, ",") { user = strings.TrimSpace(user) - cfg.HiddenUsers[user] = struct{}{} + if user != "" { + cfg.HiddenUsers[user] = struct{}{} + } } return nil diff --git a/pkg/tsdb/influxdb/flux/executor.go b/pkg/tsdb/influxdb/flux/executor.go index 256678e4f99..99c644d5be7 100644 --- a/pkg/tsdb/influxdb/flux/executor.go +++ b/pkg/tsdb/influxdb/flux/executor.go @@ -85,7 +85,9 @@ func readDataFrames(result *api.QueryTableResult, maxPoints int, maxSeries int) } } - // Attach any errors (may be null) - dr.Error = result.Err() + // result.Err() is probably more important then the other errors + if result.Err() != nil { + dr.Error = result.Err() + } return dr } diff --git a/pkg/tsdb/influxdb/flux/flux.go b/pkg/tsdb/influxdb/flux/flux.go index 7e747f93fbf..a7312e2a2b0 100644 --- a/pkg/tsdb/influxdb/flux/flux.go +++ b/pkg/tsdb/influxdb/flux/flux.go @@ -39,7 +39,9 @@ func Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQ continue } - res := executeQuery(ctx, *qm, r, 50) + // If the default changes also update labels/placeholder in config page. + maxSeries := dsInfo.JsonData.Get("maxSeries").MustInt(1000) + res := executeQuery(ctx, *qm, r, maxSeries) tRes.Results[query.RefId] = backendDataResponseToTSDBResponse(&res, query.RefId) } diff --git a/pkg/tsdb/interval.go b/pkg/tsdb/interval.go index dd6eab539c7..781cd30b41a 100644 --- a/pkg/tsdb/interval.go +++ b/pkg/tsdb/interval.go @@ -2,6 +2,7 @@ package tsdb import ( "fmt" + "regexp" "strings" "time" @@ -81,6 +82,13 @@ func GetIntervalFrom(dsInfo *models.DataSource, queryModel *simplejson.Json, def } interval = strings.Replace(strings.Replace(interval, "<", "", 1), ">", "", 1) + isPureNum, err := regexp.MatchString(`^\d+$`, interval) + if err != nil { + return time.Duration(0), err + } + if isPureNum { + interval += "s" + } parsedInterval, err := time.ParseDuration(interval) if err != nil { return time.Duration(0), err diff --git a/pkg/tsdb/interval_test.go b/pkg/tsdb/interval_test.go index d4c273d942d..bb9edbb5c29 100644 --- a/pkg/tsdb/interval_test.go +++ b/pkg/tsdb/interval_test.go @@ -4,6 +4,8 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" "github.com/stretchr/testify/assert" ) @@ -65,3 +67,26 @@ func TestFormatDuration(t *testing.T) { }) } } + +func TestGetIntervalFrom(t *testing.T) { + testCases := []struct { + name string + dsInfo *models.DataSource + queryModel string + defaultInterval time.Duration + expected time.Duration + }{ + {"45s", nil, `{"interval": "45s"}`, time.Second * 15, time.Second * 45}, + {"45", nil, `{"interval": "45"}`, time.Second * 15, time.Second * 45}, + {"2m", nil, `{"interval": "2m"}`, time.Second * 15, time.Minute * 2}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + js, _ := simplejson.NewJson([]byte(tc.queryModel)) + actual, err := GetIntervalFrom(tc.dsInfo, js, tc.defaultInterval) + assert.Nil(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index a256bde330e..d11f3e19cd4 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -107,7 +107,7 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string, } if m.timescaledb { - return fmt.Sprintf("time_bucket('%vs',%s)", interval.Seconds(), args[0]), nil + return fmt.Sprintf("time_bucket('%.0fs',%s)", interval.Seconds(), args[0]), nil } return fmt.Sprintf( diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go index b11a3efccb4..106d71effa0 100644 --- a/pkg/tsdb/postgres/macros_test.go +++ b/pkg/tsdb/postgres/macros_test.go @@ -104,6 +104,13 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, "GROUP BY time_bucket('300s',time_column)") }) + Convey("interpolate __timeGroup function with large time range as an argument and TimescaleDB enabled", func() { + sql, err := engineTS.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '12d')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "GROUP BY time_bucket('1036800s',time_column)") + }) + Convey("interpolate __unixEpochFilter function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") So(err, ShouldBeNil) diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go index a6b2b5ce5b5..c3a93296e3e 100644 --- a/pkg/tsdb/testdatasource/scenarios.go +++ b/pkg/tsdb/testdatasource/scenarios.go @@ -670,7 +670,7 @@ func randomWalk(query backend.DataQuery, model *simplejson.Json, index int) *dat return data.NewFrame("", data.NewField("time", nil, timeVec), - data.NewField(frameNameForQuery(query, model, 0), parseLabels(model), floatVec), + data.NewField(frameNameForQuery(query, model, index), parseLabels(model), floatVec), ) } diff --git a/plugins-bundled/internal/input-datasource/src/testHelpers.ts b/plugins-bundled/internal/input-datasource/src/testHelpers.ts index 47b3308f60c..ed92a3dd85d 100644 --- a/plugins-bundled/internal/input-datasource/src/testHelpers.ts +++ b/plugins-bundled/internal/input-datasource/src/testHelpers.ts @@ -1,5 +1,4 @@ -import { DataQueryRequest, DataQuery, CoreApp } from '@grafana/data'; -import { dateTime } from '@grafana/data'; +import { DataQueryRequest, DataQuery, CoreApp, dateTime } from '@grafana/data'; export function getQueryOptions( options: Partial> diff --git a/public/app/app.ts b/public/app/app.ts index 5835374ba2b..71628c38e73 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -8,9 +8,9 @@ import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'; // fetch polyfill n import ttiPolyfill from 'tti-polyfill'; import 'file-saver'; -import 'lodash'; import 'jquery'; -import 'angular'; +import _ from 'lodash'; +import angular from 'angular'; import 'angular-route'; import 'angular-sanitize'; import 'angular-bindonce'; @@ -19,12 +19,8 @@ import 'react-dom'; import 'vendor/bootstrap/bootstrap'; import 'vendor/angular-other/angular-strap'; - -import $ from 'jquery'; -import angular from 'angular'; import config from 'app/core/config'; // @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move -import _ from 'lodash'; import { AppEvents, setLocale, diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index e7f708f52a1..45800c06bcb 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -25,7 +25,6 @@ import { HelpModal } from './components/help/HelpModal'; import { Footer } from './components/Footer/Footer'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { SearchField, SearchResults, SearchResultsFilter, SearchWrapper } from '../features/search'; -import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings'; const { SecretFormField } = LegacyForms; @@ -162,6 +161,8 @@ export function registerAngularDirectives() { react2AngularDirective('lokiAnnotationsQueryEditor', LokiAnnotationsQueryEditor, [ 'expr', + 'maxLines', + 'instant', 'onChange', ['datasource', { watchDepth: 'reference' }], ]); @@ -185,15 +186,4 @@ export function registerAngularDirectives() { ['onLoad', { watchDepth: 'reference', wrapApply: true }], ['onChange', { watchDepth: 'reference', wrapApply: true }], ]); - react2AngularDirective('timePickerSettings', TimePickerSettings, [ - 'renderCount', - 'refreshIntervals', - 'timePickerHidden', - 'nowDelay', - 'timezone', - ['onTimeZoneChange', { watchDepth: 'reference', wrapApply: true }], - ['onRefreshIntervalChange', { watchDepth: 'reference', wrapApply: true }], - ['onNowDelayChange', { watchDepth: 'reference', wrapApply: true }], - ['onHideTimePickerChange', { watchDepth: 'reference', wrapApply: true }], - ]); } diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index ddb54453121..cc0ab4a917e 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -1,10 +1,9 @@ -import React from 'react'; +import React, { PureComponent } from 'react'; import config from 'app/core/config'; import { updateLocation } from 'app/core/actions'; import { connect } from 'react-redux'; import { StoreState } from 'app/types'; -import { PureComponent } from 'react'; import { getBackendSrv } from '@grafana/runtime'; import { hot } from 'react-hot-loader'; import appEvents from 'app/core/app_events'; diff --git a/public/app/core/components/OrgActionBar/OrgActionBar.tsx b/public/app/core/components/OrgActionBar/OrgActionBar.tsx index 06d6b532ddc..f4afea627cc 100644 --- a/public/app/core/components/OrgActionBar/OrgActionBar.tsx +++ b/public/app/core/components/OrgActionBar/OrgActionBar.tsx @@ -4,7 +4,7 @@ import { LinkButton } from '@grafana/ui'; export interface Props { searchQuery: string; - setSearchQuery: (value: string) => {}; + setSearchQuery: (value: string) => void; linkButton: { href: string; title: string }; target?: string; } diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 15a5e665ca1..46ab58c25a9 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -1,62 +1,58 @@ // Libraries -import React, { Component, HTMLAttributes } from 'react'; +import React, { FC, HTMLAttributes, useEffect } from 'react'; import { getTitleFromNavModel } from 'app/core/selectors/navModel'; // Components import PageHeader from '../PageHeader/PageHeader'; import { Footer } from '../Footer/Footer'; -import PageContents from './PageContents'; -import { CustomScrollbar } from '@grafana/ui'; -import { NavModel } from '@grafana/data'; -import { isEqual } from 'lodash'; +import { PageContents } from './PageContents'; +import { CustomScrollbar, useStyles } from '@grafana/ui'; +import { GrafanaTheme, NavModel } from '@grafana/data'; import { Branding } from '../Branding/Branding'; +import { css } from 'emotion'; interface Props extends HTMLAttributes { children: React.ReactNode; navModel: NavModel; } -class Page extends Component { - static Header = PageHeader; - static Contents = PageContents; - - componentDidMount() { - this.updateTitle(); - } - - componentDidUpdate(prevProps: Props) { - if (!isEqual(prevProps.navModel, this.props.navModel)) { - this.updateTitle(); - } - } - - updateTitle = () => { - const title = this.getPageTitle; - document.title = title ? title + ' - ' + Branding.AppTitle : Branding.AppTitle; - }; - - get getPageTitle() { - const { navModel } = this.props; - if (navModel) { - return getTitleFromNavModel(navModel) || undefined; - } - return undefined; - } - - render() { - const { navModel, children, ...otherProps } = this.props; - return ( -
- -
- - {children} -
-
-
-
- ); - } +export interface PageType extends FC { + Header: typeof PageHeader; + Contents: typeof PageContents; } +export const Page: PageType = ({ navModel, children, ...otherProps }) => { + const styles = useStyles(getStyles); + + useEffect(() => { + const title = getTitleFromNavModel(navModel); + document.title = title ? `${title} - ${Branding.AppTitle}` : Branding.AppTitle; + }, [navModel]); + + return ( +
+ +
+ + {children} +
+
+
+
+ ); +}; + +Page.Header = PageHeader; +Page.Contents = PageContents; + export default Page; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + position: absolute; + top: 0; + bottom: 0; + width: 100%; + background: ${theme.colors.bg1}; + `, +}); diff --git a/public/app/core/components/Page/PageContents.tsx b/public/app/core/components/Page/PageContents.tsx index a4dbdb2af6b..b9b4ef3156e 100644 --- a/public/app/core/components/Page/PageContents.tsx +++ b/public/app/core/components/Page/PageContents.tsx @@ -1,5 +1,5 @@ // Libraries -import React, { Component } from 'react'; +import React, { FC } from 'react'; // Components import PageLoader from '../PageLoader/PageLoader'; @@ -9,12 +9,6 @@ interface Props { children: React.ReactNode; } -class PageContents extends Component { - render() { - const { isLoading } = this.props; - - return
{isLoading ? : this.props.children}
; - } -} - -export default PageContents; +export const PageContents: FC = ({ isLoading, children }) => { + return
{isLoading ? : children}
; +}; diff --git a/public/app/core/components/PageHeader/PageHeader.test.tsx b/public/app/core/components/PageHeader/PageHeader.test.tsx index f38839f145a..03ac4b58663 100644 --- a/public/app/core/components/PageHeader/PageHeader.test.tsx +++ b/public/app/core/components/PageHeader/PageHeader.test.tsx @@ -1,12 +1,10 @@ import React from 'react'; import PageHeader from './PageHeader'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; describe('PageHeader', () => { - let wrapper: ShallowWrapper; - describe('when the nav tree has a node with a title', () => { - beforeAll(() => { + it('should render the title', async () => { const nav = { main: { icon: 'folder-open', @@ -17,17 +15,15 @@ describe('PageHeader', () => { }, node: {}, }; - wrapper = shallow(); - }); - it('should render the title', () => { - const title = wrapper.find('.page-header__title'); - expect(title.text()).toBe('node'); + render(); + + expect(screen.getByRole('heading', { name: 'node' })).toBeInTheDocument(); }); }); describe('when the nav tree has a node with breadcrumbs and a title', () => { - beforeAll(() => { + it('should render the title with breadcrumbs first and then title last', async () => { const nav = { main: { icon: 'folder-open', @@ -39,15 +35,11 @@ describe('PageHeader', () => { }, node: {}, }; - wrapper = shallow(); - }); - it('should render the title with breadcrumbs first and then title last', () => { - const title = wrapper.find('.page-header__title'); - expect(title.text()).toBe('Parent / child'); + render(); - const parentLink = wrapper.find('.page-header__title > a.text-link'); - expect(parentLink.prop('href')).toBe('parentUrl'); + expect(screen.getByRole('heading', { name: 'Parent / child' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Parent' })).toBeInTheDocument(); }); }); }); diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index ee343356888..fa305235e3f 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { FC } from 'react'; import { css } from 'emotion'; -import { Tab, TabsBar, Icon, IconName } from '@grafana/ui'; -import { NavModel, NavModelItem, NavModelBreadcrumb } from '@grafana/data'; +import { Tab, TabsBar, Icon, IconName, useStyles } from '@grafana/ui'; +import { NavModel, NavModelItem, NavModelBreadcrumb, GrafanaTheme } from '@grafana/data'; import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; export interface Props { @@ -71,86 +71,77 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => { ); }; -export default class PageHeader extends React.Component { - constructor(props: Props) { - super(props); +export const PageHeader: FC = ({ model }) => { + const styles = useStyles(getStyles); + + if (!model) { + return null; } - shouldComponentUpdate() { - //Hack to re-render on changed props from angular with the @observer decorator - return true; - } + const main = model.main; + const children = main.children; - renderTitle(title: string, breadcrumbs: NavModelBreadcrumb[]) { - if (!title && (!breadcrumbs || breadcrumbs.length === 0)) { - return null; - } - - if (!breadcrumbs || breadcrumbs.length === 0) { - return

{title}

; - } - - const breadcrumbsResult = []; - for (const bc of breadcrumbs) { - if (bc.url) { - breadcrumbsResult.push( - - {bc.title} - - ); - } else { - breadcrumbsResult.push( / {bc.title}); - } - } - breadcrumbsResult.push( / {title}); - - return

{breadcrumbsResult}

; - } - - renderHeaderTitle(main: NavModelItem) { - const iconClassName = - main.icon === 'grafana' - ? css` - margin-top: 12px; - ` - : css` - margin-top: 14px; - `; - - return ( -
- - {main.icon && } - {main.img && {`logo} - - -
- {this.renderTitle(main.text, main.breadcrumbs ?? [])} - {main.subTitle &&
{main.subTitle}
} + return ( +
+
+
+ {renderHeaderTitle(main)} + {children && children.length && {children}}
- ); - } +
+ ); +}; - render() { - const { model } = this.props; +function renderHeaderTitle(main: NavModelItem) { + const marginTop = main.icon === 'grafana' ? 12 : 14; - if (!model) { - return null; - } + return ( +
+ + {main.icon && } + {main.img && {`logo} + - const main = model.main; - const children = main.children; - - return ( -
-
-
- {this.renderHeaderTitle(main)} - {children && children.length && {children}} -
-
+
+ {renderTitle(main.text, main.breadcrumbs ?? [])} + {main.subTitle &&
{main.subTitle}
}
- ); - } +
+ ); } + +function renderTitle(title: string, breadcrumbs: NavModelBreadcrumb[]) { + if (!title && (!breadcrumbs || breadcrumbs.length === 0)) { + return null; + } + + if (!breadcrumbs || breadcrumbs.length === 0) { + return

{title}

; + } + + const breadcrumbsResult = []; + for (const bc of breadcrumbs) { + if (bc.url) { + breadcrumbsResult.push( + + {bc.title} + + ); + } else { + breadcrumbsResult.push( / {bc.title}); + } + } + breadcrumbsResult.push( / {title}); + + return

{breadcrumbsResult}

; +} + +const getStyles = (theme: GrafanaTheme) => ({ + headerCanvas: css` + background: ${theme.colors.bg2}; + border-bottom: 1px solid ${theme.colors.border1}; + `, +}); + +export default PageHeader; diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx index f8735d78e9f..451e45e842a 100644 --- a/public/app/core/components/Select/DashboardPicker.tsx +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -7,8 +7,8 @@ import { DashboardSearchHit } from 'app/features/search/types'; import { DashboardDTO } from 'app/types'; export interface Props { - onSelected: (dashboard: DashboardDTO) => void; - currentDashboard?: SelectableValue; + onChange: (dashboard: DashboardDTO) => void; + value?: SelectableValue; width?: number; isClearable?: boolean; invalid?: boolean; @@ -26,14 +26,7 @@ const getDashboards = (query = '') => { }); }; -export const DashboardPicker: FC = ({ - onSelected, - currentDashboard, - width, - isClearable = false, - invalid, - disabled, -}) => { +export const DashboardPicker: FC = ({ onChange, value, width, isClearable = false, invalid, disabled }) => { const debouncedSearch = debounce(getDashboards, 300); return ( @@ -42,10 +35,10 @@ export const DashboardPicker: FC = ({ isClearable={isClearable} defaultOptions={true} loadOptions={debouncedSearch} - onChange={onSelected} + onChange={onChange} placeholder="Select dashboard" noOptionsMessage="No dashboards found" - value={currentDashboard} + value={value} invalid={invalid} disabled={disabled} /> diff --git a/public/app/core/components/Select/SortPicker.tsx b/public/app/core/components/Select/SortPicker.tsx index 71639eadea3..589a76bea8a 100644 --- a/public/app/core/components/Select/SortPicker.tsx +++ b/public/app/core/components/Select/SortPicker.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import { useAsync } from 'react-use'; -import { Select, Icon } from '@grafana/ui'; +import { Select, Icon, IconName } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; import { DEFAULT_SORT } from 'app/features/search/constants'; import { SearchSrv } from '../../services/search_srv'; @@ -9,7 +9,7 @@ const searchSrv = new SearchSrv(); export interface Props { onChange: (sortValue: SelectableValue) => void; - value?: SelectableValue | null; + value?: string; placeholder?: string; } @@ -23,14 +23,16 @@ export const SortPicker: FC = ({ onChange, value, placeholder }) => { // Using sync Select and manual options fetching here since we need to find the selected option by value const { loading, value: options } = useAsync(getSortOptions, []); + const selected = options?.filter((opt) => opt.value === value); return !loading ? ( - - ) : ( - - )} -
+ + + ); }; diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index 995adc75b94..edfa4f71bd0 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -1,30 +1,134 @@ -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; +import { SelectableValue, TimeZone } from '@grafana/data'; +import { Select, TagsInput, Input, Field, CollapsableSection, RadioButtonGroup } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { DashboardModel } from '../../state/DashboardModel'; -import { AngularComponent, getAngularLoader } from '@grafana/runtime'; +import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton'; +import { TimePickerSettings } from './TimePickerSettings'; interface Props { dashboard: DashboardModel; } -export class GeneralSettings extends PureComponent { - element?: HTMLElement | null; - angularCmp?: AngularComponent; +const GRAPH_TOOLTIP_OPTIONS = [ + { value: 0, label: 'Default' }, + { value: 1, label: 'Shared crosshair' }, + { value: 2, label: 'Shared Tooltip' }, +]; - componentDidMount() { - const loader = getAngularLoader(); +export const GeneralSettings: React.FC = ({ dashboard }) => { + const [renderCounter, setRenderCounter] = useState(0); - const template = ''; - const scopeProps = { dashboard: this.props.dashboard }; - this.angularCmp = loader.load(this.element, scopeProps, template); - } + const onFolderChange = (folder: { id: number; title: string }) => { + dashboard.meta.folderId = folder.id; + dashboard.meta.folderTitle = folder.title; + dashboard.meta.hasUnsavedFolderChange = true; + }; - componentWillUnmount() { - if (this.angularCmp) { - this.angularCmp.destroy(); - } - } + const onBlur = (event: React.FocusEvent) => { + dashboard[event.currentTarget.name as 'title' | 'description'] = event.currentTarget.value; + }; - render() { - return
(this.element = ref)} />; - } -} + const onTooltipChange = (graphTooltip: SelectableValue) => { + dashboard.graphTooltip = graphTooltip.value; + setRenderCounter(renderCounter + 1); + }; + + const onRefreshIntervalChange = (intervals: string[]) => { + dashboard.timepicker.refresh_intervals = intervals.filter((i) => i.trim() !== ''); + }; + + const onNowDelayChange = (nowDelay: string) => { + dashboard.timepicker.nowDelay = nowDelay; + }; + + const onHideTimePickerChange = (hide: boolean) => { + dashboard.timepicker.hidden = hide; + setRenderCounter(renderCounter + 1); + }; + + const onTimeZoneChange = (timeZone: TimeZone) => { + dashboard.timezone = timeZone; + setRenderCounter(renderCounter + 1); + }; + + const onTagsChange = (tags: string[]) => { + dashboard.tags = tags; + }; + + const onEditableChange = (value: boolean) => { + dashboard.editable = value; + setRenderCounter(renderCounter + 1); + }; + + const editableOptions = [ + { label: 'Editable', value: true }, + { label: 'Read-only', value: false }, + ]; + + return ( +
+

+ General +

+
+ + + + + + + + + + + + + + + + +
+ + + + + + - -
- -
- - - -
-
-
+ + + + + + + + + ); } } diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts index 504e62e1504..92fde590504 100644 --- a/public/app/features/dashboard/components/DashboardSettings/index.ts +++ b/public/app/features/dashboard/components/DashboardSettings/index.ts @@ -1,3 +1 @@ -export { SettingsCtrl } from './SettingsCtrl'; export { DashboardSettings } from './DashboardSettings'; -export { TimePickerSettings } from './TimePickerSettings'; diff --git a/public/app/features/dashboard/components/DashboardSettings/template.html b/public/app/features/dashboard/components/DashboardSettings/template.html deleted file mode 100644 index c0f989d96fe..00000000000 --- a/public/app/features/dashboard/components/DashboardSettings/template.html +++ /dev/null @@ -1,64 +0,0 @@ - -

- General -

- -
-
- - -
-
- - -
-
- - - -
- - - - -
- - - - -
Panel Options
-
- -
- -
-
-
- -
diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx new file mode 100644 index 00000000000..a32207fc831 --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { DeleteDashboardModal } from './DeleteDashboardModal'; +import { Button, ModalsController } from '@grafana/ui'; +import { DashboardModel } from '../../state'; + +type Props = { + dashboard: DashboardModel; +}; + +export const DeleteDashboardButton = ({ dashboard }: Props) => ( + + {({ showModal, hideModal }) => ( + + )} + +); diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx new file mode 100644 index 00000000000..8123b2e8abf --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { css } from 'emotion'; +import sumBy from 'lodash/sumBy'; +import { Modal, ConfirmModal, HorizontalGroup, Button } from '@grafana/ui'; +import { DashboardModel, PanelModel } from '../../state'; +import { useDashboardDelete } from './useDashboardDelete'; + +type DeleteDashboardModalProps = { + hideModal(): void; + dashboard: DashboardModel; +}; + +export const DeleteDashboardModal: React.FC = ({ hideModal, dashboard }) => { + const isProvisioned = dashboard.meta.provisioned; + const { onRestoreDashboard } = useDashboardDelete(dashboard.uid); + const modalBody = getModalBody(dashboard.panels, dashboard.title); + + if (isProvisioned) { + return ; + } + + return ( + + ); +}; + +const getModalBody = (panels: PanelModel[], title: string) => { + const totalAlerts = sumBy(panels, (panel) => (panel.alert ? 1 : 0)); + return totalAlerts > 0 ? ( + <> +

Do you want to delete this dashboard?

+

+ This dashboard contains {totalAlerts} alert{totalAlerts > 1 ? 's' : ''}. Deleting this dashboard will also + delete those alerts +

+ + ) : ( + <> +

Do you want to delete this dashboard?

+

{title}

+ + ); +}; + +const ProvisionedDeleteModal = ({ hideModal, provisionedId }: { hideModal(): void; provisionedId: string }) => ( + +

+ This dashboard is managed by Grafanas provisioning and cannot be deleted. Remove the dashboard from the config + file to delete it. +

+

+ + See{' '} + + documentation + {' '} + for more information about provisioning. + +
+ File path: {provisionedId} +

+ + + +
+); diff --git a/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx new file mode 100644 index 00000000000..91a0429f140 --- /dev/null +++ b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { useAsyncFn } from 'react-use'; +import { AppEvents } from '@grafana/data'; +import appEvents from 'app/core/app_events'; +import { updateLocation } from 'app/core/reducers/location'; +import { deleteDashboard } from 'app/features/manage-dashboards/state/actions'; + +export const useDashboardDelete = (uid: string) => { + const dispatch = useDispatch(); + const [state, onRestoreDashboard] = useAsyncFn(() => deleteDashboard(uid, false), []); + useEffect(() => { + if (state.value) { + dispatch( + updateLocation({ + path: '/', + replace: true, + query: {}, + }) + ); + appEvents.emit(AppEvents.alertSuccess, ['Dashboard Deleted', state.value.title + ' has been deleted']); + } + }, [state]); + return { state, onRestoreDashboard }; +}; diff --git a/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx b/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx index 447d097523a..e188aabfc40 100644 --- a/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx +++ b/public/app/features/dashboard/components/Inspector/InspectDataTab.tsx @@ -10,9 +10,9 @@ import { SelectableValue, toCSV, transformDataFrame, + CSVConfig, } from '@grafana/data'; import { Button, Container, Field, HorizontalGroup, Spinner, Select, Switch, Table, VerticalGroup } from '@grafana/ui'; -import { CSVConfig } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { getPanelInspectorStyles } from './styles'; diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index a5e1951b733..1de8c0c6d06 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -6,21 +6,7 @@ import { ShareSnapshot } from './ShareSnapshot'; import { ShareExport } from './ShareExport'; import { ShareEmbed } from './ShareEmbed'; import { ShareModalTabModel } from './types'; - -const shareCommonTabs: ShareModalTabModel[] = [ - { label: 'Link', value: 'link', component: ShareLink }, - { label: 'Snapshot', value: 'snapshot', component: ShareSnapshot }, -]; - -// prettier-ignore -const shareDashboardTabs: ShareModalTabModel[] = [ - { label: 'Export', value: 'export', component: ShareExport }, -]; - -// prettier-ignore -const sharePanelTabs: ShareModalTabModel[] = [ - { label: 'Embed', value: 'embed', component: ShareEmbed }, -]; +import { contextSrv } from 'app/core/core'; const customDashboardTabs: ShareModalTabModel[] = []; const customPanelTabs: ShareModalTabModel[] = []; @@ -43,13 +29,18 @@ function getInitialState(props: Props): State { function getTabs(props: Props) { const { panel } = props; - const tabs = [...shareCommonTabs]; + + const tabs: ShareModalTabModel[] = [{ label: 'Link', value: 'link', component: ShareLink }]; + + if (contextSrv.isSignedIn) { + tabs.push({ label: 'Snapshot', value: 'snapshot', component: ShareSnapshot }); + } if (panel) { - tabs.push(...sharePanelTabs); + tabs.push({ label: 'Embed', value: 'embed', component: ShareEmbed }); tabs.push(...customPanelTabs); } else { - tabs.push(...shareDashboardTabs); + tabs.push({ label: 'Export', value: 'export', component: ShareExport }); tabs.push(...customDashboardTabs); } diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx index 9abf7c63b3a..04d51e31f1f 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx @@ -46,7 +46,7 @@ export const DashboardLinks: FC = ({ dashboard, links }) => { diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx index 7c304f702e7..fa3b3787bf4 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -40,7 +40,7 @@ export const DashboardLinksDashboard: React.FC = (props) => {
  • {resolvedLink.title} @@ -67,7 +67,7 @@ export const DashboardLinksDashboard: React.FC = (props) => { diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx index c1324bed2ed..6d0e7d71694 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -6,7 +6,7 @@ import { CustomScrollbar, stylesFactory, Themeable, - DismissableFeatureInfoBox, + FeatureInfoBox, useTheme, VerticalGroup, withTheme, @@ -33,6 +33,9 @@ import { TransformationOperationRows } from './TransformationOperationRows'; import { TransformationsEditorTransformation } from './types'; import { PanelNotSupported } from '../PanelEditor/PanelNotSupported'; import { AppNotificationSeverity } from '../../../../types'; +import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; + +const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed'; interface TransformationsEditorProps extends Themeable { panel: PanelModel; @@ -254,22 +257,34 @@ class UnThemedTransformationsEditor extends React.PureComponent {noTransforms && ( - -

    - Transformations allow you to join, calculate, re-order, hide and rename your query results before being - visualized.
    - Many transforms are not suitable if you're using the Graph visualization as it currently only - supports time series.
    - It can help to switch to Table visualization to understand what a transformation is doing.
    -

    -
    + storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> + {(isDismissed, onDismiss) => { + if (isDismissed) { + return null; + } + + return ( + { + onDismiss(true); + }} + url={getDocsLink(DocsId.Transformations)} + > +

    + Transformations allow you to join, calculate, re-order, hide and rename your query results before + being visualized.
    + Many transforms are not suitable if you're using the Graph visualization as it currently only + supports time series.
    + It can help to switch to Table visualization to understand what a transformation is doing.
    +

    +
    + ); + }} +
    )} {showPicker ? ( diff --git a/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx index 6fd9fd677d1..831e1e83d7c 100644 --- a/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx +++ b/public/app/features/dashboard/components/VersionHistory/RevertDashboardModal.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { css } from 'emotion'; -import { HorizontalGroup, Modal, Button } from '@grafana/ui'; +import { ConfirmModal } from '@grafana/ui'; import { useDashboardRestore } from './useDashboardRestore'; export interface RevertDashboardModalProps { hideModal: () => void; @@ -12,25 +11,16 @@ export const RevertDashboardModal: React.FC = ({ hide const { onRestoreDashboard } = useDashboardRestore(version); return ( - -

    Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.

    - - - - -
    + onConfirm={onRestoreDashboard} + body={ +

    Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.

    + } + confirmText={`Yes, restore to version ${version}`} + /> ); }; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index b1af1163eee..fb379119eb6 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -7,7 +7,7 @@ import { DashboardInitPhase, DashboardRouteInfo } from 'app/types'; import { notifyApp, updateLocation } from 'app/core/actions'; import { cleanUpDashboardAndVariables } from '../state/actions'; -jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); +jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({})); interface ScenarioContext { cleanUpDashboardAndVariablesMock: typeof cleanUpDashboardAndVariables; diff --git a/public/app/features/dashboard/containers/SoloPanelPage.test.tsx b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx index 835544feeea..9294ee0052c 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.test.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.test.tsx @@ -5,7 +5,7 @@ import { Props as DashboardPanelProps } from '../dashgrid/DashboardPanel'; import { DashboardModel } from '../state'; import { DashboardRouteInfo } from 'app/types'; -jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); +jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => ({})); jest.mock('app/features/dashboard/dashgrid/DashboardPanel', () => { class DashboardPanel extends React.Component { render() { diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 4c4009135dc..f475124a456 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -10,10 +10,9 @@ import { PanelChromeAngular } from './PanelChromeAngular'; // Actions import { initDashboardPanel } from '../state/actions'; -import { updateLocation } from 'app/core/reducers/location'; // Types -import { PanelModel, DashboardModel } from '../state'; +import { DashboardModel, PanelModel } from '../state'; import { StoreState } from 'app/types'; import { PanelPlugin } from '@grafana/data'; @@ -40,7 +39,7 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => { }; }; -const mapDispatchToProps = { initDashboardPanel, updateLocation }; +const mapDispatchToProps = { initDashboardPanel }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -76,7 +75,7 @@ export class DashboardPanelUnconnected extends PureComponent { }; renderPanel(plugin: PanelPlugin) { - const { dashboard, panel, isViewing, isInView, isEditing, updateLocation } = this.props; + const { dashboard, panel, isViewing, isInView, isEditing } = this.props; return ( @@ -110,7 +109,6 @@ export class DashboardPanelUnconnected extends PureComponent { isInView={isInView} width={width} height={height} - updateLocation={updateLocation} /> ); }} diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx index 84685e6c1db..a2bbbe4aad1 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx @@ -1,11 +1,12 @@ import React, { FC } from 'react'; import { ReplaySubject } from 'rxjs'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; import { act, render, screen } from '@testing-library/react'; import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin, PanelProps } from '@grafana/data'; import { PanelChrome, Props } from './PanelChrome'; import { DashboardModel, PanelModel } from '../state'; -import { updateLocation } from '../../../core/actions'; import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; import { setTimeSrv, TimeSrv } from '../services/TimeSrv'; @@ -16,6 +17,8 @@ jest.mock('app/core/profiler', () => ({ })); function setupTestContext(options: Partial) { + const mockStore = configureMockStore(); + const store = mockStore({ dashboard: { panels: [] } }); const subject: ReplaySubject = new ReplaySubject(); const panelQueryRunner = ({ getData: () => subject, @@ -48,19 +51,22 @@ function setupTestContext(options: Partial) { isInView: false, width: 100, height: 100, - updateLocation: (jest.fn() as unknown) as typeof updateLocation, }; const props = { ...defaults, ...options }; - const { rerender } = render(); + const { rerender } = render( + + + + ); - return { rerender, props, subject }; + return { rerender, props, subject, store }; } describe('PanelChrome', () => { describe('when the user scrolls by a panel so fast that it starts loading data but scrolls out of view', () => { it('then it should load the panel successfully when scrolled into view again', () => { - const { rerender, props, subject } = setupTestContext({}); + const { rerender, props, subject, store } = setupTestContext({}); expect(screen.queryByText(/plugin panel to render/i)).not.toBeInTheDocument(); @@ -70,7 +76,11 @@ describe('PanelChrome', () => { }); const newProps = { ...props, isInView: true }; - rerender(); + rerender( + + + + ); expect(screen.getByText(/plugin panel to render/i)).toBeInTheDocument(); }); diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 684faa94faf..47bc5d63d22 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -10,7 +10,6 @@ import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; import { profiler } from 'app/core/profiler'; import config from 'app/core/config'; -import { updateLocation } from 'app/core/actions'; // Types import { DashboardModel, PanelModel } from '../state'; import { PANEL_BORDER } from 'app/core/constants'; @@ -40,7 +39,6 @@ export interface Props { isInView: boolean; width: number; height: number; - updateLocation: typeof updateLocation; } export interface State { @@ -324,7 +322,7 @@ export class PanelChrome extends Component { } render() { - const { dashboard, panel, isViewing, isEditing, width, height, updateLocation } = this.props; + const { dashboard, panel, isViewing, isEditing, width, height } = this.props; const { errorMessage, data } = this.state; const { transparent } = panel; @@ -347,7 +345,6 @@ export class PanelChrome extends Component { isEditing={isEditing} isViewing={isViewing} data={data} - updateLocation={updateLocation} /> {({ error }) => { diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx index 02bc13f988f..b2ebd168efe 100644 --- a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -14,7 +14,6 @@ import config from 'app/core/config'; import { DashboardModel, PanelModel } from '../state'; import { StoreState } from 'app/types'; import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data'; -import { updateLocation } from 'app/core/actions'; import { PANEL_BORDER } from 'app/core/constants'; import { selectors } from '@grafana/e2e-selectors'; import { RenderEvent } from 'app/types/events'; @@ -36,7 +35,6 @@ interface ConnectedProps { interface DispatchProps { setPanelAngularComponent: typeof setPanelAngularComponent; - updateLocation: typeof updateLocation; } export type Props = OwnProps & ConnectedProps & DispatchProps; @@ -214,7 +212,7 @@ export class PanelChromeAngularUnconnected extends PureComponent { } render() { - const { dashboard, panel, isViewing, isEditing, plugin, angularComponent, updateLocation } = this.props; + const { dashboard, panel, isViewing, isEditing, plugin } = this.props; const { errorMessage, data, alertState } = this.state; const { transparent } = panel; @@ -239,13 +237,11 @@ export class PanelChromeAngularUnconnected extends PureComponent { dashboard={dashboard} title={panel.title} description={panel.description} - angularComponent={angularComponent} links={panel.links} error={errorMessage} isViewing={isViewing} isEditing={isEditing} data={data} - updateLocation={updateLocation} alertState={alertState} />
    @@ -262,6 +258,6 @@ const mapStateToProps: MapStateToProps = ( }; }; -const mapDispatchToProps: MapDispatchToProps = { setPanelAngularComponent, updateLocation }; +const mapDispatchToProps: MapDispatchToProps = { setPanelAngularComponent }; export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 35046f13990..540f1fdeff2 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -1,234 +1,74 @@ -import React, { PureComponent } from 'react'; -import classNames from 'classnames'; -import { DataLink, LoadingState, PanelData, PanelMenuItem, QueryResultMetaNotice } from '@grafana/data'; -import { AngularComponent, config } from '@grafana/runtime'; -import { ClickOutsideWrapper, Icon, IconName, Tooltip, stylesFactory } from '@grafana/ui'; +import React, { FC } from 'react'; +import { cx } from 'emotion'; +import { DataLink, PanelData } from '@grafana/data'; +import { Icon } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import PanelHeaderCorner from './PanelHeaderCorner'; -import { PanelHeaderMenu } from './PanelHeaderMenu'; - import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; -import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu'; -import { updateLocation } from 'app/core/actions'; -import { css } from 'emotion'; +import { PanelHeaderNotices } from './PanelHeaderNotices'; +import { PanelHeaderMenuTrigger } from './PanelHeaderMenuTrigger'; +import { PanelHeaderLoadingIndicator } from './PanelHeaderLoadingIndicator'; +import { PanelHeaderMenuWrapper } from './PanelHeaderMenuWrapper'; export interface Props { panel: PanelModel; dashboard: DashboardModel; title?: string; description?: string; - angularComponent?: AngularComponent | null; links?: DataLink[]; error?: string; alertState?: string; isViewing: boolean; isEditing: boolean; data: PanelData; - updateLocation: typeof updateLocation; } -interface ClickCoordinates { - x: number; - y: number; -} +export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, data, alertState, dashboard }) => { + const onCancelQuery = () => panel.getQueryRunner().cancelQuery(); + const title = panel.replaceVariables(panel.title, {}, 'text'); + const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : ''); -interface State { - panelMenuOpen: boolean; - menuItems: PanelMenuItem[]; -} - -export class PanelHeader extends PureComponent { - clickCoordinates: ClickCoordinates = { x: 0, y: 0 }; - - state: State = { - panelMenuOpen: false, - menuItems: [], - }; - - eventToClickCoordinates = (event: React.MouseEvent) => { - return { - x: Math.floor(event.clientX), - y: Math.floor(event.clientY), - }; - }; - - onMouseDown = (event: React.MouseEvent) => { - this.clickCoordinates = this.eventToClickCoordinates(event); - }; - - isClick = (clickCoordinates: ClickCoordinates) => { - return clickCoordinates.x === this.clickCoordinates.x && clickCoordinates.y === this.clickCoordinates.y; - }; - - onMenuToggle = (event: React.MouseEvent) => { - if (!this.isClick(this.eventToClickCoordinates(event))) { - return; - } - - event.stopPropagation(); - - const { dashboard, panel, angularComponent } = this.props; - const menuItems = getPanelMenu(dashboard, panel, angularComponent); - - this.setState({ - panelMenuOpen: !this.state.panelMenuOpen, - menuItems, - }); - }; - - closeMenu = () => { - this.setState({ - panelMenuOpen: false, - }); - }; - - onCancelQuery = () => { - this.props.panel.getQueryRunner().cancelQuery(); - }; - - renderLoadingState(state: LoadingState): JSX.Element | null { - if (state === LoadingState.Loading) { - return ( -
    - - - -
    - ); - } - - if (state === LoadingState.Streaming) { - const styles = getStyles(); - - return ( -
    -
    -
    - ); - } - - return null; - } - - openInspect = (e: React.SyntheticEvent, tab: string) => { - const { updateLocation, panel } = this.props; - - e.stopPropagation(); - - updateLocation({ - query: { inspect: panel.id, inspectTab: tab }, - partial: true, - }); - }; - - // This will show one icon for each severity - renderNotice = (notice: QueryResultMetaNotice) => { - let iconName: IconName = 'info-circle'; - if (notice.severity === 'error' || notice.severity === 'warning') { - iconName = 'exclamation-triangle'; - } - - return ( - - {notice.inspect ? ( -
    this.openInspect(e, notice.inspect!)}> - -
    - ) : ( -
    - - - )} - - ); - }; - - render() { - const { panel, error, isViewing, isEditing, data, alertState } = this.props; - const { menuItems } = this.state; - const title = panel.replaceVariables(panel.title, {}, 'text'); - - const panelHeaderClass = classNames({ - 'panel-header': true, - 'grid-drag-handle': !(isViewing || isEditing), - }); - - // dedupe on severity - const notices: Record = {}; - - for (const series of data.series) { - if (series.meta && series.meta.notices) { - for (const notice of series.meta.notices) { - notices[notice.severity] = notice; - } - } - } - - return ( - <> - {this.renderLoadingState(data.state)} -
    - -
    -
    - {Object.values(notices).map(this.renderNotice)} - {alertState && ( - - )} - {title} - - {this.state.panelMenuOpen && ( - - - - )} - {data.request && data.request.timeInfo && ( - - {data.request.timeInfo} - - )} -
    -
    -
    - - ); - } -} - -/* - * Styles - */ -export const getStyles = stylesFactory(() => { - return { - streamIndicator: css` - width: 10px; - height: 10px; - background: ${config.theme.colors.textFaint}; - box-shadow: 0 0 2px ${config.theme.colors.textFaint}; - border-radius: 50%; - position: relative; - top: 6px; - right: 1px; - `, - }; -}); + return ( + <> + +
    + + + {({ closeMenu, panelMenuOpen }) => { + return ( +
    + + {alertState ? ( + + ) : null} + {title} + + + {data.request && data.request.timeInfo && ( + + {data.request.timeInfo} + + )} +
    + ); + }} +
    +
    + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx new file mode 100644 index 00000000000..a32ea8fed4d --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderLoadingIndicator.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react'; +import { css } from 'emotion'; +import { GrafanaTheme, LoadingState } from '@grafana/data'; +import { Icon, Tooltip, useStyles } from '@grafana/ui'; + +interface Props { + state: LoadingState; + onClick: () => void; +} + +export const PanelHeaderLoadingIndicator: FC = ({ state, onClick }) => { + const styles = useStyles(getStyles); + + if (state === LoadingState.Loading) { + return ( +
    + + + +
    + ); + } + + if (state === LoadingState.Streaming) { + return ( +
    +
    +
    + ); + } + + return null; +}; + +function getStyles(theme: GrafanaTheme) { + return { + streamIndicator: css` + width: 10px; + height: 10px; + background: ${theme.colors.textFaint}; + box-shadow: 0 0 2px ${theme.colors.textFaint}; + border-radius: 50%; + position: relative; + top: 6px; + right: 1px; + `, + }; +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx new file mode 100644 index 00000000000..4fd29502f92 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx @@ -0,0 +1,29 @@ +import { FC, ReactElement, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { PanelMenuItem } from '@grafana/data'; + +import { DashboardModel, PanelModel } from '../../state'; +import { StoreState } from '../../../../types'; +import { getPanelMenu } from '../../utils/getPanelMenu'; + +interface PanelHeaderMenuProviderApi { + items: PanelMenuItem[]; +} + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; + children: (props: PanelHeaderMenuProviderApi) => ReactElement; +} + +export const PanelHeaderMenuProvider: FC = ({ panel, dashboard, children }) => { + const [items, setItems] = useState([]); + const angularComponent = useSelector( + (state: StoreState) => state.dashboard.panels[panel.id]?.angularComponent || null + ); + useEffect(() => { + setItems(getPanelMenu(dashboard, panel, angularComponent)); + }, [dashboard, panel, angularComponent, setItems]); + + return children({ items }); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx new file mode 100644 index 00000000000..38741292e10 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx @@ -0,0 +1,51 @@ +import React, { FC, HTMLAttributes, MouseEvent, ReactElement, useCallback, useState } from 'react'; +import { CartesianCoords2D } from '@grafana/data'; + +interface PanelHeaderMenuTriggerApi { + panelMenuOpen: boolean; + closeMenu: () => void; +} + +interface Props extends HTMLAttributes { + children: (props: PanelHeaderMenuTriggerApi) => ReactElement; +} + +export const PanelHeaderMenuTrigger: FC = ({ children, ...divProps }) => { + const [clickCoordinates, setClickCoordinates] = useState({ x: 0, y: 0 }); + const [panelMenuOpen, setPanelMenuOpen] = useState(false); + const onMenuToggle = useCallback( + (event: MouseEvent) => { + if (!isClick(clickCoordinates, eventToClickCoordinates(event))) { + return; + } + + event.stopPropagation(); + + setPanelMenuOpen(!panelMenuOpen); + }, + [clickCoordinates, panelMenuOpen, setPanelMenuOpen] + ); + const onMouseDown = useCallback( + (event: MouseEvent) => { + setClickCoordinates(eventToClickCoordinates(event)); + }, + [setClickCoordinates] + ); + + return ( +
    + {children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })} +
    + ); +}; + +function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D): boolean { + return clicked.x === current.x && clicked.y === current.y; +} + +function eventToClickCoordinates(event: MouseEvent): CartesianCoords2D { + return { + x: Math.floor(event.clientX), + y: Math.floor(event.clientY), + }; +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx new file mode 100644 index 00000000000..596dc96fa65 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { ClickOutsideWrapper } from '@grafana/ui'; +import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider'; +import { PanelHeaderMenu } from './PanelHeaderMenu'; +import { DashboardModel, PanelModel } from '../../state'; + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; + show: boolean; + onClose: () => void; +} + +export const PanelHeaderMenuWrapper: FC = ({ show, onClose, panel, dashboard }) => { + if (!show) { + return null; + } + + return ( + + + {({ items }) => { + return ; + }} + + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx new file mode 100644 index 00000000000..16e9670523a --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import { QueryResultMetaNotice } from '@grafana/data'; +import { Icon, Tooltip } from '@grafana/ui'; + +interface Props { + notice: QueryResultMetaNotice; + onClick: (e: React.SyntheticEvent, tab: string) => void; +} + +export const PanelHeaderNotice: FC = ({ notice, onClick }) => { + const iconName = + notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle'; + + return ( + + {notice.inspect ? ( +
    onClick(e, notice.inspect!)}> + +
    + ) : ( + + + + )} +
    + ); +}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx new file mode 100644 index 00000000000..d4b888a9aa0 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotices.tsx @@ -0,0 +1,47 @@ +import React, { FC, useCallback } from 'react'; +import { DataFrame, QueryResultMetaNotice } from '@grafana/data'; +import { PanelHeaderNotice } from './PanelHeaderNotice'; +import { useDispatch } from 'react-redux'; +import { updateLocation } from '../../../../core/actions'; + +interface Props { + panelId: number; + frames: DataFrame[]; +} + +export const PanelHeaderNotices: FC = ({ frames, panelId }) => { + const dispatch = useDispatch(); + const openInspect = useCallback( + (e: React.SyntheticEvent, tab: string) => { + e.stopPropagation(); + + dispatch( + updateLocation({ + query: { inspect: panelId, inspectTab: tab }, + partial: true, + }) + ); + }, + [panelId] + ); + + // dedupe on severity + const notices: Record = {}; + for (const frame of frames) { + if (!frame.meta || !frame.meta.notices) { + continue; + } + + for (const notice of frame.meta.notices) { + notices[notice.severity] = notice; + } + } + + return ( + <> + {Object.values(notices).map((notice) => ( + + ))} + + ); +}; diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts index ebc3370ca87..74070a4d051 100644 --- a/public/app/features/dashboard/index.ts +++ b/public/app/features/dashboard/index.ts @@ -8,9 +8,3 @@ import './components/DashExportModal'; import './components/DashNav'; import './components/VersionHistory'; import './components/DashboardSettings'; - -import { DashboardPermissions } from './components/DashboardPermissions/DashboardPermissions'; -// angular wrappers -import { react2AngularDirective } from 'app/core/utils/react2angular'; - -react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']); diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index d5083df2154..4a7f8b8be05 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -107,15 +107,16 @@ const defaults: any = { transparent: false, options: {}, datasource: null, + title: '', }; export class PanelModel implements DataConfigSource { /* persisted id, used in URL to identify a panel */ - id: number; - editSourceId: number; - gridPos: GridPos; - type: string; - title: string; + id!: number; + editSourceId?: number; + gridPos!: GridPos; + type!: string; + title!: string; alert?: any; scopedVars?: ScopedVars; repeat?: string; @@ -313,11 +314,12 @@ export class PanelModel implements DataConfigSource { this.fieldConfig = restoreCustomOverrideRules(this.fieldConfig, prevOptions.fieldConfig); } - applyPluginOptionDefaults(plugin: PanelPlugin) { + applyPluginOptionDefaults(plugin: PanelPlugin, isAfterPluginChange: boolean) { const options = getPanelOptionsWithDefaults({ plugin, currentOptions: this.options, currentFieldConfig: this.fieldConfig, + isAfterPluginChange: isAfterPluginChange, }); this.fieldConfig = options.fieldConfig; @@ -336,7 +338,7 @@ export class PanelModel implements DataConfigSource { } } - this.applyPluginOptionDefaults(plugin); + this.applyPluginOptionDefaults(plugin, false); this.resendLastResult(); } @@ -389,7 +391,7 @@ export class PanelModel implements DataConfigSource { // For some reason I need to rebind replace variables here, otherwise the viz repeater does not work this.replaceVariables = this.replaceVariables.bind(this); - this.applyPluginOptionDefaults(newPlugin); + this.applyPluginOptionDefaults(newPlugin, true); if (newPlugin.onPanelMigration) { this.pluginVersion = getPluginVersion(newPlugin); diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts index 0d639b39b50..df813193ca7 100644 --- a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts @@ -79,6 +79,7 @@ describe('getPanelOptionsWithDefaults', () => { defaults: {}, overrides: [], }, + isAfterPluginChange: false, }); expect(result).toMatchInlineSnapshot(` @@ -129,6 +130,7 @@ describe('getPanelOptionsWithDefaults', () => { }, overrides: [], }, + isAfterPluginChange: true, }); expect(result).toMatchInlineSnapshot(` @@ -187,6 +189,7 @@ describe('getPanelOptionsWithDefaults', () => { }, overrides: [], }, + isAfterPluginChange: true, }); expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); @@ -223,6 +226,7 @@ describe('getPanelOptionsWithDefaults', () => { }, }, }, + isAfterPluginChange: true, }); expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.Thresholds); }); @@ -357,6 +361,7 @@ interface ScenarioOptions { standardOptions?: Partial>; plugin?: PanelPlugin; options?: any; + isAfterPluginChange?: boolean; } function runScenario(options: ScenarioOptions) { @@ -386,5 +391,6 @@ function runScenario(options: ScenarioOptions) { plugin, currentOptions: options.options || {}, currentFieldConfig: fieldConfig, + isAfterPluginChange: !!options.isAfterPluginChange, }); } diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts index b8caf87c20c..8941a7a4be7 100644 --- a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts @@ -17,6 +17,7 @@ export interface Props { plugin: PanelPlugin; currentFieldConfig: FieldConfigSource; currentOptions: Record; + isAfterPluginChange: boolean; } export interface OptionDefaults { @@ -24,7 +25,12 @@ export interface OptionDefaults { fieldConfig: FieldConfigSource; } -export function getPanelOptionsWithDefaults({ plugin, currentOptions, currentFieldConfig }: Props): OptionDefaults { +export function getPanelOptionsWithDefaults({ + plugin, + currentOptions, + currentFieldConfig, + isAfterPluginChange, +}: Props): OptionDefaults { const optionsWithDefaults = mergeWith( {}, plugin.defaults, @@ -37,7 +43,7 @@ export function getPanelOptionsWithDefaults({ plugin, currentOptions, currentFie ); const fieldConfigWithDefaults = applyFieldConfigDefaults(currentFieldConfig, plugin); - const fieldConfigWithOptimalColorMode = adaptFieldColorMode(plugin, fieldConfigWithDefaults); + const fieldConfigWithOptimalColorMode = adaptFieldColorMode(plugin, fieldConfigWithDefaults, isAfterPluginChange); return { options: optionsWithDefaults, fieldConfig: fieldConfigWithOptimalColorMode }; } @@ -119,7 +125,15 @@ function cleanProperties(obj: any, parentPath: string, fieldConfigRegistry: Fiel } } -function adaptFieldColorMode(plugin: PanelPlugin, fieldConfig: FieldConfigSource): FieldConfigSource { +function adaptFieldColorMode( + plugin: PanelPlugin, + fieldConfig: FieldConfigSource, + isAfterPluginChange: boolean +): FieldConfigSource { + if (!isAfterPluginChange) { + return fieldConfig; + } + // adjust to prefered field color setting if needed const color = plugin.fieldConfigRegistry.getIfExists(FieldConfigProperty.Color); diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts index b32acb97864..2674b32ab38 100644 --- a/public/app/features/dashboard/state/reducers.test.ts +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -4,9 +4,10 @@ import { dashboardInitFetching, dashboardInitSlow, loadDashboardPermissions, + dashboardReducer, + initialState, } from './reducers'; import { DashboardInitPhase, DashboardState, OrgRole, PermissionLevel } from 'app/types'; -import { dashboardReducer, initialState } from './reducers'; import { DashboardModel } from './DashboardModel'; describe('dashboard reducer', () => { diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts index 89f74f48859..cbc6077384d 100644 --- a/public/app/features/dashboard/utils/panel.ts +++ b/public/app/features/dashboard/utils/panel.ts @@ -4,12 +4,10 @@ import store from 'app/core/store'; // Models import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { TimeRange, AppEvents } from '@grafana/data'; +import { TimeRange, AppEvents, rangeUtil, dateMath } from '@grafana/data'; // Utils import { isString as _isString } from 'lodash'; -import { rangeUtil } from '@grafana/data'; -import { dateMath } from '@grafana/data'; import appEvents from 'app/core/app_events'; import config from 'app/core/config'; diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx index f47ba005e05..c8770f550c8 100644 --- a/public/app/features/datasources/DataSourceDashboards.test.tsx +++ b/public/app/features/datasources/DataSourceDashboards.test.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { DataSourceDashboards, Props } from './DataSourceDashboards'; -import { DataSourceSettings } from '@grafana/data'; -import { NavModel } from '@grafana/data'; +import { DataSourceSettings, NavModel } from '@grafana/data'; import { PluginDashboard } from 'app/types'; const setup = (propOverrides?: object) => { diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx index 23cab72315b..1f71b8ad76b 100644 --- a/public/app/features/datasources/DataSourceDashboards.tsx +++ b/public/app/features/datasources/DataSourceDashboards.tsx @@ -17,8 +17,7 @@ import { getDataSource } from './state/selectors'; // Types import { PluginDashboard, StoreState } from 'app/types'; -import { DataSourceSettings } from '@grafana/data'; -import { NavModel } from '@grafana/data'; +import { DataSourceSettings, NavModel } from '@grafana/data'; export interface Props { navModel: NavModel; diff --git a/public/app/features/datasources/DataSourceList.test.tsx b/public/app/features/datasources/DataSourceList.test.tsx index 6e097da2c53..0cbe041b949 100644 --- a/public/app/features/datasources/DataSourceList.test.tsx +++ b/public/app/features/datasources/DataSourceList.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import DataSourcesList from './DataSourcesList'; import { getMockDataSources } from './__mocks__/dataSourcesMocks'; import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; @@ -10,13 +10,20 @@ const setup = () => { layoutMode: LayoutModes.Grid, }; - return shallow(); + return render(); }; -describe('Render', () => { - it('should render component', () => { - const wrapper = setup(); +describe('DataSourcesList', () => { + it('should render list of datasources', () => { + setup(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect(screen.getAllByRole('heading')).toHaveLength(3); + }); - expect(wrapper).toMatchSnapshot(); + it('should render all elements in the list item', () => { + setup(); + expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'dataSource-0 dataSource-0' })).toBeInTheDocument(); + expect(screen.getByAltText('dataSource-0')).toBeInTheDocument(); }); }); diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx index de1e9afa762..6899120df1a 100644 --- a/public/app/features/datasources/DataSourcesList.tsx +++ b/public/app/features/datasources/DataSourcesList.tsx @@ -1,39 +1,50 @@ // Libraries -import React, { PureComponent } from 'react'; -import classNames from 'classnames'; - -// Components -import DataSourcesListItem from './DataSourcesListItem'; +import React, { FC } from 'react'; // Types import { DataSourceSettings } from '@grafana/data'; -import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; +import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; +import { Card, Tag, useStyles } from '@grafana/ui'; +import { css } from 'emotion'; export interface Props { dataSources: DataSourceSettings[]; layoutMode: LayoutMode; } -export class DataSourcesList extends PureComponent { - render() { - const { dataSources, layoutMode } = this.props; +export const DataSourcesList: FC = ({ dataSources, layoutMode }) => { + const styles = useStyles(getStyles); - const listStyle = classNames({ - 'card-section': true, - 'card-list-layout-grid': layoutMode === LayoutModes.Grid, - 'card-list-layout-list': layoutMode === LayoutModes.List, - }); - - return ( -
    -
      - {dataSources.map((dataSource, index) => { - return ; - })} -
    -
    - ); - } -} + return ( +
      + {dataSources.map((dataSource, index) => { + return ( +
    • + + + {dataSource.name} + + + {[ + dataSource.typeName, + dataSource.url, + dataSource.isDefault && , + ]} + + +
    • + ); + })} +
    + ); +}; export default DataSourcesList; + +const getStyles = () => { + return { + list: css` + list-style: none; + `, + }; +}; diff --git a/public/app/features/datasources/DataSourcesListItem.test.tsx b/public/app/features/datasources/DataSourcesListItem.test.tsx deleted file mode 100644 index 138c71cb46a..00000000000 --- a/public/app/features/datasources/DataSourcesListItem.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import DataSourcesListItem from './DataSourcesListItem'; -import { getMockDataSource } from './__mocks__/dataSourcesMocks'; - -const setup = () => { - const props = { - dataSource: getMockDataSource(), - }; - - return shallow(); -}; - -describe('Render', () => { - it('should render component', () => { - const wrapper = setup(); - - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/public/app/features/datasources/DataSourcesListItem.tsx b/public/app/features/datasources/DataSourcesListItem.tsx deleted file mode 100644 index 2ca2bbd64e2..00000000000 --- a/public/app/features/datasources/DataSourcesListItem.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { PureComponent } from 'react'; -import { DataSourceSettings } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; - -export interface Props { - dataSource: DataSourceSettings; -} - -export class DataSourcesListItem extends PureComponent { - render() { - const { dataSource } = this.props; - return ( -
  • - -
    -
    {dataSource.type}
    -
    -
    -
    - {dataSource.name} -
    -
    -
    - {dataSource.name} - {dataSource.isDefault && default} -
    -
    {dataSource.url}
    -
    -
    -
    -
  • - ); - } -} - -export default DataSourcesListItem; diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx index 4d509aa199c..4784bdfda40 100644 --- a/public/app/features/datasources/DataSourcesListPage.tsx +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -49,11 +49,7 @@ const emptyListModel = { export class DataSourcesListPage extends PureComponent { componentDidMount() { - this.fetchDataSources(); - } - - async fetchDataSources() { - return await this.props.loadDataSources(); + this.props.loadDataSources(); } render() { diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts index 0c01224af99..cc3eaa361c1 100644 --- a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts +++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts @@ -3,7 +3,7 @@ import { DataSourceSettings } from '@grafana/data'; export const getMockDataSources = (amount: number) => { const dataSources = []; - for (let i = 0; i <= amount; i++) { + for (let i = 0; i < amount; i++) { dataSources.push({ access: '', basicAuth: false, @@ -37,6 +37,7 @@ export const getMockDataSource = (): DataSourceSettings => { isDefault: false, jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, name: 'gdev-cloudwatch', + typeName: 'Cloudwatch', orgId: 1, password: '', readOnly: false, diff --git a/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap deleted file mode 100644 index 7167f59b048..00000000000 --- a/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap +++ /dev/null @@ -1,108 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render component 1`] = ` -
    -
      - - - - -
    -
    -`; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap deleted file mode 100644 index d051703887e..00000000000 --- a/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render component 1`] = ` -
  • - -
    -
    - cloudwatch -
    -
    -
  • -`; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap index 9a8b1eaabd2..df1135c44fe 100644 --- a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap +++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap @@ -125,25 +125,6 @@ exports[`Render should render action bar and datasources 1`] = ` "url": "", "user": "", }, - Object { - "access": "", - "basicAuth": false, - "database": "database-5", - "id": 5, - "isDefault": false, - "jsonData": Object { - "authType": "credentials", - "defaultRegion": "eu-west-2", - }, - "name": "dataSource-5", - "orgId": 1, - "password": "", - "readOnly": false, - "type": "cloudwatch", - "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", - "url": "", - "user": "", - }, ] } key="list" diff --git a/public/app/features/datasources/mocks.ts b/public/app/features/datasources/mocks.ts index d2cc7be296a..bc4f9d0cddc 100644 --- a/public/app/features/datasources/mocks.ts +++ b/public/app/features/datasources/mocks.ts @@ -7,6 +7,7 @@ export function createDatasourceSettings(jsonData: T): DataSourceSettings name: 'datasource-test', typeLogoUrl: '', type: 'datasource', + typeName: 'Datasource', access: 'server', url: 'http://localhost', password: '', diff --git a/public/app/features/datasources/settings/CloudInfoBox.tsx b/public/app/features/datasources/settings/CloudInfoBox.tsx index 6a2107c4f3e..28ee4730f28 100644 --- a/public/app/features/datasources/settings/CloudInfoBox.tsx +++ b/public/app/features/datasources/settings/CloudInfoBox.tsx @@ -1,9 +1,12 @@ import { DataSourceSettings, GrafanaTheme } from '@grafana/data'; -import { DismissableFeatureInfoBox, useStyles } from '@grafana/ui'; +import { FeatureInfoBox, useStyles } from '@grafana/ui'; import { css } from 'emotion'; import React, { FC } from 'react'; import { config } from 'app/core/config'; import { GrafanaEdition } from '@grafana/data/src/types/config'; +import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; + +const LOCAL_STORAGE_KEY = 'datasources.settings.cloudInfoBox.isDismissed'; export interface Props { dataSource: DataSourceSettings; @@ -38,27 +41,38 @@ export const CloudInfoBox: FC = ({ dataSource }) => { } return ( - -
    - Or skip the effort and get {mainDS} (and {extraDS}) as fully managed, scalable and hosted data sources from - Grafana Labs with the{' '} - - free-forever Grafana Cloud plan - - . -
    -
    + storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> + {(isDismissed, onDismiss) => { + if (isDismissed) { + return null; + } + return ( + { + onDismiss(true); + }} + > +
    + Or skip the effort and get {mainDS} (and {extraDS}) as fully managed, scalable and hosted data sources + from Grafana Labs with the{' '} + + free-forever Grafana Cloud plan + + . +
    +
    + ); + }} + ); }; diff --git a/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap index 831c65adead..01fdf2dd1db 100644 --- a/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap +++ b/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap @@ -48,6 +48,7 @@ exports[`Render should render alpha info text 1`] = ` "secureJsonFields": Object {}, "type": "cloudwatch", "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "typeName": "Cloudwatch", "url": "", "user": "", "withCredentials": false, @@ -81,6 +82,7 @@ exports[`Render should render alpha info text 1`] = ` "secureJsonFields": Object {}, "type": "cloudwatch", "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "typeName": "Cloudwatch", "url": "", "user": "", "withCredentials": false, @@ -198,6 +200,7 @@ exports[`Render should render beta info text 1`] = ` "secureJsonFields": Object {}, "type": "cloudwatch", "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "typeName": "Cloudwatch", "url": "", "user": "", "withCredentials": false, @@ -257,6 +260,7 @@ exports[`Render should render component 1`] = ` "secureJsonFields": Object {}, "type": "cloudwatch", "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "typeName": "Cloudwatch", "url": "", "user": "", "withCredentials": false, @@ -321,6 +325,7 @@ exports[`Render should render is ready only message 1`] = ` "secureJsonFields": Object {}, "type": "cloudwatch", "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "typeName": "Cloudwatch", "url": "", "user": "", "withCredentials": false, @@ -354,6 +359,7 @@ exports[`Render should render is ready only message 1`] = ` "secureJsonFields": Object {}, "type": "cloudwatch", "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "typeName": "Cloudwatch", "url": "", "user": "", "withCredentials": false, diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts index 7a88341babb..479d7a6968a 100644 --- a/public/app/features/datasources/state/navModel.ts +++ b/public/app/features/datasources/state/navModel.ts @@ -83,6 +83,7 @@ export function getDataSourceLoadingNav(pageName: string): NavModel { password: '', readOnly: false, type: 'Loading', + typeName: 'Loading', typeLogoUrl: 'public/img/icn-datasource.svg', url: '', user: '', diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts index 7c135185210..3f8094a8aa7 100644 --- a/public/app/features/datasources/state/reducers.test.ts +++ b/public/app/features/datasources/state/reducers.test.ts @@ -40,7 +40,7 @@ const mockPlugin = () => describe('dataSourcesReducer', () => { describe('when dataSourcesLoaded is dispatched', () => { it('then state should be correct', () => { - const dataSources = getMockDataSources(0); + const dataSources = getMockDataSources(1); reducerTester() .givenReducer(dataSourcesReducer, initialState) diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 3581c7c99d1..921127e870e 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -22,16 +22,7 @@ const dummyProps: ExploreProps = { datasourceMissing: false, exploreId: ExploreId.left, loading: false, - initializeExplore: jest.fn(), - initialized: true, modifyQueries: jest.fn(), - update: { - datasource: false, - queries: false, - range: false, - mode: false, - }, - refreshExplore: jest.fn(), scanning: false, scanRange: { from: '0', @@ -40,18 +31,7 @@ const dummyProps: ExploreProps = { scanStart: jest.fn(), scanStopAction: scanStopAction, setQueries: jest.fn(), - split: false, queryKeys: [], - initialDatasource: 'test', - initialQueries: [], - initialRange: { - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { - from: 'now-6h', - to: 'now', - }, - }, isLive: false, syncedTimes: false, updateTimeRange: jest.fn(), diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 842c36c3c3e..fcbbea177b5 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -15,36 +15,24 @@ import { LoadingState, PanelData, RawTimeRange, - TimeRange, TimeZone, - ExploreUrlState, LogsModel, - EventBusExtended, - EventBusSrv, TraceViewData, DataFrame, } from '@grafana/data'; -import store from 'app/core/store'; import LogsContainer from './LogsContainer'; import QueryRows from './QueryRows'; import TableContainer from './TableContainer'; import RichHistoryContainer from './RichHistory/RichHistoryContainer'; import ExploreQueryInspector from './ExploreQueryInspector'; import { splitOpen } from './state/main'; -import { changeSize, initializeExplore, refreshExplore } from './state/explorePane'; +import { changeSize } from './state/explorePane'; import { updateTimeRange } from './state/time'; import { scanStopAction, addQueryRow, modifyQueries, setQueries, scanStart } from './state/query'; -import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types/explore'; +import { ExploreId, ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types'; -import { - DEFAULT_RANGE, - ensureQueries, - getFirstNonQueryRowSpecificError, - getTimeRange, - getTimeRangeFromUrl, - lastUsedDatasourceKeyForOrgId, -} from 'app/core/utils/explore'; +import { getFirstNonQueryRowSpecificError } from 'app/core/utils/explore'; import { ExploreToolbar } from './ExploreToolbar'; import { NoDataSourceCallToAction } from './NoDataSourceCallToAction'; import { getTimeZone } from '../profile/state/selectors'; @@ -83,21 +71,13 @@ export interface ExploreProps { datasourceInstance: DataSourceApi | null; datasourceMissing: boolean; exploreId: ExploreId; - initializeExplore: typeof initializeExplore; - initialized: boolean; modifyQueries: typeof modifyQueries; - update: ExploreUpdateState; - refreshExplore: typeof refreshExplore; scanning?: boolean; scanRange?: RawTimeRange; scanStart: typeof scanStart; scanStopAction: typeof scanStopAction; setQueries: typeof setQueries; - split: boolean; queryKeys: string[]; - initialDatasource: string; - initialQueries: DataQuery[]; - initialRange: TimeRange; isLive: boolean; syncedTimes: boolean; updateTimeRange: typeof updateTimeRange; @@ -153,47 +133,13 @@ interface ExploreState { * `format`, to indicate eventual transformations by the datasources' result transformers. */ export class Explore extends React.PureComponent { - el: any; - exploreEvents: EventBusExtended; - constructor(props: ExploreProps) { super(props); - this.exploreEvents = new EventBusSrv(); this.state = { openDrawer: undefined, }; } - componentDidMount() { - const { initialized, exploreId, initialDatasource, initialQueries, initialRange, originPanelId } = this.props; - const width = this.el ? this.el.offsetWidth : 0; - - // initialize the whole explore first time we mount and if browser history contains a change in datasource - if (!initialized) { - this.props.initializeExplore( - exploreId, - initialDatasource, - initialQueries, - initialRange, - width, - this.exploreEvents, - originPanelId - ); - } - } - - componentWillUnmount() { - this.exploreEvents.removeAllListeners(); - } - - componentDidUpdate(prevProps: ExploreProps) { - this.refreshExplore(); - } - - getRef = (el: any) => { - this.el = el; - }; - onChangeTime = (rawRange: RawTimeRange) => { const { updateTimeRange, exploreId } = this.props; updateTimeRange({ exploreId, rawRange }); @@ -271,14 +217,6 @@ export class Explore extends React.PureComponent { }); }; - refreshExplore = () => { - const { exploreId, update } = this.props; - - if (update.queries || update.range || update.datasource || update.mode) { - this.props.refreshExplore(exploreId); - } - }; - renderEmptyState() { return (
    @@ -367,7 +305,6 @@ export class Explore extends React.PureComponent { datasourceInstance, datasourceMissing, exploreId, - split, queryKeys, graphResult, queryResponse, @@ -380,7 +317,6 @@ export class Explore extends React.PureComponent { showNodeGraph, } = this.props; const { openDrawer } = this.state; - const exploreClass = split ? 'explore explore-split' : 'explore'; const styles = getStyles(theme); const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted; @@ -393,13 +329,13 @@ export class Explore extends React.PureComponent { const showQueryInspector = openDrawer === ExploreDrawer.QueryInspector; return ( -
    + <> {datasourceMissing ? this.renderEmptyState() : null} {datasourceInstance && (
    - + {
    )} -
    + ); } } -const ensureQueriesMemoized = memoizeOne(ensureQueries); -const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl); - function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partial { const explore = state.explore; - const { split, syncedTimes } = explore; - const item: ExploreItemState = explore[exploreId]; + const { syncedTimes } = explore; + const item: ExploreItemState = explore[exploreId]!; const timeZone = getTimeZone(state.user); const { datasourceInstance, datasourceMissing, - initialized, queryKeys, - urlState, - update, isLive, graphResult, logsResult, @@ -485,29 +415,15 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia loading, } = item; - const { datasource, queries, range: urlRange, originPanelId } = (urlState || {}) as ExploreUrlState; - const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId)); - const initialQueries: DataQuery[] = ensureQueriesMemoized(queries); - const initialRange = urlRange - ? getTimeRangeFromUrlMemoized(urlRange, timeZone) - : getTimeRange(timeZone, DEFAULT_RANGE); - return { datasourceInstance, datasourceMissing, - initialized, - split, queryKeys, - update, - initialDatasource, - initialQueries, - initialRange, isLive, graphResult, logsResult: logsResult ?? undefined, absoluteRange, queryResponse, - originPanelId, syncedTimes, timeZone, showLogs, @@ -521,9 +437,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia const mapDispatchToProps: Partial = { changeSize, - initializeExplore, modifyQueries, - refreshExplore, scanStart, scanStopAction, setQueries, diff --git a/public/app/features/explore/ExploreGraphNGPanel.tsx b/public/app/features/explore/ExploreGraphNGPanel.tsx index 7c9ac428af1..858528d7acc 100644 --- a/public/app/features/explore/ExploreGraphNGPanel.tsx +++ b/public/app/features/explore/ExploreGraphNGPanel.tsx @@ -129,14 +129,10 @@ export function ExploreGraphNGPanel({ legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }} timeZone={timeZone} > - - - {annotations ? ( - - ) : ( - <> - )} + + + {annotations && } diff --git a/public/app/features/explore/ExplorePaneContainer.tsx b/public/app/features/explore/ExplorePaneContainer.tsx new file mode 100644 index 00000000000..fe55a4cb3b8 --- /dev/null +++ b/public/app/features/explore/ExplorePaneContainer.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { hot } from 'react-hot-loader'; +import { compose } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; +import memoizeOne from 'memoize-one'; +import { withTheme } from '@grafana/ui'; +import { DataQuery, ExploreUrlState, EventBusExtended, EventBusSrv } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import store from 'app/core/store'; +import { lastSavedUrl, cleanupPaneAction } from './state/main'; +import { initializeExplore, refreshExplore } from './state/explorePane'; +import { ExploreId } from 'app/types/explore'; +import { StoreState } from 'app/types'; +import { + DEFAULT_RANGE, + ensureQueries, + getTimeRange, + getTimeRangeFromUrl, + lastUsedDatasourceKeyForOrgId, + parseUrlState, +} from 'app/core/utils/explore'; +import { getTimeZone } from '../profile/state/selectors'; +import Explore from './Explore'; + +type PropsFromRedux = ConnectedProps; +interface Props extends PropsFromRedux { + exploreId: ExploreId; + split: boolean; +} + +/** + * This component is responsible for handling initialization of an Explore pane and triggering synchronization + * of state based on URL changes and preventing any infinite loops. + */ +export class ExplorePaneContainerUnconnected extends React.PureComponent> { + el: any; + exploreEvents: EventBusExtended; + + constructor(props: Props) { + super(props); + this.exploreEvents = new EventBusSrv(); + this.state = { + openDrawer: undefined, + }; + } + + componentDidMount() { + const { initialized, exploreId, initialDatasource, initialQueries, initialRange, originPanelId } = this.props; + const width = this.el?.offsetWidth ?? 0; + + // initialize the whole explore first time we mount and if browser history contains a change in datasource + if (!initialized) { + this.props.initializeExplore( + exploreId, + initialDatasource, + initialQueries, + initialRange, + width, + this.exploreEvents, + originPanelId + ); + } + } + + componentWillUnmount() { + this.exploreEvents.removeAllListeners(); + this.props.cleanupPaneAction({ exploreId: this.props.exploreId }); + } + + componentDidUpdate(prevProps: Props) { + this.refreshExplore(prevProps.urlQuery); + } + + refreshExplore = (prevUrlQuery: string) => { + const { exploreId, urlQuery } = this.props; + + // Update state from url only if it changed and only if the change wasn't initialised by redux to prevent any loops + if (urlQuery !== prevUrlQuery && urlQuery !== lastSavedUrl[exploreId]) { + this.props.refreshExplore(exploreId, urlQuery); + } + }; + + getRef = (el: any) => { + this.el = el; + }; + + render() { + const exploreClass = this.props.split ? 'explore explore-split' : 'explore'; + return ( +
    + {this.props.initialized && } +
    + ); + } +} + +const ensureQueriesMemoized = memoizeOne(ensureQueries); +const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl); + +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { + const urlQuery = state.location.query[exploreId] as string; + const urlState = parseUrlState(urlQuery); + const timeZone = getTimeZone(state.user); + + const { datasource, queries, range: urlRange, originPanelId } = (urlState || {}) as ExploreUrlState; + const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId)); + const initialQueries: DataQuery[] = ensureQueriesMemoized(queries); + const initialRange = urlRange + ? getTimeRangeFromUrlMemoized(urlRange, timeZone) + : getTimeRange(timeZone, DEFAULT_RANGE); + + return { + initialized: state.explore[exploreId]?.initialized, + initialDatasource, + initialQueries, + initialRange, + originPanelId, + urlQuery, + }; +} + +const mapDispatchToProps = { + initializeExplore, + refreshExplore, + cleanupPaneAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export const ExplorePaneContainer = compose(hot(module), connector, withTheme)(ExplorePaneContainerUnconnected); diff --git a/public/app/features/explore/ExploreQueryInspector.tsx b/public/app/features/explore/ExploreQueryInspector.tsx index ef797422e77..824f8221407 100644 --- a/public/app/features/explore/ExploreQueryInspector.tsx +++ b/public/app/features/explore/ExploreQueryInspector.tsx @@ -172,7 +172,7 @@ export function ExploreQueryInspector(props: Props) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { const explore = state.explore; - const item: ExploreItemState = explore[exploreId]; + const item: ExploreItemState = explore[exploreId]!; const { loading, queryResponse } = item; return { diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 90d34da3da1..c57ffeda9f1 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -21,6 +21,7 @@ import { RunButton } from './RunButton'; import { LiveTailControls } from './useLiveTailControls'; import { cancelQueries, clearQueries, runQueries } from './state/query'; import ReturnToDashboardButton from './ReturnToDashboardButton'; +import { isSplit } from './state/selectors'; interface OwnProps { exploreId: ExploreId; @@ -127,7 +128,12 @@ export class UnConnectedExploreToolbar extends PureComponent { )}
    {splitted && ( - closeSplit(exploreId)} name="times" /> + closeSplit(exploreId)} + name="times" + /> )}
    @@ -227,9 +233,8 @@ export class UnConnectedExploreToolbar extends PureComponent { } const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => { - const splitted = state.explore.split; const syncedTimes = state.explore.syncedTimes; - const exploreItem: ExploreItemState = state.explore[exploreId]; + const exploreItem: ExploreItemState = state.explore[exploreId]!; const { datasourceInstance, datasourceMissing, @@ -249,7 +254,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps loading, range, timeZone: getTimeZone(state.user), - splitted, + splitted: isSplit(state), refreshInterval, hasLiveOption, isLive, diff --git a/public/app/features/explore/NodeGraphContainer.tsx b/public/app/features/explore/NodeGraphContainer.tsx index b289d84457e..3a06af9f699 100644 --- a/public/app/features/explore/NodeGraphContainer.tsx +++ b/public/app/features/explore/NodeGraphContainer.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { Badge, NodeGraph } from '@grafana/ui'; +import { Badge, NodeGraph, Collapse } from '@grafana/ui'; import { DataFrame, TimeRange } from '@grafana/data'; import { ExploreId, StoreState } from '../../types'; import { splitOpen } from './state/main'; import { connect, ConnectedProps } from 'react-redux'; -import { Collapse } from '@grafana/ui'; import { useLinks } from './utils/links'; interface Props { @@ -37,7 +36,7 @@ export function UnconnectedNodeGraphContainer(props: Props & ConnectedProps { const props: QueryRowProps = { diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx index a0c5caf3851..1ef490558bc 100644 --- a/public/app/features/explore/QueryRow.tsx +++ b/public/app/features/explore/QueryRow.tsx @@ -31,7 +31,6 @@ import { HelpToggle } from '../query/components/HelpToggle'; interface PropsFromParent { exploreId: ExploreId; index: number; - exploreEvents: EventBusExtended; } export interface QueryRowProps extends PropsFromParent { @@ -49,6 +48,7 @@ export interface QueryRowProps extends PropsFromParent { runQueries: typeof runQueries; queryResponse: PanelData; latency: number; + exploreEvents: EventBusExtended; } interface QueryRowState { @@ -201,8 +201,8 @@ export class QueryRow extends PureComponent { function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps) { const explore = state.explore; - const item: ExploreItemState = explore[exploreId]; - const { datasourceInstance, history, queries, range, absoluteRange, queryResponse, latency } = item; + const item: ExploreItemState = explore[exploreId]!; + const { datasourceInstance, history, queries, range, absoluteRange, queryResponse, latency, eventBridge } = item; const query = queries[index]; return { @@ -213,6 +213,7 @@ function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps) absoluteRange, queryResponse, latency, + exploreEvents: eventBridge, }; } diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 264131f44ef..1957263119a 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -5,23 +5,21 @@ import React, { PureComponent } from 'react'; import QueryRow from './QueryRow'; // Types -import { EventBusExtended } from '@grafana/data'; import { ExploreId } from 'app/types/explore'; interface QueryRowsProps { className?: string; - exploreEvents: EventBusExtended; exploreId: ExploreId; queryKeys: string[]; } export default class QueryRows extends PureComponent { render() { - const { className = '', exploreEvents, exploreId, queryKeys } = this.props; + const { className = '', exploreId, queryKeys } = this.props; return (
    {queryKeys.map((key, index) => { - return ; + return ; })}
    ); diff --git a/public/app/features/explore/ReturnToDashboardButton.tsx b/public/app/features/explore/ReturnToDashboardButton.tsx index c1c3a4f8f3e..3d21a8ceca2 100644 --- a/public/app/features/explore/ReturnToDashboardButton.tsx +++ b/public/app/features/explore/ReturnToDashboardButton.tsx @@ -10,6 +10,7 @@ import { StoreState } from 'app/types'; import { ExploreId } from 'app/types/explore'; import { updateLocation } from 'app/core/actions'; import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers'; +import { isSplit } from './state/selectors'; interface Props { exploreId: ExploreId; @@ -83,8 +84,8 @@ export const UnconnectedReturnToDashboardButton: FC = ({ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { const explore = state.explore; - const splitted = state.explore.split; - const { datasourceInstance, queries, originPanelId } = explore[exploreId]; + const splitted = isSplit(state); + const { datasourceInstance, queries, originPanelId } = explore[exploreId]!; return { exploreId, diff --git a/public/app/features/explore/RichHistory/RichHistory.test.tsx b/public/app/features/explore/RichHistory/RichHistory.test.tsx index 13a45c75fba..92ea6b49539 100644 --- a/public/app/features/explore/RichHistory/RichHistory.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.test.tsx @@ -2,8 +2,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { GrafanaTheme } from '@grafana/data'; import { ExploreId } from '../../../types/explore'; -import { RichHistory, RichHistoryProps } from './RichHistory'; -import { Tabs } from './RichHistory'; +import { RichHistory, RichHistoryProps, Tabs } from './RichHistory'; import { Tab } from '@grafana/ui'; jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx index 5cb34bcc65d..f277cd90e08 100644 --- a/public/app/features/explore/RichHistory/RichHistory.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -3,12 +3,11 @@ import React, { PureComponent } from 'react'; //Services & Utils import { RICH_HISTORY_SETTING_KEYS, SortOrder } from 'app/core/utils/richHistory'; import store from 'app/core/store'; -import { withTheme, TabbedContainer, TabConfig } from '@grafana/ui'; +import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui'; //Types import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { SelectableValue } from '@grafana/data'; -import { Themeable } from '@grafana/ui'; //Components import { RichHistorySettings } from './RichHistorySettings'; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index f137f88dea8..ad4d080aee6 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -5,7 +5,7 @@ import { css, cx } from 'emotion'; import { stylesFactory, useTheme, TextArea, Button, IconButton } from '@grafana/ui'; import { getDataSourceSrv } from '@grafana/runtime'; import { GrafanaTheme, AppEvents, DataSourceApi } from '@grafana/data'; -import { RichHistoryQuery, ExploreId, ExploreItemState } from 'app/types/explore'; +import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { copyStringToClipboard } from 'app/core/utils/explore'; @@ -313,9 +313,7 @@ export function RichHistoryCard(props: Props) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { const explore = state.explore; - const { datasourceInstance } = explore[exploreId]; - // @ts-ignore - const item: ExploreItemState = explore[exploreId]; + const { datasourceInstance } = explore[exploreId]!; return { exploreId, datasourceInstance, diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx index 43cfd5ce982..59e5f3b09fa 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -6,7 +6,7 @@ import { uniqBy, debounce } from 'lodash'; import { RichHistoryQuery, ExploreId } from 'app/types/explore'; // Utils -import { stylesFactory, useTheme } from '@grafana/ui'; +import { stylesFactory, useTheme, RangeSlider, Select } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { @@ -20,7 +20,6 @@ import { // Components import RichHistoryCard from './RichHistoryCard'; import { sortOrderOptions } from './RichHistory'; -import { RangeSlider, Select } from '@grafana/ui'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; export interface Props { diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx index c0ef21625f0..fcf808470c7 100644 --- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx @@ -6,14 +6,13 @@ import { uniqBy, debounce } from 'lodash'; import { RichHistoryQuery, ExploreId } from 'app/types/explore'; // Utils -import { stylesFactory, useTheme } from '@grafana/ui'; +import { stylesFactory, useTheme, Select } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { filterAndSortQueries, createDatasourcesList, SortOrder } from 'app/core/utils/richHistory'; // Components import RichHistoryCard from './RichHistoryCard'; import { sortOrderOptions } from './RichHistory'; -import { Select } from '@grafana/ui'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; export interface Props { diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 4c4af3e7c56..30e2e298cde 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -3,7 +3,6 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import Wrapper from './Wrapper'; import { configureStore } from '../../store/configureStore'; import { Provider } from 'react-redux'; -import { store } from '../../store/store'; import { setDataSourceSrv } from '@grafana/runtime'; import { ArrayDataFrame, @@ -22,6 +21,9 @@ import { updateLocation } from '../../core/reducers/location'; import { LokiDatasource } from '../../plugins/datasource/loki/datasource'; import { LokiQuery } from '../../plugins/datasource/loki/types'; import { fromPairs } from 'lodash'; +import { EnhancedStore } from '@reduxjs/toolkit'; +import userEvent from '@testing-library/user-event'; +import { splitOpen } from './state/main'; type Mock = jest.Mock; @@ -42,7 +44,7 @@ describe('Wrapper', () => { }); it('inits url and renders editor but does not call query on empty url', async () => { - const { datasources } = setup(); + const { datasources, store } = setup(); // Wait for rendering the editor await screen.findByText(/Editor/i); @@ -57,7 +59,7 @@ describe('Wrapper', () => { it('runs query when url contains query and renders results', async () => { const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; - const { datasources } = setup({ query }); + const { datasources, store } = setup({ query }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Make sure we render the logs panel @@ -90,7 +92,7 @@ describe('Wrapper', () => { it('handles url change and runs the new query', async () => { const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; - const { datasources } = setup({ query }); + const { datasources, store } = setup({ query }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Wait for rendering the logs await screen.findByText(/custom log line/i); @@ -111,7 +113,7 @@ describe('Wrapper', () => { it('handles url change and runs the new query with different datasource', async () => { const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; - const { datasources } = setup({ query }); + const { datasources, store } = setup({ query }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Wait for rendering the logs await screen.findByText(/custom log line/i); @@ -133,7 +135,7 @@ describe('Wrapper', () => { it('handles changing the datasource manually', async () => { const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) }; - const { datasources } = setup({ query }); + const { datasources, store } = setup({ query }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Wait for rendering the editor await screen.findByText(/Editor/i); @@ -147,15 +149,15 @@ describe('Wrapper', () => { }); }); - it('opens the split pane', async () => { - const { datasources } = setup(); + it('opens the split pane when split button is clicked', async () => { + setup(); // Wait for rendering the editor const splitButton = await screen.findByText(/split/i); fireEvent.click(splitButton); - const editors = await screen.findAllByText('loki Editor input:'); - - expect(editors.length).toBe(2); - expect(datasources.loki.query).not.toBeCalled(); + await waitFor(() => { + const editors = screen.getAllByText('loki Editor input:'); + expect(editors.length).toBe(2); + }); }); it('inits with two panes if specified in url', async () => { @@ -164,7 +166,7 @@ describe('Wrapper', () => { right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]), }; - const { datasources } = setup({ query }); + const { datasources, store } = setup({ query }); (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); (datasources.elastic.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); @@ -199,6 +201,65 @@ describe('Wrapper', () => { targets: [{ expr: 'error' }], }); }); + + it('can close a pane from a split', async () => { + const query = { + left: JSON.stringify(['now-1h', 'now', 'loki', {}]), + right: JSON.stringify(['now-1h', 'now', 'elastic', {}]), + }; + setup({ query }); + const closeButtons = await screen.findAllByTitle(/Close split pane/i); + userEvent.click(closeButtons[1]); + + await waitFor(() => { + const logsPanels = screen.queryAllByTitle(/Close split pane/i); + expect(logsPanels.length).toBe(0); + }); + }); + + it('handles url change to split view', async () => { + const query = { + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + }; + const { datasources, store } = setup({ query }); + (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); + (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); + + store.dispatch( + updateLocation({ + path: '/explore', + query: { + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + right: JSON.stringify(['now-1h', 'now', 'elastic', { expr: 'error' }]), + }, + }) + ); + + // Editor renders the new query + await screen.findByText(`loki Editor input: { label="value"}`); + await screen.findByText(`elastic Editor input: error`); + }); + + it('handles opening split with split open func', async () => { + const query = { + left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]), + }; + const { datasources, store } = setup({ query }); + (datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse()); + (datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse()); + + // This is mainly to wait for render so that the left pane state is initialized as that is needed for splitOpen + // to work + await screen.findByText(`loki Editor input: { label="value"}`); + + store.dispatch( + splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any + ); + + // Editor renders the new query + await screen.findByText(`elastic Editor input: error`); + await screen.findByText(`loki Editor input: { label="value"}`); + }); }); type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi }; @@ -206,7 +267,7 @@ type SetupOptions = { datasources?: DatasourceSetup[]; query?: any; }; -function setup(options?: SetupOptions): { datasources: { [name: string]: DataSourceApi } } { +function setup(options?: SetupOptions): { datasources: { [name: string]: DataSourceApi }; store: EnhancedStore } { // Clear this up otherwise it persists data source selection // TODO: probably add test for that too window.localStorage.clear(); @@ -238,15 +299,18 @@ function setup(options?: SetupOptions): { datasources: { [name: string]: DataSou }, } as any); - configureStore(); + const store = configureStore(); store.getState().user = { orgId: 1, timeZone: 'utc', }; + store.getState().location.path = '/explore'; if (options?.query) { - // We have to dispatch cause right now we take the url state from the action not from the store - store.dispatch(updateLocation({ query: options.query, path: '/explore' })); + store.getState().location = { + ...store.getState().location, + query: options.query, + }; } render( @@ -254,7 +318,7 @@ function setup(options?: SetupOptions): { datasources: { [name: string]: DataSou ); - return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])) }; + return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store }; } function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup { diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index eac32ca2f2e..4925e04e8d0 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -6,9 +6,9 @@ import { StoreState } from 'app/types'; import { ExploreId } from 'app/types/explore'; import { CustomScrollbar, ErrorBoundaryAlert } from '@grafana/ui'; -import { resetExploreAction, richHistoryUpdatedAction } from './state/main'; -import Explore from './Explore'; +import { lastSavedUrl, resetExploreAction, richHistoryUpdatedAction } from './state/main'; import { getRichHistory } from '../../core/utils/richHistory'; +import { ExplorePaneContainer } from './ExplorePaneContainer'; interface WrapperProps { split: boolean; @@ -22,6 +22,9 @@ export class Wrapper extends Component { } componentDidMount() { + lastSavedUrl.left = undefined; + lastSavedUrl.right = undefined; + const richHistory = getRichHistory(); this.props.richHistoryUpdatedAction({ richHistory }); } @@ -34,11 +37,11 @@ export class Wrapper extends Component {
    - + {split && ( - + )}
    @@ -49,8 +52,11 @@ export class Wrapper extends Component { } const mapStateToProps = (state: StoreState) => { - const { split } = state.explore; - return { split }; + // Here we use URL to say if we should split or not which is different than in other places. Reason is if we change + // the URL first there is no internal state saying we should split. So this triggers render of ExplorePaneContainer + // and initialisation of each pane state. + const isUrlSplit = Boolean(state.location.query[ExploreId.left] && state.location.query[ExploreId.right]); + return { split: isUrlSplit }; }; const mapDispatchToProps = { diff --git a/public/app/features/explore/__snapshots__/Explore.test.tsx.snap b/public/app/features/explore/__snapshots__/Explore.test.tsx.snap index 501de41bab2..5898b8cd187 100644 --- a/public/app/features/explore/__snapshots__/Explore.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Explore.test.tsx.snap @@ -1,10 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Explore should render component 1`] = ` -
    + @@ -47,5 +36,5 @@ exports[`Explore should render component 1`] = `
    -
    + `; diff --git a/public/app/features/explore/state/datasource.ts b/public/app/features/explore/state/datasource.ts index f48024768ba..e6256e8d100 100644 --- a/public/app/features/explore/state/datasource.ts +++ b/public/app/features/explore/state/datasource.ts @@ -8,7 +8,7 @@ import { ExploreItemState, ThunkResult } from 'app/types'; import { ExploreId } from 'app/types/explore'; import { importQueries, runQueries } from './query'; import { changeRefreshInterval } from './time'; -import { createEmptyQueryResponse, loadAndInitDatasource, makeInitialUpdateState } from './utils'; +import { createEmptyQueryResponse, loadAndInitDatasource } from './utils'; // // Actions and Payloads @@ -41,7 +41,7 @@ export function changeDatasource( return async (dispatch, getState) => { const orgId = getState().user.orgId; const { history, instance } = await loadAndInitDatasource(orgId, datasourceName); - const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance; + const currentDataSourceInstance = getState().explore[exploreId]!.datasourceInstance; dispatch( updateDatasourceInstanceAction({ @@ -51,13 +51,13 @@ export function changeDatasource( }) ); - const queries = getState().explore[exploreId].queries; + const queries = getState().explore[exploreId]!.queries; if (options?.importQueries) { await dispatch(importQueries(exploreId, queries, currentDataSourceInstance, instance)); } - if (getState().explore[exploreId].isLive) { + if (getState().explore[exploreId]!.isLive) { dispatch(changeRefreshInterval(exploreId, RefreshPicker.offOption.value)); } @@ -97,11 +97,9 @@ export const datasourceReducer = (state: ExploreItemState, action: AnyAction): E queryResponse: createEmptyQueryResponse(), loading: false, queryKeys: [], - originPanelId: state.urlState && state.urlState.originPanelId, history, datasourceMissing: false, logsHighlighterExpressions: undefined, - update: makeInitialUpdateState(), }; } diff --git a/public/app/features/explore/state/explorePane.test.ts b/public/app/features/explore/state/explorePane.test.ts index 737ae53ea69..6b2d28e7f8d 100644 --- a/public/app/features/explore/state/explorePane.test.ts +++ b/public/app/features/explore/state/explorePane.test.ts @@ -1,18 +1,9 @@ -import { PayloadAction } from '@reduxjs/toolkit'; -import { DataQuery, DefaultTimeZone, EventBusExtended, ExploreUrlState, LogsDedupStrategy, toUtc } from '@grafana/data'; -import { ExploreId, ExploreItemState, ExploreUpdateState } from 'app/types'; -import { thunkTester } from 'test/core/thunk/thunkTester'; -import { - changeDedupStrategyAction, - initializeExploreAction, - InitializeExplorePayload, - paneReducer, - refreshExplore, -} from './explorePane'; -import { setQueriesAction } from './query'; -import { makeExplorePaneState, makeInitialUpdateState } from './utils'; -import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { DataQuery, DefaultTimeZone, EventBusExtended, serializeStateToUrlParam, toUtc } from '@grafana/data'; +import { ExploreId } from 'app/types'; +import { refreshExplore } from './explorePane'; import { setDataSourceSrv } from '@grafana/runtime'; +import { configureStore } from '../../../store/configureStore'; +import { of } from 'rxjs'; jest.mock('../../dashboard/services/TimeSrv', () => ({ getTimeSrv: jest.fn().mockReturnValue({ @@ -30,134 +21,125 @@ const testRange = { }, }; -setDataSourceSrv({ - getList() { - return []; +const defaultInitialState = { + user: { + orgId: '1', + timeZone: DefaultTimeZone, }, - getInstanceSettings(name: string) { - return { name: 'hello' }; - }, - get() { - return Promise.resolve({ - testDatasource: jest.fn(), - init: jest.fn(), - }); - }, -} as any); - -const setup = (updateOverides?: Partial) => { - const exploreId = ExploreId.left; - const containerWidth = 1920; - const eventBridge = {} as EventBusExtended; - const timeZone = DefaultTimeZone; - const range = testRange; - const urlState: ExploreUrlState = { - datasource: 'some-datasource', - queries: [], - range: range.raw, - }; - const updateDefaults = makeInitialUpdateState(); - const update = { ...updateDefaults, ...updateOverides }; - const initialState = { - user: { - orgId: '1', - timeZone, - }, - explore: { - [exploreId]: { - initialized: true, - urlState, - containerWidth, - eventBridge, - update, - datasourceInstance: { name: 'some-datasource' }, - queries: [] as DataQuery[], - range, - refreshInterval: { - label: 'Off', - value: 0, - }, + explore: { + [ExploreId.left]: { + initialized: true, + containerWidth: 1920, + eventBridge: {} as EventBusExtended, + queries: [] as DataQuery[], + range: testRange, + refreshInterval: { + label: 'Off', + value: 0, }, }, - }; - - return { - initialState, - exploreId, - range, - containerWidth, - eventBridge, - }; + }, }; +function setupStore(state?: any) { + return configureStore({ + ...defaultInitialState, + explore: { + [ExploreId.left]: { + ...defaultInitialState.explore[ExploreId.left], + ...(state || {}), + }, + }, + } as any); +} + +function setup(state?: any) { + const datasources: Record = { + newDs: { + testDatasource: jest.fn(), + init: jest.fn(), + query: jest.fn(), + name: 'newDs', + meta: { id: 'newDs' }, + }, + someDs: { + testDatasource: jest.fn(), + init: jest.fn(), + query: jest.fn(), + name: 'someDs', + meta: { id: 'someDs' }, + }, + }; + + setDataSourceSrv({ + getList() { + return Object.values(datasources).map((d) => ({ name: d.name })); + }, + getInstanceSettings(name: string) { + return { name: 'hello' }; + }, + get(name?: string) { + return Promise.resolve( + name + ? datasources[name] + : { + testDatasource: jest.fn(), + init: jest.fn(), + name: 'default', + } + ); + }, + } as any); + + const store = setupStore({ + datasourceInstance: datasources.someDs, + ...(state || {}), + }); + + return { + store, + datasources, + }; +} + describe('refreshExplore', () => { - describe('when explore is initialized', () => { - describe('and update datasource is set', () => { - it('then it should dispatch initializeExplore', async () => { - const { exploreId, initialState, containerWidth, eventBridge } = setup({ datasource: true }); - - const dispatchedActions = await thunkTester(initialState) - .givenThunk(refreshExplore) - .whenThunkIsDispatched(exploreId); - - const initializeExplore = dispatchedActions.find((action) => action.type === initializeExploreAction.type); - const { type, payload } = initializeExplore as PayloadAction; - - expect(type).toEqual(initializeExploreAction.type); - expect(payload.containerWidth).toEqual(containerWidth); - expect(payload.eventBridge).toEqual(eventBridge); - expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on - expect(payload.range.from).toEqual(testRange.from); - expect(payload.range.to).toEqual(testRange.to); - expect(payload.range.raw.from).toEqual(testRange.raw.from); - expect(payload.range.raw.to).toEqual(testRange.raw.to); - }); - }); - - describe('and update queries is set', () => { - it('then it should dispatch setQueriesAction', async () => { - const { exploreId, initialState } = setup({ queries: true }); - - const dispatchedActions = await thunkTester(initialState) - .givenThunk(refreshExplore) - .whenThunkIsDispatched(exploreId); - - expect(dispatchedActions[0].type).toEqual(setQueriesAction.type); - expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] }); - }); - }); + it('should change data source when datasource in url changes', async () => { + const { store } = setup(); + await store.dispatch( + refreshExplore(ExploreId.left, serializeStateToUrlParam({ datasource: 'newDs', queries: [], range: testRange })) + ); + expect(store.getState().explore[ExploreId.left].datasourceInstance?.name).toBe('newDs'); }); - describe('when update is not initialized', () => { - it('then it should not dispatch any actions', async () => { - const exploreId = ExploreId.left; - const initialState = { explore: { [exploreId]: { initialized: false } } }; + it('should change and run new queries from the URL', async () => { + const { store, datasources } = setup(); + datasources.someDs.query.mockReturnValueOnce(of({})); + await store.dispatch( + refreshExplore( + ExploreId.left, + serializeStateToUrlParam({ datasource: 'someDs', queries: [{ expr: 'count()' }], range: testRange }) + ) + ); + // same + const state = store.getState().explore[ExploreId.left]; + expect(state.datasourceInstance?.name).toBe('someDs'); + expect(state.queries.length).toBe(1); + expect(state.queries).toMatchObject([{ expr: 'count()' }]); + expect(datasources.someDs.query).toHaveBeenCalledTimes(1); + }); - const dispatchedActions = await thunkTester(initialState) - .givenThunk(refreshExplore) - .whenThunkIsDispatched(exploreId); - - expect(dispatchedActions).toEqual([]); - }); - }); -}); - -describe('Explore pane reducer', () => { - describe('changing dedup strategy', () => { - describe('when changeDedupStrategyAction is dispatched', () => { - it('then it should set correct dedup strategy in state', () => { - const initialState = makeExplorePaneState(); - - reducerTester() - .givenReducer(paneReducer, initialState) - .whenActionIsDispatched( - changeDedupStrategyAction({ exploreId: ExploreId.left, dedupStrategy: LogsDedupStrategy.exact }) - ) - .thenStateShouldEqual({ - ...initialState, - dedupStrategy: LogsDedupStrategy.exact, - }); - }); + it('should not do anything if pane is not initialized', async () => { + const { store } = setup({ + initialized: false, }); + const state = store.getState(); + await store.dispatch( + refreshExplore( + ExploreId.left, + serializeStateToUrlParam({ datasource: 'newDs', queries: [{ expr: 'count()' }], range: testRange }) + ) + ); + + expect(state).toEqual(store.getState()); }); }); diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index c4bb995cd4a..b82abd4b8df 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -1,12 +1,25 @@ import { AnyAction } from 'redux'; -import { isEqual } from 'lodash'; -import { getQueryKeys } from 'app/core/utils/explore'; +import isEqual from 'lodash/isEqual'; + +import { + DEFAULT_RANGE, + getQueryKeys, + parseUrlState, + ensureQueries, + generateNewKeyAndAddRefIdIfMissing, + getTimeRangeFromUrl, +} from 'app/core/utils/explore'; import { ExploreId, ExploreItemState } from 'app/types/explore'; -import { queryReducer } from './query'; +import { queryReducer, runQueries, setQueriesAction } from './query'; import { datasourceReducer } from './datasource'; -import { timeReducer } from './time'; +import { timeReducer, updateTime } from './time'; import { historyReducer } from './history'; -import { makeExplorePaneState, makeInitialUpdateState, loadAndInitDatasource, createEmptyQueryResponse } from './utils'; +import { + makeExplorePaneState, + loadAndInitDatasource, + createEmptyQueryResponse, + getUrlStateFromPaneState, +} from './utils'; import { createAction, PayloadAction } from '@reduxjs/toolkit'; import { EventBusExtended, @@ -18,21 +31,12 @@ import { HistoryItem, DataSourceApi, } from '@grafana/data'; -import { - clearQueryKeys, - ensureQueries, - generateNewKeyAndAddRefIdIfMissing, - getTimeRangeFromUrl, -} from 'app/core/utils/explore'; // Types import { ThunkResult } from 'app/types'; import { getTimeZone } from 'app/features/profile/state/selectors'; -import { updateLocation } from '../../../core/actions'; -import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; -import { runQueries, setQueriesAction } from './query'; -import { updateTime } from './time'; -import { toRawTimeRange } from '../utils/time'; import { getDataSourceSrv } from '@grafana/runtime'; +import { getRichHistory } from '../../../core/utils/richHistory'; +import { richHistoryUpdatedAction } from './main'; // // Actions and Payloads @@ -157,52 +161,35 @@ export function initializeExplore( dispatch(updateTime({ exploreId })); if (instance) { - dispatch(runQueries(exploreId)); + // We do not want to add the url to browser history on init because when the pane is initialised it's because + // we already have something in the url. Adding basically the same state as additional history item prevents + // user to go back to previous url. + dispatch(runQueries(exploreId, { replaceUrl: true })); } + + const richHistory = getRichHistory(); + dispatch(richHistoryUpdatedAction({ richHistory })); }; } /** - * Save local redux state back to the URL. Should be called when there is some change that should affect the URL. - * Not all of the redux state is reflected in URL though. + * Reacts to changes in URL state that we need to sync back to our redux state. Computes diff of newUrlQuery vs current + * state and runs update actions for relevant parts. */ -export const stateSave = (): ThunkResult => { - return (dispatch, getState) => { - const { left, right, split } = getState().explore; - const orgId = getState().user.orgId.toString(); - const replace = left && left.urlReplaced === false; - const urlStates: { [index: string]: string } = { orgId }; - urlStates.left = serializeStateToUrlParam(getUrlStateFromPaneState(left), true); - if (split) { - urlStates.right = serializeStateToUrlParam(getUrlStateFromPaneState(right), true); - } - - dispatch(updateLocation({ query: urlStates, replace })); - if (replace) { - dispatch(setUrlReplacedAction({ exploreId: ExploreId.left })); - } - }; -}; - -/** - * Reacts to changes in URL state that we need to sync back to our redux state. Checks the internal update variable - * to see which parts change and need to be synced. - * @param exploreId - */ -export function refreshExplore(exploreId: ExploreId): ThunkResult { - return (dispatch, getState) => { - const itemState = getState().explore[exploreId]; +export function refreshExplore(exploreId: ExploreId, newUrlQuery: string): ThunkResult { + return async (dispatch, getState) => { + const itemState = getState().explore[exploreId]!; if (!itemState.initialized) { return; } - const { urlState, update, containerWidth, eventBridge } = itemState; + // Get diff of what should be updated + const newUrlState = parseUrlState(newUrlQuery); + const update = urlDiff(newUrlState, getUrlStateFromPaneState(itemState)); - if (!urlState) { - return; - } + const { containerWidth, eventBridge } = itemState; - const { datasource, queries, range: urlRange, originPanelId } = urlState; + const { datasource, queries, range: urlRange, originPanelId } = newUrlState; const refreshQueries: DataQuery[] = []; for (let index = 0; index < queries.length; index++) { @@ -213,10 +200,11 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult { const timeZone = getTimeZone(getState().user); const range = getTimeRangeFromUrl(urlRange, timeZone); - // need to refresh datasource + // commit changes based on the diff of new url vs old url + if (update.datasource) { const initialQueries = ensureQueries(queries); - dispatch( + await dispatch( initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, originPanelId) ); return; @@ -226,7 +214,6 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult { dispatch(updateTime({ exploreId, rawRange: range.raw })); } - // need to refresh queries if (update.queries) { dispatch(setQueriesAction({ exploreId, queries: refreshQueries })); } @@ -288,7 +275,6 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac initialized: true, queryKeys: getQueryKeys(queries, datasourceInstance), originPanelId, - update: makeInitialUpdateState(), datasourceInstance, history, datasourceMissing: !datasourceInstance, @@ -305,22 +291,28 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac }; } - if (setUrlReplacedAction.match(action)) { - return { - ...state, - urlReplaced: true, - }; - } - return state; }; -function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState { +/** + * Compare 2 explore urls and return a map of what changed. Used to update the local state with all the + * side effects needed. + */ +export const urlDiff = ( + oldUrlState: ExploreUrlState | undefined, + currentUrlState: ExploreUrlState | undefined +): { + datasource: boolean; + queries: boolean; + range: boolean; +} => { + const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource); + const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries); + const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE); + return { - // It can happen that if we are in a split and initial load also runs queries we can be here before the second pane - // is initialized so datasourceInstance will be still undefined. - datasource: pane.datasourceInstance?.name || pane.urlState!.datasource, - queries: pane.queries.map(clearQueryKeys), - range: toRawTimeRange(pane.range), + datasource, + queries, + range, }; -} +}; diff --git a/public/app/features/explore/state/main.test.ts b/public/app/features/explore/state/main.test.ts index 85f4d2dab72..e3ff1b1c1e6 100644 --- a/public/app/features/explore/state/main.test.ts +++ b/public/app/features/explore/state/main.test.ts @@ -1,13 +1,12 @@ import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; -import { exploreReducer, initialExploreState, navigateToExplore, splitCloseAction, splitOpenAction } from './main'; +import { exploreReducer, navigateToExplore, splitCloseAction } from './main'; import { thunkTester } from 'test/core/thunk/thunkTester'; import { PanelModel } from 'app/features/dashboard/state'; import { updateLocation } from '../../../core/actions'; import { MockDataSourceApi } from '../../../../test/mocks/datasource_srv'; import { ExploreId, ExploreItemState, ExploreState } from '../../../types'; -import { makeExplorePaneState, makeInitialUpdateState } from './utils'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; -import { ExploreUrlState, UrlQueryMap } from '@grafana/data'; +import { ExploreUrlState } from '@grafana/data'; const getNavigateToExploreContext = async (openInNewWindow?: (url: string) => void) => { const url = 'http://www.someurl.com'; @@ -137,27 +136,6 @@ describe('navigateToExplore', () => { describe('Explore reducer', () => { describe('split view', () => { - it("should make right pane a duplicate of the given item's state on split open", () => { - const leftItemMock = ({ - containerWidth: 100, - } as unknown) as ExploreItemState; - - const initialState = ({ - split: null, - left: leftItemMock as ExploreItemState, - right: makeExplorePaneState(), - } as unknown) as ExploreState; - - reducerTester() - .givenReducer(exploreReducer, initialState) - .whenActionIsDispatched(splitOpenAction({ itemState: leftItemMock })) - .thenStateShouldEqual(({ - split: true, - left: leftItemMock, - right: leftItemMock, - } as unknown) as ExploreState); - }); - describe('split close', () => { it('should keep right pane as left when left is closed', () => { const leftItemMock = ({ @@ -169,7 +147,6 @@ describe('Explore reducer', () => { } as unknown) as ExploreItemState; const initialState = ({ - split: null, left: leftItemMock, right: rightItemMock, } as unknown) as ExploreState; @@ -179,9 +156,8 @@ describe('Explore reducer', () => { .givenReducer(exploreReducer, initialState) .whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.left })) .thenStateShouldEqual(({ - split: false, left: rightItemMock, - right: initialExploreState.right, + right: undefined, } as unknown) as ExploreState); }); it('should reset right pane when it is closed ', () => { @@ -194,7 +170,6 @@ describe('Explore reducer', () => { } as unknown) as ExploreItemState; const initialState = ({ - split: null, left: leftItemMock, right: rightItemMock, } as unknown) as ExploreState; @@ -204,238 +179,15 @@ describe('Explore reducer', () => { .givenReducer(exploreReducer, initialState) .whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right })) .thenStateShouldEqual(({ - split: false, left: leftItemMock, - right: initialExploreState.right, + right: undefined, } as unknown) as ExploreState); }); }); }); - - describe('when updateLocation is dispatched', () => { - describe('and payload does not contain a query', () => { - it('then it should just return state', () => { - reducerTester() - .givenReducer(exploreReducer, ({} as unknown) as ExploreState) - .whenActionIsDispatched(updateLocation({ query: (null as unknown) as UrlQueryMap })) - .thenStateShouldEqual(({} as unknown) as ExploreState); - }); - }); - - describe('and payload contains a query', () => { - describe("but does not contain 'left'", () => { - it('then it should just return state', () => { - reducerTester() - .givenReducer(exploreReducer, ({} as unknown) as ExploreState) - .whenActionIsDispatched(updateLocation({ query: {} })) - .thenStateShouldEqual(({} as unknown) as ExploreState); - }); - }); - - describe("and query contains a 'right'", () => { - it('then it should add split in state', () => { - const { initialState, serializedUrlState } = setup(); - const expectedState = { ...initialState, split: true }; - - reducerTester() - .givenReducer(exploreReducer, initialState) - .whenActionIsDispatched( - updateLocation({ - query: { - left: serializedUrlState, - right: serializedUrlState, - }, - }) - ) - .thenStateShouldEqual(expectedState); - }); - }); - - describe("and query contains a 'left'", () => { - describe('but urlState is not set in state', () => { - it('then it should just add urlState and update in state', () => { - const { initialState, serializedUrlState } = setup(); - const urlState: ExploreUrlState = (null as unknown) as ExploreUrlState; - const stateWithoutUrlState = ({ ...initialState, left: { urlState } } as unknown) as ExploreState; - const expectedState = { ...initialState }; - - reducerTester() - .givenReducer(exploreReducer, stateWithoutUrlState) - .whenActionIsDispatched( - updateLocation({ - query: { - left: serializedUrlState, - }, - path: '/explore', - }) - ) - .thenStateShouldEqual(expectedState); - }); - }); - - describe("but '/explore' is missing in path", () => { - it('then it should just add urlState and update in state', () => { - const { initialState, serializedUrlState } = setup(); - const expectedState = { ...initialState }; - - reducerTester() - .givenReducer(exploreReducer, initialState) - .whenActionIsDispatched( - updateLocation({ - query: { - left: serializedUrlState, - }, - path: '/dashboard', - }) - ) - .thenStateShouldEqual(expectedState); - }); - }); - - describe("and '/explore' is in path", () => { - describe('and datasource differs', () => { - it('then it should return update datasource', () => { - const { initialState, serializedUrlState } = setup(); - const expectedState = { - ...initialState, - left: { - ...initialState.left, - update: { - ...initialState.left.update, - datasource: true, - }, - }, - }; - const stateWithDifferentDataSource: any = { - ...initialState, - left: { - ...initialState.left, - urlState: { - ...initialState.left.urlState, - datasource: 'different datasource', - }, - }, - }; - - reducerTester() - .givenReducer(exploreReducer, stateWithDifferentDataSource) - .whenActionIsDispatched( - updateLocation({ - query: { - left: serializedUrlState, - }, - path: '/explore', - }) - ) - .thenStateShouldEqual(expectedState); - }); - }); - - describe('and range differs', () => { - it('then it should return update range', () => { - const { initialState, serializedUrlState } = setup(); - const expectedState = { - ...initialState, - left: { - ...initialState.left, - update: { - ...initialState.left.update, - range: true, - }, - }, - }; - const stateWithDifferentDataSource: any = { - ...initialState, - left: { - ...initialState.left, - urlState: { - ...initialState.left.urlState, - range: { - from: 'now', - to: 'now-6h', - }, - }, - }, - }; - - reducerTester() - .givenReducer(exploreReducer, stateWithDifferentDataSource) - .whenActionIsDispatched( - updateLocation({ - query: { - left: serializedUrlState, - }, - path: '/explore', - }) - ) - .thenStateShouldEqual(expectedState); - }); - }); - - describe('and queries differs', () => { - it('then it should return update queries', () => { - const { initialState, serializedUrlState } = setup(); - const expectedState = { - ...initialState, - left: { - ...initialState.left, - update: { - ...initialState.left.update, - queries: true, - }, - }, - }; - const stateWithDifferentDataSource: any = { - ...initialState, - left: { - ...initialState.left, - urlState: { - ...initialState.left.urlState, - queries: [{ expr: '{__filename__="some.log"}' }], - }, - }, - }; - - reducerTester() - .givenReducer(exploreReducer, stateWithDifferentDataSource) - .whenActionIsDispatched( - updateLocation({ - query: { - left: serializedUrlState, - }, - path: '/explore', - }) - ) - .thenStateShouldEqual(expectedState); - }); - }); - - describe('and nothing differs', () => { - it('then it should return update ui', () => { - const { initialState, serializedUrlState } = setup(); - const expectedState = { ...initialState }; - - reducerTester() - .givenReducer(exploreReducer, initialState) - .whenActionIsDispatched( - updateLocation({ - query: { - left: serializedUrlState, - }, - path: '/explore', - }) - ) - .thenStateShouldEqual(expectedState); - }); - }); - }); - }); - }); - }); }); export const setup = (urlStateOverrides?: any) => { - const update = makeInitialUpdateState(); const urlStateDefaults: ExploreUrlState = { datasource: 'some-datasource', queries: [], @@ -448,8 +200,6 @@ export const setup = (urlStateOverrides?: any) => { const serializedUrlState = serializeStateToUrlParam(urlState); const initialState = ({ split: false, - left: { urlState, update }, - right: { urlState, update }, } as unknown) as ExploreState; return { diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 211ac019e31..d8c84324329 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -1,18 +1,14 @@ -import _ from 'lodash'; import { AnyAction } from 'redux'; -import { DataSourceSrv, LocationUpdate } from '@grafana/runtime'; +import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery, ExploreUrlState, serializeStateToUrlParam, TimeRange, UrlQueryMap } from '@grafana/data'; -import { stopQueryState, parseUrlState, DEFAULT_RANGE, GetExploreUrlArguments } from 'app/core/utils/explore'; +import { GetExploreUrlArguments, stopQueryState } from 'app/core/utils/explore'; import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore'; import { updateLocation } from '../../../core/actions'; -import { paneReducer, stateSave } from './explorePane'; +import { paneReducer } from './explorePane'; import { createAction } from '@reduxjs/toolkit'; -import { makeExplorePaneState } from './utils'; -import { DataQuery, TimeRange } from '@grafana/data'; +import { getUrlStateFromPaneState, makeExplorePaneState } from './utils'; import { ThunkResult } from '../../../types'; -import { getDatasourceSrv } from '../../plugins/datasource_srv'; -import { changeDatasource } from './datasource'; -import { runQueries, setQueriesAction } from './query'; import { TimeSrv } from '../../dashboard/services/TimeSrv'; import { PanelModel } from 'app/features/dashboard/state'; @@ -20,24 +16,6 @@ import { PanelModel } from 'app/features/dashboard/state'; // Actions and Payloads // -/** - * Close the split view and save URL state. - */ -export interface SplitCloseActionPayload { - itemId: ExploreId; -} -export const splitCloseAction = createAction('explore/splitClose'); - -/** - * Open the split view and copy the left state to be the right state. - * The right state is automatically initialized. - * The copy keeps all query modifications but wipes the query results. - */ -export interface SplitOpenPayload { - itemState: ExploreItemState; -} -export const splitOpenAction = createAction('explore/splitOpen'); - export interface SyncTimesPayload { syncedTimes: boolean; } @@ -53,15 +31,56 @@ export interface ResetExplorePayload { } export const resetExploreAction = createAction('explore/resetExplore'); +/** + * Close the split view and save URL state. + */ +export interface SplitCloseActionPayload { + itemId: ExploreId; +} +export const splitCloseAction = createAction('explore/splitClose'); + +/** + * Cleans up a pane state. Could seem like this should be in explorePane.ts actions but in case we area closing + * left pane we need to move right state to the left. + * Also this may seem redundant as we have splitClose actions which clears up state but that action is not called on + * URL change. + */ +export interface CleanupPanePayload { + exploreId: ExploreId; +} +export const cleanupPaneAction = createAction('explore/cleanupPane'); + // // Action creators // /** - * Open the split view and the right state is automatically initialized. - * If options are specified it initializes that pane with the datasource and query from options. - * Otherwise it copies the left state to be the right state. The copy keeps all query modifications but wipes the query - * results. + * Save local redux state back to the URL. Should be called when there is some change that should affect the URL. + * Not all of the redux state is reflected in URL though. + */ +export const stateSave = (options?: { replace?: boolean }): ThunkResult => { + return (dispatch, getState) => { + const { left, right } = getState().explore; + const orgId = getState().user.orgId.toString(); + const urlStates: { [index: string]: string } = { orgId }; + urlStates.left = serializeStateToUrlParam(getUrlStateFromPaneState(left), true); + if (right) { + urlStates.right = serializeStateToUrlParam(getUrlStateFromPaneState(right), true); + } + + lastSavedUrl.right = urlStates.right; + lastSavedUrl.left = urlStates.left; + dispatch(updateLocation({ query: urlStates, replace: options?.replace })); + }; +}; + +// Store the url we saved last se we are not trying to update local state based on that. +export const lastSavedUrl: UrlQueryMap = {}; + +/** + * Opens a new right split pane by navigating to appropriate URL. It either copies existing state of the left pane + * or uses values from options arg. This does only navigation each pane is then responsible for initialization from + * the URL. */ export function splitOpen(options?: { datasourceUid: string; @@ -70,70 +89,31 @@ export function splitOpen(options?: { range?: TimeRange; }): ThunkResult { return async (dispatch, getState) => { - // Clone left state to become the right state const leftState: ExploreItemState = getState().explore[ExploreId.left]; - const rightState: ExploreItemState = { - ...leftState, - }; - const queryState = getState().location.query[ExploreId.left] as string; - const urlState = parseUrlState(queryState); + const leftUrlState = getUrlStateFromPaneState(leftState); + let rightUrlState: ExploreUrlState = leftUrlState; if (options) { - rightState.queries = []; - rightState.graphResult = null; - rightState.logsResult = null; - rightState.tableResult = null; - rightState.queryKeys = []; - urlState.queries = []; - rightState.urlState = urlState; - rightState.showLogs = false; - rightState.showMetrics = false; - rightState.showNodeGraph = false; - rightState.showTrace = false; - rightState.showTable = false; - if (options.range) { - urlState.range = options.range.raw; - // This is super hacky. In traces to logs we want to create a link but also internally open split window. - // We use the same range object but the raw part is treated differently because it's parsed differently during - // init depending on whether we open split or new window. - rightState.range = { - ...options.range, - raw: { - from: options.range.from.utc().toISOString(), - to: options.range.to.utc().toISOString(), - }, - }; - } - - dispatch(splitOpenAction({ itemState: rightState })); - - const queries = [ - { - ...options.query, - refId: 'A', - } as DataQuery, - ]; - - const dataSourceSettings = getDatasourceSrv().getInstanceSettings(options.datasourceUid); - - await dispatch(changeDatasource(ExploreId.right, dataSourceSettings!.name)); - await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries })); - await dispatch(runQueries(ExploreId.right)); - } else { - rightState.queries = leftState.queries.slice(); - rightState.urlState = urlState; - dispatch(splitOpenAction({ itemState: rightState })); + const datasourceName = getDataSourceSrv().getInstanceSettings(options.datasourceUid)?.name || ''; + rightUrlState = { + datasource: datasourceName, + queries: [options.query], + range: options.range || leftState.range, + }; } - dispatch(stateSave()); + const urlState = serializeStateToUrlParam(rightUrlState, true); + dispatch(updateLocation({ query: { right: urlState }, partial: true })); }; } /** - * Close the split view and save URL state. + * Close the split view and save URL state. We need to update the state here because when closing we cannot just + * update the URL and let the components handle it because if we swap panes from right to left it is not easily apparent + * from the URL. */ export function splitClose(itemId: ExploreId): ThunkResult { - return (dispatch) => { + return (dispatch, getState) => { dispatch(splitCloseAction({ itemId })); dispatch(stateSave()); }; @@ -177,10 +157,9 @@ export const navigateToExplore = ( */ const initialExploreItemState = makeExplorePaneState(); export const initialExploreState: ExploreState = { - split: false, syncedTimes: false, left: initialExploreItemState, - right: initialExploreItemState, + right: undefined, richHistory: [], }; @@ -192,18 +171,29 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): if (splitCloseAction.match(action)) { const { itemId } = action.payload as SplitCloseActionPayload; const targetSplit = { - left: itemId === ExploreId.left ? state.right : state.left, - right: initialExploreState.right, + left: itemId === ExploreId.left ? state.right! : state.left, + right: undefined, }; return { ...state, ...targetSplit, - split: false, }; } - if (splitOpenAction.match(action)) { - return { ...state, split: true, right: { ...action.payload.itemState } }; + if (cleanupPaneAction.match(action)) { + const { exploreId } = action.payload as CleanupPanePayload; + if (exploreId === ExploreId.left) { + return { + ...state, + [ExploreId.left]: state[ExploreId.right]!, + [ExploreId.right]: undefined, + }; + } else { + return { + ...state, + [ExploreId.right]: undefined, + }; + } } if (syncTimesAction.match(action)) { @@ -222,7 +212,9 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): const leftState = state[ExploreId.left]; const rightState = state[ExploreId.right]; stopQueryState(leftState.querySubscription); - stopQueryState(rightState.querySubscription); + if (rightState) { + stopQueryState(rightState.querySubscription); + } if (payload.force || !Number.isInteger(state.left.originPanelId)) { return initialExploreState; @@ -238,25 +230,6 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): }; } - if (updateLocation.match(action)) { - const payload: LocationUpdate = action.payload; - const { query } = payload; - if (!query || !query[ExploreId.left]) { - return state; - } - - const split = query[ExploreId.right] ? true : false; - const leftState = state[ExploreId.left]; - const rightState = state[ExploreId.right]; - - return { - ...state, - split, - [ExploreId.left]: updatePaneRefreshState(leftState, payload, ExploreId.left), - [ExploreId.right]: updatePaneRefreshState(rightState, payload, ExploreId.right), - }; - } - if (action.payload) { const { exploreId } = action.payload; if (exploreId !== undefined) { @@ -272,44 +245,3 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): export default { explore: exploreReducer, }; - -export const updatePaneRefreshState = ( - state: Readonly, - payload: LocationUpdate, - exploreId: ExploreId -): ExploreItemState => { - const path = payload.path || ''; - if (!payload.query) { - return state; - } - - const queryState = payload.query[exploreId] as string; - if (!queryState) { - return state; - } - - const urlState = parseUrlState(queryState); - if (!state.urlState || path !== '/explore') { - // we only want to refresh when browser back/forward - return { - ...state, - urlState, - update: { datasource: false, queries: false, range: false, mode: false }, - }; - } - - const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false; - const queries = _.isEqual(urlState ? urlState.queries : [], state.urlState.queries) === false; - const range = _.isEqual(urlState ? urlState.range : DEFAULT_RANGE, state.urlState.range) === false; - - return { - ...state, - urlState, - update: { - ...state.update, - datasource, - queries, - range, - }, - }; -}; diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index 268deb32f21..02464c21899 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -4,15 +4,19 @@ import { cancelQueriesAction, queryReducer, removeQueryRowAction, + runQueries, scanStartAction, scanStopAction, } from './query'; import { ExploreId, ExploreItemState } from 'app/types'; -import { interval } from 'rxjs'; -import { RawTimeRange, toUtc } from '@grafana/data'; +import { interval, of } from 'rxjs'; +import { ArrayVector, DataQueryResponse, DefaultTimeZone, MutableDataFrame, RawTimeRange, toUtc } from '@grafana/data'; import { thunkTester } from 'test/core/thunk/thunkTester'; import { makeExplorePaneState } from './utils'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { configureStore } from '../../../store/configureStore'; +import { setTimeSrv } from '../../dashboard/services/TimeSrv'; +import Mock = jest.Mock; const QUERY_KEY_REGEX = /Q-(?:[a-z0-9]+-){5}(?:[0-9]+)/; const t = toUtc(); @@ -24,6 +28,58 @@ const testRange = { to: t, }, }; +const defaultInitialState = { + user: { + orgId: '1', + timeZone: DefaultTimeZone, + }, + explore: { + [ExploreId.left]: { + datasourceInstance: { + query: jest.fn(), + meta: { + id: 'something', + }, + }, + initialized: true, + containerWidth: 1920, + eventBridge: { emit: () => {} } as any, + queries: [{ expr: 'test' }] as any[], + range: testRange, + refreshInterval: { + label: 'Off', + value: 0, + }, + }, + }, +}; + +describe('runQueries', () => { + it('should pass dataFrames to state even if there is error in response', async () => { + setTimeSrv({ + init() {}, + } as any); + const store = configureStore({ + ...(defaultInitialState as any), + }); + (store.getState().explore[ExploreId.left].datasourceInstance?.query as Mock).mockReturnValueOnce( + of({ + error: { message: 'test error' }, + data: [ + new MutableDataFrame({ + fields: [{ name: 'test', values: new ArrayVector() }], + meta: { + preferredVisualisationType: 'graph', + }, + }), + ], + } as DataQueryResponse) + ); + await store.dispatch(runQueries(ExploreId.left)); + expect(store.getState().explore[ExploreId.left].showMetrics).toBeTruthy(); + expect(store.getState().explore[ExploreId.left].graphResult).toBeDefined(); + }); +}); describe('running queries', () => { it('should cancel running query when cancelQueries is dispatched', async () => { diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 7c365d3f127..fdf85d83fa3 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -35,12 +35,11 @@ import { decorateWithTableResult, } from '../utils/decorators'; import { createErrorNotification } from '../../../core/copy/appNotification'; -import { richHistoryUpdatedAction } from './main'; -import { stateSave } from './explorePane'; +import { richHistoryUpdatedAction, stateSave } from './main'; import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit'; import { updateTime } from './time'; import { historyUpdatedAction } from './history'; -import { createEmptyQueryResponse, makeInitialUpdateState } from './utils'; +import { createEmptyQueryResponse } from './utils'; // // Actions and Payloads @@ -174,7 +173,7 @@ export const scanStopAction = createAction('explore/scanStop'); */ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { return (dispatch, getState) => { - const queries = getState().explore[exploreId].queries; + const queries = getState().explore[exploreId]!.queries; const query = generateEmptyQuery(queries, index); dispatch(addQueryRowAction({ exploreId, index, query })); @@ -194,7 +193,7 @@ export function changeQuery( return (dispatch, getState) => { // Null query means reset if (query === null) { - const queries = getState().explore[exploreId].queries; + const queries = getState().explore[exploreId]!.queries; const { refId, key } = queries[index]; query = generateNewKeyAndAddRefIdIfMissing({ refId, key }, queries, index); } @@ -292,12 +291,12 @@ export function modifyQueries( /** * Main action to run queries and dispatches sub-actions based on which result viewers are active */ -export const runQueries = (exploreId: ExploreId): ThunkResult => { +export const runQueries = (exploreId: ExploreId, options?: { replaceUrl?: boolean }): ThunkResult => { return (dispatch, getState) => { dispatch(updateTime({ exploreId })); const richHistory = getState().explore.richHistory; - const exploreItemState = getState().explore[exploreId]; + const exploreItemState = getState().explore[exploreId]!; const { datasourceInstance, queries, @@ -314,7 +313,7 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { if (!hasNonEmptyQuery(queries)) { dispatch(clearQueriesAction({ exploreId })); - dispatch(stateSave()); // Remember to save to state and update location + dispatch(stateSave({ replace: options?.replaceUrl })); // Remember to save to state and update location return; } @@ -379,7 +378,7 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); // We save queries to the URL here so that only successfully run queries change the URL. - dispatch(stateSave()); + dispatch(stateSave({ replace: options?.replaceUrl })); } firstResponse = false; @@ -387,9 +386,9 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { dispatch(queryStreamUpdatedAction({ exploreId, response: data })); // Keep scanning for results if this was the last scanning transaction - if (getState().explore[exploreId].scanning) { + if (getState().explore[exploreId]!.scanning) { if (data.state === LoadingState.Done && data.series.length === 0) { - const range = getShiftedTimeRange(-1, getState().explore[exploreId].range); + const range = getShiftedTimeRange(-1, getState().explore[exploreId]!.range); dispatch(updateTime({ exploreId, absoluteRange: range })); dispatch(runQueries(exploreId)); } else { @@ -416,7 +415,7 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult { return (dispatch, getState) => { // Inject react keys into query objects - const queries = getState().explore[exploreId].queries; + const queries = getState().explore[exploreId]!.queries; const nextQueries = rawQueries.map((query, index) => generateNewKeyAndAddRefIdIfMissing(query, queries, index)); dispatch(setQueriesAction({ exploreId, queries: nextQueries })); dispatch(runQueries(exploreId)); @@ -433,7 +432,7 @@ export function scanStart(exploreId: ExploreId): ThunkResult { // Register the scanner dispatch(scanStartAction({ exploreId })); // Scanning must trigger query run, and return the new range - const range = getShiftedTimeRange(-1, getState().explore[exploreId].range); + const range = getShiftedTimeRange(-1, getState().explore[exploreId]!.range); // Set the new range to be displayed dispatch(updateTime({ exploreId, absoluteRange: range })); dispatch(runQueries(exploreId)); @@ -627,7 +626,6 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor ...state, scanning: false, scanRange: undefined, - update: makeInitialUpdateState(), }; } @@ -664,16 +662,6 @@ export const processQueryResponse = ( // For Angular editors state.eventBridge.emit(PanelEvents.dataError, error); - - return { - ...state, - loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming, - queryResponse: response, - graphResult: null, - tableResult: null, - logsResult: null, - update: makeInitialUpdateState(), - }; } if (!request) { @@ -697,7 +685,6 @@ export const processQueryResponse = ( tableResult, logsResult, loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming, - update: makeInitialUpdateState(), showLogs: !!logsResult, showMetrics: !!graphResult, showTable: !!tableResult, diff --git a/public/app/features/explore/state/selectors.ts b/public/app/features/explore/state/selectors.ts index 32e097b4f62..c1e8c0010ee 100644 --- a/public/app/features/explore/state/selectors.ts +++ b/public/app/features/explore/state/selectors.ts @@ -1,5 +1,5 @@ import { createSelector } from 'reselect'; -import { ExploreItemState } from 'app/types'; +import { ExploreId, ExploreItemState, StoreState } from 'app/types'; import { filterLogLevels, dedupLogRows } from 'app/core/logs_model'; const logsRowsSelector = (state: ExploreItemState) => state.logsResult && state.logsResult.rows; @@ -17,3 +17,5 @@ export const deduplicatedRowsSelector = createSelector( return dedupLogRows(filteredRows, dedupStrategy); } ); + +export const isSplit = (state: StoreState) => Boolean(state.explore[ExploreId.left] && state.explore[ExploreId.right]); diff --git a/public/app/features/explore/state/time.test.ts b/public/app/features/explore/state/time.test.ts index 4d64875d161..93aef3f4db5 100644 --- a/public/app/features/explore/state/time.test.ts +++ b/public/app/features/explore/state/time.test.ts @@ -1,6 +1,6 @@ import { dateTime, LoadingState } from '@grafana/data'; -import { makeExplorePaneState, makeInitialUpdateState } from './utils'; +import { makeExplorePaneState } from './utils'; import { ExploreId, ExploreItemState } from 'app/types/explore'; import { reducerTester } from 'test/core/redux/reducerTester'; import { changeRangeAction, changeRefreshIntervalAction, timeReducer } from './time'; @@ -55,7 +55,6 @@ describe('Explore item reducer', () => { it('then it should set correct state', () => { reducerTester() .givenReducer(timeReducer, ({ - update: { ...makeInitialUpdateState(), range: true }, range: null, absoluteRange: null, } as unknown) as ExploreItemState) @@ -67,7 +66,6 @@ describe('Explore item reducer', () => { }) ) .thenStateShouldEqual(({ - update: { ...makeInitialUpdateState(), range: false }, absoluteRange: { from: 1546297200000, to: 1546383600000 }, range: { from: dateTime('2019-01-01'), to: dateTime('2019-01-02'), raw: { from: 'now-1d', to: 'now' } }, } as unknown) as ExploreItemState); diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index 000a103630b..e3217008d90 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -16,9 +16,7 @@ import { getTimeZone } from 'app/features/profile/state/selectors'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state'; import { runQueries } from './query'; -import { syncTimesAction } from './main'; -import { stateSave } from './explorePane'; -import { makeInitialUpdateState } from './utils'; +import { syncTimesAction, stateSave } from './main'; // // Actions and Payloads @@ -76,7 +74,7 @@ export const updateTime = (config: { }): ThunkResult => { return (dispatch, getState) => { const { exploreId, absoluteRange: absRange, rawRange: actionRange } = config; - const itemState = getState().explore[exploreId]; + const itemState = getState().explore[exploreId]!; const timeZone = getTimeZone(getState().user); const { range: rangeInState } = itemState; let rawRange: RawTimeRange = rangeInState.raw; @@ -117,7 +115,7 @@ export function syncTimes(exploreId: ExploreId): ThunkResult { const leftState = getState().explore.left; dispatch(updateTimeRange({ exploreId: ExploreId.right, rawRange: leftState.range.raw })); } else { - const rightState = getState().explore.right; + const rightState = getState().explore.right!; dispatch(updateTimeRange({ exploreId: ExploreId.left, rawRange: rightState.range.raw })); } const isTimeSynced = getState().explore.syncedTimes; @@ -165,7 +163,6 @@ export const timeReducer = (state: ExploreItemState, action: AnyAction): Explore ...state, range, absoluteRange, - update: makeInitialUpdateState(), }; } diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index e5a924db708..c25d49199a3 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -1,6 +1,7 @@ import { DataSourceApi, EventBusExtended, + ExploreUrlState, getDefaultTimeRange, HistoryItem, LoadingState, @@ -8,23 +9,17 @@ import { PanelData, } from '@grafana/data'; -import { ExploreItemState, ExploreUpdateState } from 'app/types/explore'; +import { ExploreItemState } from 'app/types/explore'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; import store from '../../../core/store'; -import { lastUsedDatasourceKeyForOrgId } from '../../../core/utils/explore'; +import { clearQueryKeys, lastUsedDatasourceKeyForOrgId } from '../../../core/utils/explore'; +import { toRawTimeRange } from '../utils/time'; export const DEFAULT_RANGE = { from: 'now-6h', to: 'now', }; -export const makeInitialUpdateState = (): ExploreUpdateState => ({ - datasource: false, - queries: false, - range: false, - mode: false, -}); - /** * Returns a fresh Explore area state */ @@ -47,12 +42,9 @@ export const makeExplorePaneState = (): ExploreItemState => ({ scanning: false, loading: false, queryKeys: [], - urlState: null, - update: makeInitialUpdateState(), latency: 0, isLive: false, isPaused: false, - urlReplaced: false, queryResponse: createEmptyQueryResponse(), tableResult: null, graphResult: null, @@ -88,3 +80,13 @@ export async function loadAndInitDatasource( store.set(lastUsedDatasourceKeyForOrgId(orgId), instance.name); return { history, instance }; } + +export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState { + return { + // datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined + // lets just fallback instead of crashing. + datasource: pane.datasourceInstance?.name || '', + queries: pane.queries.map(clearQueryKeys), + range: toRawTimeRange(pane.range), + }; +} diff --git a/public/app/features/explore/useLiveTailControls.ts b/public/app/features/explore/useLiveTailControls.ts index a13780661d3..b18bd8b8409 100644 --- a/public/app/features/explore/useLiveTailControls.ts +++ b/public/app/features/explore/useLiveTailControls.ts @@ -3,9 +3,8 @@ import { useDispatch } from 'react-redux'; import { RefreshPicker } from '@grafana/ui'; import { changeRefreshInterval } from './state/time'; -import { setPausedStateAction } from './state/query'; +import { setPausedStateAction, runQueries } from './state/query'; import { ExploreId } from '../../types'; -import { runQueries } from './state/query'; /** * Hook that gives you all the functions needed to control the live tailing. diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 24c17aed6c6..93469e33175 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -132,7 +132,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => { }); }); - it('should handle query error', () => { + it('should return frames even if there is an error', () => { const { timeSeries, logs, table } = getTestContext(); const series: DataFrame[] = [timeSeries, logs, table]; const panelData: PanelData = { @@ -147,9 +147,9 @@ describe('decorateWithGraphLogsTraceAndTable', () => { error: {}, state: LoadingState.Error, timeRange: {}, - graphFrames: [], - tableFrames: [], - logsFrames: [], + graphFrames: [timeSeries], + tableFrames: [table], + logsFrames: [logs], traceFrames: [], nodeGraphFrames: [], graphResult: null, @@ -171,10 +171,10 @@ describe('decorateWithGraphResult', () => { expect(decorateWithGraphResult(panelData).graphResult).toBeNull(); }); - it('returns null if panelData has error', () => { + it('returns data if panelData has error', () => { const { timeSeries } = getTestContext(); const panelData = createExplorePanelData({ error: {}, graphFrames: [timeSeries] }); - expect(decorateWithGraphResult(panelData).graphResult).toBeNull(); + expect(decorateWithGraphResult(panelData).graphResult).toMatchObject([timeSeries]); }); }); @@ -272,11 +272,11 @@ describe('decorateWithTableResult', () => { expect(panelResult.tableResult).toBeNull(); }); - it('returns null if panelData has error', async () => { + it('returns data if panelData has error', async () => { const { table, emptyTable } = getTestContext(); const panelData = createExplorePanelData({ error: {}, tableFrames: [table, emptyTable] }); const panelResult = await decorateWithTableResult(panelData).toPromise(); - expect(panelResult.tableResult).toBeNull(); + expect(panelResult.tableResult).not.toBeNull(); }); }); @@ -386,9 +386,9 @@ describe('decorateWithLogsResult', () => { expect(decorateWithLogsResult()(panelData).logsResult).toBeNull(); }); - it('returns null if panelData has error', () => { + it('returns data if panelData has error', () => { const { logs } = getTestContext(); const panelData = createExplorePanelData({ error: {}, logsFrames: [logs] }); - expect(decorateWithLogsResult()(panelData).logsResult).toBeNull(); + expect(decorateWithLogsResult()(panelData).logsResult).not.toBeNull(); }); }); diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index d81851e7224..a9bae4d5725 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -21,20 +21,6 @@ import { ExplorePanelData } from '../../../types'; * Observable pipeline, it decorates the existing panelData to pass the results to later processing stages. */ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData => { - if (data.error) { - return { - ...data, - graphFrames: [], - tableFrames: [], - logsFrames: [], - traceFrames: [], - nodeGraphFrames: [], - graphResult: null, - tableResult: null, - logsResult: null, - }; - } - const graphFrames: DataFrame[] = []; const tableFrames: DataFrame[] = []; const logsFrames: DataFrame[] = []; @@ -83,7 +69,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData }; export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelData => { - if (data.error || !data.graphFrames.length) { + if (!data.graphFrames.length) { return { ...data, graphResult: null }; } @@ -96,10 +82,6 @@ export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelDat * multiple results and so this should be used with mergeMap or similar to unbox the internal observable. */ export const decorateWithTableResult = (data: ExplorePanelData): Observable => { - if (data.error) { - return of({ ...data, tableResult: null }); - } - if (data.tableFrames.length === 0) { return of({ ...data, tableResult: null }); } @@ -149,10 +131,6 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable (data: ExplorePanelData): ExplorePanelData => { - if (data.error) { - return { ...data, logsResult: null }; - } - if (data.logsFrames.length === 0) { return { ...data, logsResult: null }; } diff --git a/public/app/features/folders/services/FolderPageLoader.ts b/public/app/features/folders/services/FolderPageLoader.ts deleted file mode 100644 index c110c0a6729..00000000000 --- a/public/app/features/folders/services/FolderPageLoader.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { backendSrv } from 'app/core/services/backend_srv'; - -export class FolderPageLoader { - load(ctrl: any, uid: any, activeChildId: any) { - ctrl.navModel = { - main: { - icon: 'folder', - id: 'manage-folder', - subTitle: 'Manage folder dashboards & permissions', - url: '', - text: '', - breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }], - children: [ - { - active: activeChildId === 'manage-folder-dashboards', - icon: 'apps', - id: 'manage-folder-dashboards', - text: 'Dashboards', - url: 'dashboards', - }, - { - active: activeChildId === 'manage-folder-permissions', - icon: 'lock', - id: 'manage-folder-permissions', - text: 'Permissions', - url: 'dashboards/permissions', - }, - { - active: activeChildId === 'manage-folder-settings', - icon: 'cog', - id: 'manage-folder-settings', - text: 'Settings', - url: 'dashboards/settings', - }, - ], - }, - }; - - return backendSrv.getFolderByUid(uid).then((folder: any) => { - ctrl.folderId = folder.id; - const folderTitle = folder.title; - const folderUrl = folder.url; - ctrl.navModel.main.text = folderTitle; - - const dashTab = ctrl.navModel.main.children.find((child: any) => child.id === 'manage-folder-dashboards'); - dashTab.url = folderUrl; - - if (folder.canAdmin) { - const permTab = ctrl.navModel.main.children.find((child: any) => child.id === 'manage-folder-permissions'); - permTab.url = folderUrl + '/permissions'; - - const settingsTab = ctrl.navModel.main.children.find((child: any) => child.id === 'manage-folder-settings'); - settingsTab.url = folderUrl + '/settings'; - } else { - ctrl.navModel.main.children = [dashTab]; - } - - return folder; - }); - } -} diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index 82de51b64c7..c0734427775 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -2,7 +2,7 @@ import { FolderDTO } from 'app/types'; import { NavModelItem, NavModel } from '@grafana/data'; export function buildNavModel(folder: FolderDTO): NavModelItem { - return { + const model = { icon: 'folder', id: 'manage-folder', subTitle: 'Manage folder dashboards & permissions', @@ -17,22 +17,30 @@ export function buildNavModel(folder: FolderDTO): NavModelItem { text: 'Dashboards', url: folder.url, }, - { - active: false, - icon: 'lock', - id: `folder-permissions-${folder.uid}`, - text: 'Permissions', - url: `${folder.url}/permissions`, - }, - { - active: false, - icon: 'cog', - id: `folder-settings-${folder.uid}`, - text: 'Settings', - url: `${folder.url}/settings`, - }, ], }; + + if (folder.canAdmin) { + model.children.push({ + active: false, + icon: 'lock', + id: `folder-permissions-${folder.uid}`, + text: 'Permissions', + url: `${folder.url}/permissions`, + }); + } + + if (folder.canSave) { + model.children.push({ + active: false, + icon: 'cog', + id: `folder-settings-${folder.uid}`, + text: 'Settings', + url: `${folder.url}/settings`, + }); + } + + return model; } export function getLoadingNav(tabIndex: number): NavModel { @@ -41,9 +49,9 @@ export function getLoadingNav(tabIndex: number): NavModel { uid: 'loading', title: 'Loading', url: 'url', - canSave: false, - canEdit: false, - canAdmin: false, + canSave: true, + canEdit: true, + canAdmin: true, version: 0, }); diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts new file mode 100644 index 00000000000..15ef3907bca --- /dev/null +++ b/public/app/features/library-panels/state/api.ts @@ -0,0 +1,57 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; + +export interface LibraryPanelDTO { + id: number; + orgId: number; + folderId: number; + uid: string; + name: string; + model: any; + meta: LibraryPanelDTOMeta; +} + +export interface LibraryPanelDTOMeta { + canEdit: boolean; + created: string; + updated: string; + createdBy: LibraryPanelDTOMetaUser; + updatedBy: LibraryPanelDTOMetaUser; +} + +export interface LibraryPanelDTOMetaUser { + id: number; + name: string; + avatarUrl: string; +} + +export async function getLibraryPanels(): Promise { + const { result } = await getBackendSrv().get(`/api/library-panels`); + return result; +} + +export async function addLibraryPanel(panelSaveModel: any, folderId: number): Promise { + const { result } = await getBackendSrv().post(`/api/library-panels`, { + folderId, + name: panelSaveModel.title, + model: panelSaveModel, + }); + return result; +} + +export async function updateLibraryPanel(panelSaveModel: any, folderId: number): Promise { + const { result } = await getBackendSrv().patch(`/api/library-panels/${panelSaveModel.libraryPanel.uid}`, { + folderId, + name: panelSaveModel.title, + model: panelSaveModel, + }); + return result; +} + +export function deleteLibraryPanel(uid: string): Promise<{ message: string }> { + return getBackendSrv().delete(`/api/library-panels/${uid}`); +} + +export async function getLibraryPanelConnectedDashboards(libraryPanelUid: string): Promise { + const { result } = await getBackendSrv().get(`/api/library-panels/${libraryPanelUid}/dashboards`); + return result; +} diff --git a/public/app/features/panel/query_ctrl.ts b/public/app/features/panel/query_ctrl.ts index aa3b3016135..c0a2d46b531 100644 --- a/public/app/features/panel/query_ctrl.ts +++ b/public/app/features/panel/query_ctrl.ts @@ -2,11 +2,11 @@ import _ from 'lodash'; import { auto } from 'angular'; export class QueryCtrl { - target: any; - datasource: any; - panelCtrl: any; + target!: any; + datasource!: any; + panelCtrl!: any; panel: any; - hasRawMode: boolean; + hasRawMode!: boolean; error?: string | null; isLastQuery: boolean; diff --git a/public/app/features/playlist/playlists_ctrl.ts b/public/app/features/playlist/playlists_ctrl.ts index e42e0e262ca..7885d1430fd 100644 --- a/public/app/features/playlist/playlists_ctrl.ts +++ b/public/app/features/playlist/playlists_ctrl.ts @@ -1,13 +1,12 @@ import { IScope } from 'angular'; import _ from 'lodash'; import { AppEvents } from '@grafana/data'; -import { OrgRole } from 'app/types'; +import { OrgRole, AppEventEmitter, CoreEvents } from 'app/types'; import { getBackendSrv } from '@grafana/runtime'; import coreModule from '../../core/core_module'; import config from '../../core/config'; import { NavModelSrv } from 'app/core/nav_model_srv'; -import { AppEventEmitter, CoreEvents } from 'app/types'; import { promiseToDigest } from '../../core/utils/promiseToDigest'; export class PlaylistsCtrl { diff --git a/public/app/features/playlist/specs/playlist_edit_ctrl.test.ts b/public/app/features/playlist/specs/playlist_edit_ctrl.test.ts index 5af2744c225..a28ef698a44 100644 --- a/public/app/features/playlist/specs/playlist_edit_ctrl.test.ts +++ b/public/app/features/playlist/specs/playlist_edit_ctrl.test.ts @@ -1,4 +1,3 @@ -import '../playlist_edit_ctrl'; import { PlaylistEditCtrl } from '../playlist_edit_ctrl'; import { ILocationService, IScope } from 'angular'; import { AppEventEmitter } from '../../../types'; diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 09f594b8ab0..b2ab9dc919a 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -54,7 +54,15 @@ export class DatasourceSrv implements DataSourceService { // For this we just pick the current or first data source in the variable if (nameOrUid[0] === '$') { const interpolatedName = this.templateSrv.replace(nameOrUid, {}, variableInterpolation); - const dsSettings = this.settingsMapByUid[interpolatedName] ?? this.settingsMapByName[interpolatedName]; + + let dsSettings; + + if (interpolatedName === 'default') { + dsSettings = this.settingsMapByName[this.defaultName]; + } else { + dsSettings = this.settingsMapByUid[interpolatedName] ?? this.settingsMapByName[interpolatedName]; + } + if (!dsSettings) { return undefined; } @@ -157,6 +165,15 @@ export class DatasourceSrv implements DataSourceService { if (filters.pluginId && x.meta.id !== filters.pluginId) { return false; } + if ( + !filters.all && + x.meta.metrics !== true && + x.meta.annotations !== true && + x.meta.tracing !== true && + x.meta.logs !== true + ) { + return false; + } return true; }); diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index da48fa60ef1..dbe0dc6c98c 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -21,17 +21,6 @@ import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import TableModel from 'app/core/table_model'; import { coreModule, appEvents, contextSrv } from 'app/core/core'; -import { - DataSourcePlugin, - AppPlugin, - PanelPlugin, - PluginMeta, - DataSourcePluginMeta, - dateMath, - DataSourceApi, - DataSourceJsonData, - DataQuery, -} from '@grafana/data'; import * as flatten from 'app/core/utils/flatten'; import * as ticks from 'app/core/utils/ticks'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; @@ -129,7 +118,7 @@ exposeToPlugin('app/core/services/backend_srv', { }); exposeToPlugin('app/plugins/sdk', sdk); -exposeToPlugin('app/core/utils/datemath', dateMath); +exposeToPlugin('app/core/utils/datemath', grafanaData.dateMath); exposeToPlugin('app/core/utils/flatten', flatten); exposeToPlugin('app/core/utils/kbn', kbn); exposeToPlugin('app/core/utils/ticks', ticks); @@ -191,7 +180,7 @@ export async function importPluginModule(path: string): Promise { return grafanaRuntime.SystemJS.import(path); } -export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { +export function importDataSourcePlugin(meta: grafanaData.DataSourcePluginMeta): Promise { return importPluginModule(meta.module).then((pluginExports) => { if (pluginExports.plugin) { const dsPlugin = pluginExports.plugin as GenericDataSourcePlugin; @@ -200,10 +189,10 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise, - DataQuery, - DataSourceJsonData + const dsPlugin = new grafanaData.DataSourcePlugin< + grafanaData.DataSourceApi, + grafanaData.DataQuery, + grafanaData.DataSourceJsonData >(pluginExports.Datasource); dsPlugin.setComponentsFromLegacyExports(pluginExports); dsPlugin.meta = meta; @@ -214,9 +203,9 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { +export function importAppPlugin(meta: grafanaData.PluginMeta): Promise { return importPluginModule(meta.module).then((pluginExports) => { - const plugin = pluginExports.plugin ? (pluginExports.plugin as AppPlugin) : new AppPlugin(); + const plugin = pluginExports.plugin ? (pluginExports.plugin as grafanaData.AppPlugin) : new grafanaData.AppPlugin(); plugin.init(meta); plugin.meta = meta; plugin.setComponentsFromLegacyExports(pluginExports); @@ -228,11 +217,11 @@ import { getPanelPluginNotFound, getPanelPluginLoadError } from '../dashboard/da import { GenericDataSourcePlugin } from '../datasources/settings/PluginSettings'; interface PanelCache { - [key: string]: Promise; + [key: string]: Promise; } const panelCache: PanelCache = {}; -export function importPanelPlugin(id: string): Promise { +export function importPanelPlugin(id: string): Promise { const loaded = panelCache[id]; if (loaded) { @@ -248,9 +237,9 @@ export function importPanelPlugin(id: string): Promise { panelCache[id] = importPluginModule(meta.module) .then((pluginExports) => { if (pluginExports.plugin) { - return pluginExports.plugin as PanelPlugin; + return pluginExports.plugin as grafanaData.PanelPlugin; } else if (pluginExports.PanelCtrl) { - const plugin = new PanelPlugin(null); + const plugin = new grafanaData.PanelPlugin(null); plugin.angularPanelCtrl = pluginExports.PanelCtrl; return plugin; } diff --git a/public/app/features/plugins/plugin_page_ctrl.ts b/public/app/features/plugins/plugin_page_ctrl.ts index 3ed40d22e35..a2a2c5cad34 100644 --- a/public/app/features/plugins/plugin_page_ctrl.ts +++ b/public/app/features/plugins/plugin_page_ctrl.ts @@ -2,10 +2,9 @@ import angular from 'angular'; import _ from 'lodash'; import { getPluginSettings } from './PluginSettingsCache'; -import { PluginMeta } from '@grafana/data'; +import { PluginMeta, AppEvents } from '@grafana/data'; import { NavModelSrv } from 'app/core/core'; import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; -import { AppEvents } from '@grafana/data'; import { promiseToDigest } from '../../core/utils/promiseToDigest'; export class AppPageCtrl { diff --git a/public/app/features/plugins/specs/datasource_srv.test.ts b/public/app/features/plugins/specs/datasource_srv.test.ts index 8817efa358f..1ff4e03af9d 100644 --- a/public/app/features/plugins/specs/datasource_srv.test.ts +++ b/public/app/features/plugins/specs/datasource_srv.test.ts @@ -1,4 +1,3 @@ -import 'app/features/plugins/datasource_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DataSourceInstanceSettings, DataSourcePlugin } from '@grafana/data'; @@ -12,9 +11,18 @@ const templateSrv: any = { value: 'BBB', }, }, + { + type: 'datasource', + name: 'datasourceDefault', + current: { + value: 'default', + }, + }, ], replace: (v: string) => { - return v.replace('${datasource}', 'BBB'); + let result = v.replace('${datasource}', 'BBB'); + result = result.replace('${datasourceDefault}', 'default'); + return result; }, }; @@ -76,6 +84,12 @@ describe('datasource_srv', () => { uid: 'uid-code-Jaeger', meta: { tracing: true, id: 'jaeger' }, }, + CannotBeQueried: { + type: 'no-query', + name: 'no-query', + uid: 'no-query', + meta: { id: 'no-query' }, + }, }; describe('Given a list of data sources', () => { @@ -112,6 +126,12 @@ describe('datasource_srv', () => { expect(ds?.name).toBe('${datasource}'); expect(ds?.uid).toBe('uid-code-BBB'); }); + + it('should work with variable', () => { + const ds = dataSourceSrv.getInstanceSettings('${datasourceDefault}'); + expect(ds?.name).toBe('${datasourceDefault}'); + expect(ds?.uid).toBe('uid-code-BBB'); + }); }); describe('when getting external metric sources', () => { @@ -121,9 +141,17 @@ describe('datasource_srv', () => { }); }); + it('Should by default filter out data sources that cannot be queried', () => { + const list = dataSourceSrv.getList({}); + expect(list.find((x) => x.name === 'no-query')).toBeUndefined(); + const all = dataSourceSrv.getList({ all: true }); + expect(all.find((x) => x.name === 'no-query')).toBeDefined(); + }); + it('Can get list of data sources with variables: true', () => { const list = dataSourceSrv.getList({ metrics: true, variables: true }); - expect(list[0].name).toBe('${datasource}'); + expect(list[0].name).toBe('${datasourceDefault}'); + expect(list[1].name).toBe('${datasource}'); }); it('Can get list of data sources with tracing: true', () => { diff --git a/public/app/features/profile/ChangePasswordForm.tsx b/public/app/features/profile/ChangePasswordForm.tsx index 9937659a484..bf27dba2233 100644 --- a/public/app/features/profile/ChangePasswordForm.tsx +++ b/public/app/features/profile/ChangePasswordForm.tsx @@ -1,20 +1,27 @@ import React, { FC } from 'react'; import config from 'app/core/config'; +import { UserDTO } from 'app/types'; import { Button, LinkButton, Form, Field, Input, HorizontalGroup } from '@grafana/ui'; import { ChangePasswordFields } from 'app/core/utils/UserProvider'; import { css } from 'emotion'; export interface Props { + user: UserDTO; isSaving: boolean; onChangePassword: (payload: ChangePasswordFields) => void; } -export const ChangePasswordForm: FC = ({ onChangePassword, isSaving }) => { - const { ldapEnabled, authProxyEnabled } = config; +export const ChangePasswordForm: FC = ({ user, onChangePassword, isSaving }) => { + const { ldapEnabled, authProxyEnabled, disableLoginForm } = config; + const authSource = user.authLabels?.length && user.authLabels[0]; if (ldapEnabled || authProxyEnabled) { return

    You cannot change password when ldap or auth proxy authentication is enabled.

    ; } + if (authSource && disableLoginForm) { + return

    Password cannot be changed here!

    ; + } + return (
    { - render() { - const { navModel } = this.props; - return ( - - - {({ changePassword }, states) => ( - -

    Change Your Password

    - -
    - )} -
    -
    - ); - } -} +export const ChangePasswordPage: FC = ({ navModel }) => ( + + + {( + api: UserAPI, + states: LoadingStates, + teams: Team[], + orgs: UserOrg[], + sessions: UserSession[], + user?: UserDTO + ) => { + return ( + +

    Change Your Password

    + {states.loadUser ? ( + + ) : ( + + )} +
    + ); + }} +
    +
    +); function mapStateToProps(state: StoreState) { return { diff --git a/public/app/features/profile/UserOrganizations.tsx b/public/app/features/profile/UserOrganizations.tsx index a0a178ea2c0..901ce469b62 100644 --- a/public/app/features/profile/UserOrganizations.tsx +++ b/public/app/features/profile/UserOrganizations.tsx @@ -1,9 +1,9 @@ import React, { PureComponent } from 'react'; -import { User, UserOrg } from 'app/types'; +import { UserDTO, UserOrg } from 'app/types'; import { LoadingPlaceholder, Button } from '@grafana/ui'; export interface Props { - user: User; + user: UserDTO; orgs: UserOrg[]; isLoading: boolean; loadOrgs: () => void; diff --git a/public/app/features/profile/UserProfileEdit.tsx b/public/app/features/profile/UserProfileEdit.tsx index ec6c59189f2..135065856cd 100644 --- a/public/app/features/profile/UserProfileEdit.tsx +++ b/public/app/features/profile/UserProfileEdit.tsx @@ -6,7 +6,7 @@ import { config } from '@grafana/runtime'; import { NavModel } from '@grafana/data'; import { UserProvider, UserAPI, LoadingStates } from 'app/core/utils/UserProvider'; import { getNavModel } from 'app/core/selectors/navModel'; -import { User, Team, UserOrg, UserSession, StoreState } from 'app/types'; +import { UserDTO, Team, UserOrg, UserSession, StoreState } from 'app/types'; import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences'; import Page from 'app/core/components/Page/Page'; import { UserTeams } from './UserTeams'; @@ -21,7 +21,14 @@ export interface Props { export const UserProfileEdit: FC = ({ navModel }) => ( - {(api: UserAPI, states: LoadingStates, teams: Team[], orgs: UserOrg[], sessions: UserSession[], user: User) => { + {( + api: UserAPI, + states: LoadingStates, + teams: Team[], + orgs: UserOrg[], + sessions: UserSession[], + user?: UserDTO + ) => { return ( {states.loadUser ? ( @@ -30,7 +37,7 @@ export const UserProfileEdit: FC = ({ navModel }) => ( )} @@ -42,14 +49,14 @@ export const UserProfileEdit: FC = ({ navModel }) => ( setUserOrg={api.setUserOrg} loadOrgs={api.loadOrgs} orgs={orgs} - user={user} + user={user!} /> )} diff --git a/public/app/features/profile/UserProfileEditForm.tsx b/public/app/features/profile/UserProfileEditForm.tsx index c116d03c59a..71a0b7f600a 100644 --- a/public/app/features/profile/UserProfileEditForm.tsx +++ b/public/app/features/profile/UserProfileEditForm.tsx @@ -1,11 +1,11 @@ import React, { FC } from 'react'; import { Button, Tooltip, Icon, Form, Input, Field, FieldSet } from '@grafana/ui'; -import { User } from 'app/types'; +import { UserDTO } from 'app/types'; import config from 'app/core/config'; import { ProfileUpdateFields } from 'app/core/utils/UserProvider'; export interface Props { - user: User; + user: UserDTO; isSavingUser: boolean; updateProfile: (payload: ProfileUpdateFields) => void; } @@ -22,8 +22,14 @@ export const UserProfileEditForm: FC = ({ user, isSavingUser, updateProfi {({ register, errors }) => { return (
    - - + + } + /> void; diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 2a02f1352ce..3fe2fedced3 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -75,11 +75,13 @@ export class QueryEditorRow extends PureComponent { } getAngularQueryComponentScope(): AngularQueryComponentScope { - const { query, onChange, onRunQuery, queries } = this.props; + const { query, queries } = this.props; const { datasource } = this.state; const panel = new PanelModel({ targets: queries }); const dashboard = {} as DashboardModel; + const me = this; + return { datasource: datasource, target: query, @@ -87,8 +89,18 @@ export class QueryEditorRow extends PureComponent { dashboard: dashboard, refresh: () => { // Old angular editors modify the query model and just call refresh - onChange(query); - onRunQuery(); + // Important that this use this.props here so that as this fuction is only created on mount and it's + // important not to capture old prop functions in this closure + + // the "hide" attribute of the quries can be changed from the "outside", + // it will be applied to "this.props.query.hide", but not to "query.hide". + // so we have to apply it. + if (query.hide !== me.props.query.hide) { + query.hide = me.props.query.hide; + } + + this.props.onChange(query); + this.props.onRunQuery(); }, render: () => () => console.log('legacy render function called, it does nothing'), events: new EventBusSrv(), @@ -124,14 +136,16 @@ export class QueryEditorRow extends PureComponent { const { data, query } = this.props; if (data !== prevProps.data) { - this.setState({ data: filterPanelDataToQuery(data, query.refId) }); + const dataFilteredByRefId = filterPanelDataToQuery(data, query.refId); + + this.setState({ data: dataFilteredByRefId }); if (this.angularScope) { this.angularScope.range = getTimeSrv().timeRange(); } - if (this.angularQueryEditor) { - notifyAngularQueryEditorsOfData(this.angularScope!, data, this.angularQueryEditor); + if (this.angularQueryEditor && dataFilteredByRefId) { + notifyAngularQueryEditorsOfData(this.angularScope!, dataFilteredByRefId, this.angularQueryEditor); } } @@ -351,17 +365,7 @@ export class QueryEditorRow extends PureComponent { } } -// To avoid sending duplicate events for each row we have this global cached object here -// So we can check if we already emitted this legacy data event -let globalLastPanelDataCache: PanelData | null = null; - function notifyAngularQueryEditorsOfData(scope: AngularQueryComponentScope, data: PanelData, editor: AngularComponent) { - if (data === globalLastPanelDataCache) { - return; - } - - globalLastPanelDataCache = data; - if (data.state === LoadingState.Done) { const legacy = data.series.map((v) => toLegacyResponseData(v)); scope.events.emit(PanelEvents.dataReceived, legacy); diff --git a/public/app/features/query/components/QueryEditorRowTitle.tsx b/public/app/features/query/components/QueryEditorRowTitle.tsx index e6b5054486d..a1b5cf52c85 100644 --- a/public/app/features/query/components/QueryEditorRowTitle.tsx +++ b/public/app/features/query/components/QueryEditorRowTitle.tsx @@ -1,9 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import { css, cx } from 'emotion'; import { DataQuery, GrafanaTheme } from '@grafana/data'; import { Icon, Input, stylesFactory, useTheme, FieldValidationMessage } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; -import { useState } from 'react'; export interface Props { query: DataQuery; diff --git a/public/app/features/query/state/PanelQueryRunner.test.ts b/public/app/features/query/state/PanelQueryRunner.test.ts index dd557c80217..7645dd08475 100644 --- a/public/app/features/query/state/PanelQueryRunner.test.ts +++ b/public/app/features/query/state/PanelQueryRunner.test.ts @@ -9,14 +9,6 @@ jest.mock('@grafana/data', () => ({ import { PanelQueryRunner } from './PanelQueryRunner'; // Importing this way to be able to spy on grafana/data import * as grafanaData from '@grafana/data'; -import { - DataConfigSource, - DataQueryRequest, - DataTransformerConfig, - GrafanaTheme, - PanelData, - ScopedVars, -} from '@grafana/data'; import { DashboardModel } from '../../dashboard/state/index'; import { setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; import { Echo } from '../../../core/services/echo/Echo'; @@ -48,21 +40,25 @@ interface ScenarioContext { maxDataPoints?: number | null; dsInterval?: string; minInterval?: string; - scopedVars: ScopedVars; + scopedVars: grafanaData.ScopedVars; // Filled in by the Scenario runner - events?: PanelData[]; - res?: PanelData; - queryCalledWith?: DataQueryRequest; + events?: grafanaData.PanelData[]; + res?: grafanaData.PanelData; + queryCalledWith?: grafanaData.DataQueryRequest; runner: PanelQueryRunner; } type ScenarioFn = (ctx: ScenarioContext) => void; -function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn, panelConfig?: DataConfigSource) { +function describeQueryRunnerScenario( + description: string, + scenarioFn: ScenarioFn, + panelConfig?: grafanaData.DataConfigSource +) { describe(description, () => { let setupFn = () => {}; - const defaultPanelConfig: DataConfigSource = { + const defaultPanelConfig: grafanaData.DataConfigSource = { getFieldOverrideOptions: () => undefined, getTransformations: () => undefined, }; @@ -98,7 +94,7 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn const datasource: any = { name: 'TestDB', interval: ctx.dsInterval, - query: (options: DataQueryRequest) => { + query: (options: grafanaData.DataQueryRequest) => { ctx.queryCalledWith = options; return Promise.resolve(response); }, @@ -121,7 +117,7 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn ctx.runner = new PanelQueryRunner(panelConfig || defaultPanelConfig); ctx.runner.getData({ withTransforms: true, withFieldConfig: true }).subscribe({ - next: (data: PanelData) => { + next: (data: grafanaData.PanelData) => { ctx.res = data; ctx.events?.push(data); }, @@ -221,7 +217,7 @@ describe('PanelQueryRunner', () => { (ctx) => { it('should apply when field override options are set', async () => { ctx.runner.getData({ withTransforms: true, withFieldConfig: true }).subscribe({ - next: (data: PanelData) => { + next: (data: grafanaData.PanelData) => { return data; }, }); @@ -238,7 +234,7 @@ describe('PanelQueryRunner', () => { overrides: [], }, replaceVariables: (v) => v, - theme: {} as GrafanaTheme, + theme: {} as grafanaData.GrafanaTheme, }), getTransformations: () => undefined, } @@ -252,7 +248,7 @@ describe('PanelQueryRunner', () => { spy.mockClear(); ctx.runner.getData({ withTransforms: true, withFieldConfig: true }).subscribe({ - next: (data: PanelData) => { + next: (data: grafanaData.PanelData) => { return data; }, }); @@ -263,7 +259,7 @@ describe('PanelQueryRunner', () => { { getFieldOverrideOptions: () => undefined, // @ts-ignore - getTransformations: () => [({} as unknown) as DataTransformerConfig], + getTransformations: () => [({} as unknown) as grafanaData.DataTransformerConfig], } ); @@ -274,7 +270,7 @@ describe('PanelQueryRunner', () => { const spy = jest.spyOn(grafanaData, 'transformDataFrame'); spy.mockClear(); ctx.runner.getData({ withTransforms: false, withFieldConfig: true }).subscribe({ - next: (data: PanelData) => { + next: (data: grafanaData.PanelData) => { return data; }, }); @@ -284,7 +280,7 @@ describe('PanelQueryRunner', () => { it('should not apply field config when applyFieldConfig option is false', async () => { ctx.runner.getData({ withFieldConfig: false, withTransforms: true }).subscribe({ - next: (data: PanelData) => { + next: (data: grafanaData.PanelData) => { return data; }, }); @@ -302,10 +298,10 @@ describe('PanelQueryRunner', () => { overrides: [], }, replaceVariables: (v) => v, - theme: {} as GrafanaTheme, + theme: {} as grafanaData.GrafanaTheme, }), // @ts-ignore - getTransformations: () => [({} as unknown) as DataTransformerConfig], + getTransformations: () => [({} as unknown) as grafanaData.DataTransformerConfig], } ); @@ -316,7 +312,7 @@ describe('PanelQueryRunner', () => { const spy = jest.spyOn(grafanaData, 'transformDataFrame'); spy.mockClear(); ctx.runner.getData({ withTransforms: false, withFieldConfig: true }).subscribe({ - next: (data: PanelData) => { + next: (data: grafanaData.PanelData) => { return data; }, }); @@ -326,7 +322,7 @@ describe('PanelQueryRunner', () => { it('should not apply field config when applyFieldConfig option is false', async () => { ctx.runner.getData({ withFieldConfig: false, withTransforms: true }).subscribe({ - next: (data: PanelData) => { + next: (data: grafanaData.PanelData) => { return data; }, }); @@ -344,7 +340,7 @@ describe('PanelQueryRunner', () => { overrides: [], }, replaceVariables: (v) => v, - theme: {} as GrafanaTheme, + theme: {} as grafanaData.GrafanaTheme, }), // @ts-ignore getTransformations: () => [{}], diff --git a/public/app/features/query/state/runRequest.test.ts b/public/app/features/query/state/runRequest.test.ts index eec5ac41e5b..981ee3e5113 100644 --- a/public/app/features/query/state/runRequest.test.ts +++ b/public/app/features/query/state/runRequest.test.ts @@ -30,13 +30,13 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({ })); class ScenarioCtx { - ds: DataSourceApi; - request: DataQueryRequest; - subscriber: Subscriber; + ds!: DataSourceApi; + request!: DataQueryRequest; + subscriber!: Subscriber; isUnsubbed = false; setupFn: () => void = () => {}; - results: PanelData[]; - subscription: Subscription; + results!: PanelData[]; + subscription!: Subscription; wasStarted = false; error: Error | null = null; toStartTime = dateTime(); diff --git a/public/app/features/search/components/DashboardListPage.tsx b/public/app/features/search/components/DashboardListPage.tsx index 109638a0dc7..1373fa234d9 100644 --- a/public/app/features/search/components/DashboardListPage.tsx +++ b/public/app/features/search/components/DashboardListPage.tsx @@ -22,14 +22,14 @@ export const DashboardListPage: FC = memo(({ navModel, uid, url }) => { return Promise.resolve({ pageNavModel: navModel }); } - return loadFolderPage(uid!, 'manage-folder-dashboards').then(({ folder, model }) => { + return loadFolderPage(uid!).then(({ folder, folderNav }) => { const path = locationUtil.stripBaseFromUrl(folder.url); if (path !== location.pathname) { getLocationSrv().update({ path }); } - return { folder, pageNavModel: { ...navModel, ...model } }; + return { folder, pageNavModel: { ...navModel, main: folderNav } }; }); }, [uid]); @@ -41,6 +41,7 @@ export const DashboardListPage: FC = memo(({ navModel, uid, url }) => { ); }); + DashboardListPage.displayName = 'DashboardListPage'; const mapStateToProps: MapStateToProps = (state) => { diff --git a/public/app/features/search/components/DashboardSearch.test.tsx b/public/app/features/search/components/DashboardSearch.test.tsx index 7319e3c8598..df3117eb201 100644 --- a/public/app/features/search/components/DashboardSearch.test.tsx +++ b/public/app/features/search/components/DashboardSearch.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, fireEvent, screen, waitFor, act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import selectEvent from 'react-select-event'; import * as SearchSrv from 'app/core/services/search_srv'; import * as MockSearchSrv from 'app/core/services/__mocks__/search_srv'; import { DashboardSearch, Props } from './DashboardSearch'; @@ -50,6 +50,7 @@ describe('DashboardSearch', () => { folderIds: [], layout: SearchLayout.Folders, sort: undefined, + prevSort: null, }); }); @@ -71,6 +72,7 @@ describe('DashboardSearch', () => { folderIds: [], layout: SearchLayout.Folders, sort: undefined, + prevSort: null, }); }); @@ -94,12 +96,9 @@ describe('DashboardSearch', () => { setup(); await waitFor(() => screen.getByLabelText('Tag filter')); - // Get the actual element for the underlying Select component, since Select doesn't accept aria- props - const tagComponent = screen.getByLabelText('Tag filter').querySelector('div') as Node; - fireEvent.keyDown(tagComponent, { keyCode: 40 }); - const firstTag = await screen.findByText('tag1'); - userEvent.click(firstTag); + const tagComponent = screen.getByLabelText('Tag filter'); + await selectEvent.select(tagComponent, 'tag1'); expect(tagComponent).toBeInTheDocument(); @@ -113,6 +112,7 @@ describe('DashboardSearch', () => { folderIds: [], layout: SearchLayout.Folders, sort: undefined, + prevSort: null, }) ); }); diff --git a/public/app/features/search/components/SearchItem.tsx b/public/app/features/search/components/SearchItem.tsx index dc9dd742b44..b60e4f97151 100644 --- a/public/app/features/search/components/SearchItem.tsx +++ b/public/app/features/search/components/SearchItem.tsx @@ -1,7 +1,7 @@ import React, { FC, useCallback } from 'react'; import { css } from 'emotion'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; -import { TagList, Card, useStyles } from '@grafana/ui'; +import { TagList, Card, useStyles, Icon, IconName } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; import { DashboardSectionItem, OnToggleChecked } from '../types'; import { SearchCheckbox } from './SearchCheckbox'; @@ -16,6 +16,15 @@ export interface Props { const selectors = e2eSelectors.pages.Dashboards; +const getIconFromMeta = (meta = ''): IconName => { + const metaIconMap = new Map([ + ['errors', 'info-circle'], + ['views', 'eye'], + ]); + + return metaIconMap.has(meta) ? metaIconMap.get(meta)! : 'sort-amount-down'; +}; + export const SearchItem: FC = ({ item, editable, onToggleChecked, onTagSelected }) => { const styles = useStyles(getStyles); const tagSelected = useCallback((tag: string, event: React.MouseEvent) => { @@ -32,6 +41,7 @@ export const SearchItem: FC = ({ item, editable, onToggleChecked, onTagSe [item] ); + const folderTitle = item.folderTitle || 'General'; return ( = ({ item, editable, onToggleChecked, onTagSe - {item.folderTitle && {item.folderTitle}} + + + + {folderTitle} + + {item.sortMetaName && ( + + + {item.sortMeta} {item.sortMetaName} + + )} + @@ -56,5 +77,15 @@ const getStyles = (theme: GrafanaTheme) => { container: css` padding: ${theme.spacing.sm} ${theme.spacing.md}; `, + metaContainer: css` + display: flex; + align-items: center; + margin-right: ${theme.spacing.sm}; + + svg { + margin-right: ${theme.spacing.xs}; + margin-bottom: 0; + } + `, }; }; diff --git a/public/app/features/search/components/SearchResultsFilter.test.tsx b/public/app/features/search/components/SearchResultsFilter.test.tsx index 4d5d1805d83..0aaf8ac4629 100644 --- a/public/app/features/search/components/SearchResultsFilter.test.tsx +++ b/public/app/features/search/components/SearchResultsFilter.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; +import selectEvent from 'react-select-event'; import { Props, SearchResultsFilter } from './SearchResultsFilter'; import { SearchLayout } from '../types'; @@ -14,6 +15,7 @@ beforeEach(() => { const searchQuery = { starred: false, sort: null, + prevSort: null, tag: ['tag'], query: '', skipRecent: true, @@ -80,9 +82,8 @@ describe('SearchResultsFilter', () => { query: { ...searchQuery, tag: [] }, }); const tagComponent = await screen.findByLabelText('Tag filter'); + await selectEvent.select(tagComponent, 'tag1'); - fireEvent.keyDown(tagComponent.querySelector('div') as Node, { keyCode: 40 }); - fireEvent.click(await screen.findByText('tag1')); expect(mockFilterByTags).toHaveBeenCalledTimes(1); expect(mockFilterByTags).toHaveBeenCalledWith(['tag1']); }); diff --git a/public/app/features/search/components/SectionHeader.tsx b/public/app/features/search/components/SectionHeader.tsx index 71e07f6cb0f..b033cd447f5 100644 --- a/public/app/features/search/components/SectionHeader.tsx +++ b/public/app/features/search/components/SectionHeader.tsx @@ -60,7 +60,6 @@ export const SectionHeader: FC = ({ )}
    - {section.itemsFetching ? : }
    ); diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts index beb2c73f228..c772ce435e6 100644 --- a/public/app/features/search/constants.ts +++ b/public/app/features/search/constants.ts @@ -2,6 +2,6 @@ export const NO_ID_SECTIONS = ['Recent', 'Starred']; // Height of the search result item export const SEARCH_ITEM_HEIGHT = 62; export const SEARCH_ITEM_MARGIN = 8; -export const DEFAULT_SORT = { label: 'A-Z', value: 'alpha-asc' }; +export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' }; export const SECTION_STORAGE_KEY = 'search.sections'; export const GENERAL_FOLDER_ID = 0; diff --git a/public/app/features/search/hooks/useSearchQuery.ts b/public/app/features/search/hooks/useSearchQuery.ts index 2e921e21db2..de5f39a1c30 100644 --- a/public/app/features/search/hooks/useSearchQuery.ts +++ b/public/app/features/search/hooks/useSearchQuery.ts @@ -51,6 +51,10 @@ export const useSearchQuery = (queryParams: Partial, updateLocat const onLayoutChange = (layout: SearchLayout) => { dispatch({ type: LAYOUT_CHANGE, payload: layout }); + if (layout === SearchLayout.Folders) { + updateLocationQuery({ layout, sort: null }); + return; + } updateLocationQuery({ layout }); }; diff --git a/public/app/features/search/loaders.ts b/public/app/features/search/loaders.ts index a3a7f61f763..98df76a95b6 100644 --- a/public/app/features/search/loaders.ts +++ b/public/app/features/search/loaders.ts @@ -1,59 +1,11 @@ import { backendSrv } from 'app/core/services/backend_srv'; -import { NavModel } from '@grafana/data'; - -export const loadFolderPage = (uid: string, activeChildId: string) => { - const navModel: Pick = { - main: { - icon: 'folder-open', - id: 'manage-folder', - subTitle: 'Manage folder dashboards & permissions', - url: '', - text: '', - breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }], - children: [ - { - active: activeChildId === 'manage-folder-dashboards', - icon: 'th-large', - id: 'manage-folder-dashboards', - text: 'Dashboards', - url: 'dashboards', - }, - { - active: activeChildId === 'manage-folder-permissions', - icon: 'lock', - id: 'manage-folder-permissions', - text: 'Permissions', - url: 'dashboards/permissions', - }, - { - active: activeChildId === 'manage-folder-settings', - icon: 'cog', - id: 'manage-folder-settings', - text: 'Settings', - url: 'dashboards/settings', - }, - ], - }, - }; +import { buildNavModel } from '../folders/state/navModel'; +export const loadFolderPage = (uid: string) => { return backendSrv.getFolderByUid(uid).then((folder) => { - const folderTitle = folder.title; - const folderUrl = folder.url; - navModel.main.text = folderTitle; + const navModel = buildNavModel(folder); + navModel.children![0].active = true; - const dashTab = navModel.main.children!.find((child: any) => child.id === 'manage-folder-dashboards'); - dashTab!.url = folderUrl; - - if (folder.canAdmin) { - const permTab = navModel.main.children!.find((child: any) => child.id === 'manage-folder-permissions'); - permTab!.url = folderUrl + '/permissions'; - - const settingsTab = navModel.main.children!.find((child: any) => child.id === 'manage-folder-settings'); - settingsTab!.url = folderUrl + '/settings'; - } else { - navModel.main.children = [dashTab!]; - } - - return { folder, model: navModel }; + return { folder, folderNav: navModel }; }); }; diff --git a/public/app/features/search/reducers/searchQueryReducer.ts b/public/app/features/search/reducers/searchQueryReducer.ts index e2848c55e26..02ad3288ace 100644 --- a/public/app/features/search/reducers/searchQueryReducer.ts +++ b/public/app/features/search/reducers/searchQueryReducer.ts @@ -20,6 +20,7 @@ export const defaultQuery: DashboardQuery = { folderIds: [], sort: null, layout: SearchLayout.Folders, + prevSort: null, }; export const defaultQueryParams: RouteParams = { @@ -58,9 +59,9 @@ export const queryReducer = (state: DashboardQuery, action: SearchAction) => { case LAYOUT_CHANGE: { const layout = action.payload; if (state.sort && layout === SearchLayout.Folders) { - return { ...state, layout, sort: null }; + return { ...state, layout, sort: null, prevSort: state.sort }; } - return { ...state, layout }; + return { ...state, layout, sort: state.prevSort }; } default: return state; diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index a7cc34b9a76..d24dd9dd917 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -41,6 +41,8 @@ export interface DashboardSectionItem { uid?: string; uri: string; url: string; + sortMeta?: number; + sortMetaName?: string; } export interface DashboardSearchHit extends DashboardSectionItem, DashboardSection {} @@ -67,6 +69,8 @@ export interface DashboardQuery { skipStarred: boolean; folderIds: number[]; sort: SelectableValue | null; + // Save sorting data between layouts + prevSort: SelectableValue | null; layout: SearchLayout; } diff --git a/public/app/features/variables/adapters.ts b/public/app/features/variables/adapters.ts index 6bfba8bd37c..742dd1c8e0f 100644 --- a/public/app/features/variables/adapters.ts +++ b/public/app/features/variables/adapters.ts @@ -2,17 +2,7 @@ import { ComponentType } from 'react'; import { Reducer } from 'redux'; import { Registry, UrlQueryValue, VariableType } from '@grafana/data'; -import { - AdHocVariableModel, - ConstantVariableModel, - CustomVariableModel, - DataSourceVariableModel, - IntervalVariableModel, - QueryVariableModel, - TextBoxVariableModel, - VariableModel, - VariableOption, -} from './types'; +import { VariableModel, VariableOption } from './types'; import { VariableEditorProps } from './editor/types'; import { VariablesState } from './state/variablesReducer'; import { VariablePickerProps } from './pickers/types'; @@ -36,22 +26,12 @@ export interface VariableAdapter { updateOptions: (variable: Model, searchFilter?: string) => Promise; getSaveModel: (variable: Model, saveCurrentAsDefault?: boolean) => Partial; getValueForUrl: (variable: Model) => string | string[]; - picker: ComponentType; - editor: ComponentType; + picker: ComponentType>; + editor: ComponentType>; reducer: Reducer; beforeAdding?: (model: any) => any; } -export type VariableModels = - | QueryVariableModel - | CustomVariableModel - | TextBoxVariableModel - | ConstantVariableModel - | DataSourceVariableModel - | IntervalVariableModel - | AdHocVariableModel; -export type VariableTypeRegistry = Registry>; - export const getDefaultVariableAdapters = () => [ createQueryVariableAdapter(), createCustomVariableAdapter(), @@ -63,4 +43,4 @@ export const getDefaultVariableAdapters = () => [ createSystemVariableAdapter(), ]; -export const variableAdapters: VariableTypeRegistry = new Registry>(); +export const variableAdapters = new Registry>(); diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts index cd3162bec79..1f753a540ce 100644 --- a/public/app/features/variables/adhoc/actions.test.ts +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -3,8 +3,7 @@ import { DataSourcePluginMeta, DataSourceSelectItem } from '@grafana/data'; import { variableAdapters } from '../adapters'; import { createAdHocVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; -import { getRootReducer } from '../state/helpers'; +import { getRootReducer, RootReducerType } from '../state/helpers'; import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { addFilter, @@ -19,7 +18,6 @@ import { import { filterAdded, filterRemoved, filtersRestored, filterUpdated } from './reducer'; import { addVariable, changeVariableProp } from '../state/sharedReducer'; import { updateLocation } from 'app/core/actions'; -import { DashboardState, LocationState } from 'app/types'; import { VariableModel } from 'app/features/variables/types'; import { changeVariableEditorExtended, setIdInEditor } from '../editor/reducer'; import { adHocBuilder } from '../shared/testing/builders'; @@ -34,12 +32,6 @@ jest.mock('app/features/plugins/datasource_srv', () => ({ })), })); -type ReducersUsedInContext = { - templating: TemplatingState; - dashboard: DashboardState; - location: LocationState; -}; - variableAdapters.setInit(() => [createAdHocVariableAdapter()]); describe('adhoc actions', () => { @@ -66,7 +58,7 @@ describe('adhoc actions', () => { .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -90,7 +82,7 @@ describe('adhoc actions', () => { operator: '=', }; - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -123,7 +115,7 @@ describe('adhoc actions', () => { .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -155,7 +147,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource(options.datasource).build(); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(existing)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -194,7 +186,7 @@ describe('adhoc actions', () => { const update = { index: 0, filter: updated }; - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(changeFilter('elastic-filter', update), true); @@ -230,7 +222,7 @@ describe('adhoc actions', () => { .withDatasource('elasticsearch') .build(); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); @@ -261,7 +253,7 @@ describe('adhoc actions', () => { .withDatasource('elasticsearch') .build(); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); @@ -284,7 +276,7 @@ describe('adhoc actions', () => { .withDatasource('elasticsearch') .build(); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); @@ -314,7 +306,7 @@ describe('adhoc actions', () => { .withDatasource('elasticsearch') .build(); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); @@ -349,7 +341,7 @@ describe('adhoc actions', () => { { ...existing, name: 'value-2' }, ]; - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(setFiltersFromUrl('elastic-filter', fromUrl), true); @@ -380,7 +372,7 @@ describe('adhoc actions', () => { getMetricSources.mockRestore(); getMetricSources.mockReturnValue(datasources); - const tester = reduxTester() + const tester = reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(initAdHocVariableEditor()); @@ -406,7 +398,7 @@ describe('adhoc actions', () => { getDatasource.mockRestore(); getDatasource.mockResolvedValue(null); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) @@ -434,7 +426,7 @@ describe('adhoc actions', () => { getTagKeys: () => {}, }); - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) diff --git a/public/app/features/variables/adhoc/picker/AdHocFilterValue.tsx b/public/app/features/variables/adhoc/picker/AdHocFilterValue.tsx index 9a3eb340974..fd8168b448b 100644 --- a/public/app/features/variables/adhoc/picker/AdHocFilterValue.tsx +++ b/public/app/features/variables/adhoc/picker/AdHocFilterValue.tsx @@ -6,7 +6,7 @@ import { MetricFindValue, SelectableValue } from '@grafana/data'; interface Props { datasource: string; filterKey: string; - filterValue: string | null; + filterValue?: string; onChange: (item: SelectableValue) => void; placeHolder?: string; } diff --git a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx index 07e86a9bff0..b77527e9286 100644 --- a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx +++ b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx @@ -1,6 +1,5 @@ import React, { PureComponent, ReactNode } from 'react'; -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; -import { StoreState } from 'app/types'; +import { connect, ConnectedProps } from 'react-redux'; import { AdHocVariableFilter, AdHocVariableModel } from 'app/features/variables/types'; import { VariablePickerProps } from '../../pickers/types'; import { SelectableValue } from '@grafana/data'; @@ -10,20 +9,20 @@ import { addFilter, changeFilter, removeFilter } from '../actions'; import { REMOVE_FILTER_KEY } from './AdHocFilterKey'; import { AdHocFilterRenderer } from './AdHocFilterRenderer'; +const mapDispatchToProps = { + addFilter, + removeFilter, + changeFilter, +}; + +const connector = connect(null, mapDispatchToProps); + interface OwnProps extends VariablePickerProps {} -interface ConnectedProps {} - -interface DispatchProps { - addFilter: typeof addFilter; - removeFilter: typeof removeFilter; - changeFilter: typeof changeFilter; -} - -type Props = OwnProps & ConnectedProps & DispatchProps; +type Props = OwnProps & ConnectedProps; export class AdHocPickerUnconnected extends PureComponent { - onChange = (index: number, prop: string) => (key: SelectableValue) => { + onChange = (index: number, prop: string) => (key: SelectableValue) => { const { id, filters } = this.props.variable; const { value } = key; @@ -85,13 +84,5 @@ export class AdHocPickerUnconnected extends PureComponent { } } -const mapDispatchToProps: MapDispatchToProps = { - addFilter, - removeFilter, - changeFilter, -}; - -const mapStateToProps: MapStateToProps = (state) => ({}); - -export const AdHocPicker = connect(mapStateToProps, mapDispatchToProps)(AdHocPickerUnconnected); +export const AdHocPicker = connector(AdHocPickerUnconnected); AdHocPicker.displayName = 'AdHocPicker'; diff --git a/public/app/features/variables/adhoc/urlParser.test.ts b/public/app/features/variables/adhoc/urlParser.test.ts index 86fb993558e..df6cda2fae5 100644 --- a/public/app/features/variables/adhoc/urlParser.test.ts +++ b/public/app/features/variables/adhoc/urlParser.test.ts @@ -78,6 +78,42 @@ describe('urlParser', () => { }); }); + describe('parsing toUrl with filters with number values', () => { + it('then url params should be correct', () => { + const a = ({ + value: 1974, + key: 'key', + operator: '=', + condition: '', + } as unknown) as AdHocVariableFilter; + + const filters: AdHocVariableFilter[] = [a]; + + const expectedA = `key|=|1974`; + const expected: string[] = [expectedA]; + + expect(toUrl(filters)).toEqual(expected); + }); + }); + + describe('parsing toUrl with filters with boolean values', () => { + it('then url params should be correct', () => { + const a = ({ + value: false, + key: 'key', + operator: '=', + condition: '', + } as unknown) as AdHocVariableFilter; + + const filters: AdHocVariableFilter[] = [a]; + + const expectedA = `key|=|false`; + const expected: string[] = [expectedA]; + + expect(toUrl(filters)).toEqual(expected); + }); + }); + describe('parsing toFilters with url containing no filters as string', () => { it('then url params should be correct', () => { const url: UrlQueryValue = ''; diff --git a/public/app/features/variables/adhoc/urlParser.ts b/public/app/features/variables/adhoc/urlParser.ts index 6c2372591ff..8003fd159ab 100644 --- a/public/app/features/variables/adhoc/urlParser.ts +++ b/public/app/features/variables/adhoc/urlParser.ts @@ -17,11 +17,19 @@ export const toFilters = (value: UrlQueryValue): AdHocVariableFilter[] => { }; function escapeDelimiter(value: string | undefined): string { - return value?.replace(/\|/g, '__gfp__') ?? ''; + if (value === null || value === undefined) { + return ''; + } + + return /\|/g[Symbol.replace](value, '__gfp__'); } function unescapeDelimiter(value: string | undefined): string { - return value?.replace(/__gfp__/g, '|') ?? ''; + if (value === null || value === undefined) { + return ''; + } + + return /__gfp__/g[Symbol.replace](value, '|'); } function toArray(filter: AdHocVariableFilter): string[] { diff --git a/public/app/features/variables/constant/ConstantVariableEditor.tsx b/public/app/features/variables/constant/ConstantVariableEditor.tsx index 3f41f505eb1..fdac8d6a304 100644 --- a/public/app/features/variables/constant/ConstantVariableEditor.tsx +++ b/public/app/features/variables/constant/ConstantVariableEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FocusEvent, PureComponent } from 'react'; +import React, { FormEvent, PureComponent } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { VerticalGroup } from '@grafana/ui'; @@ -10,17 +10,17 @@ import { VariableTextField } from '../editor/VariableTextField'; export interface Props extends VariableEditorProps {} export class ConstantVariableEditor extends PureComponent { - onChange = (event: ChangeEvent) => { + onChange = (event: FormEvent) => { this.props.onPropChange({ propName: 'query', - propValue: event.target.value, + propValue: event.currentTarget.value, }); }; - onBlur = (event: FocusEvent) => { + onBlur = (event: FormEvent) => { this.props.onPropChange({ propName: 'query', - propValue: event.target.value, + propValue: event.currentTarget.value, updateOptions: true, }); }; diff --git a/public/app/features/variables/constant/actions.test.ts b/public/app/features/variables/constant/actions.test.ts index bfd31ec5e6b..b483c8ad547 100644 --- a/public/app/features/variables/constant/actions.test.ts +++ b/public/app/features/variables/constant/actions.test.ts @@ -1,9 +1,8 @@ import { variableAdapters } from '../adapters'; import { createConstantVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; import { updateConstantVariableOptions } from './actions'; -import { getRootReducer } from '../state/helpers'; +import { getRootReducer, RootReducerType } from '../state/helpers'; import { ConstantVariableModel, initialVariableModelState, VariableOption } from '../types'; import { toVariablePayload } from '../state/types'; import { createConstantOptionsFromQuery } from './reducer'; @@ -35,19 +34,15 @@ describe('constant actions', () => { query: 'A', }; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(updateConstantVariableOptions(toVariablePayload(variable)), true); - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [createAction, setCurrentAction] = actions; - const expectedNumberOfActions = 2; - - expect(createAction).toEqual(createConstantOptionsFromQuery(toVariablePayload(variable))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + createConstantOptionsFromQuery(toVariablePayload(variable)), + setCurrentVariableValue(toVariablePayload(variable, { option })) + ); }); }); }); diff --git a/public/app/features/variables/constant/adapter.ts b/public/app/features/variables/constant/adapter.ts index f5641fd073c..c3cce4a17b3 100644 --- a/public/app/features/variables/constant/adapter.ts +++ b/public/app/features/variables/constant/adapter.ts @@ -4,10 +4,10 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { constantVariableReducer, initialConstantVariableModelState } from './reducer'; -import { OptionsPicker } from '../pickers'; import { ConstantVariableEditor } from './ConstantVariableEditor'; import { updateConstantVariableOptions } from './actions'; import { toVariableIdentifier } from '../state/types'; +import { optionPickerFactory } from '../pickers'; export const createConstantVariableAdapter = (): VariableAdapter => { return { @@ -16,7 +16,7 @@ export const createConstantVariableAdapter = (): VariableAdapter(), editor: ConstantVariableEditor, dependsOn: () => { return false; diff --git a/public/app/features/variables/custom/CustomVariableEditor.tsx b/public/app/features/variables/custom/CustomVariableEditor.tsx index d2161ce7200..375023384ca 100644 --- a/public/app/features/variables/custom/CustomVariableEditor.tsx +++ b/public/app/features/variables/custom/CustomVariableEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FocusEvent, PureComponent } from 'react'; +import React, { FormEvent, PureComponent } from 'react'; import { CustomVariableModel, VariableWithMultiSupport } from '../types'; import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; @@ -21,10 +21,10 @@ interface DispatchProps { export type Props = OwnProps & ConnectedProps & DispatchProps; class CustomVariableEditorUnconnected extends PureComponent { - onChange = (event: ChangeEvent) => { + onChange = (event: FormEvent) => { this.props.onPropChange({ propName: 'query', - propValue: event.target.value, + propValue: event.currentTarget.value, }); }; @@ -32,10 +32,10 @@ class CustomVariableEditorUnconnected extends PureComponent { this.props.onPropChange({ propName, propValue, updateOptions: true }); }; - onBlur = (event: FocusEvent) => { + onBlur = (event: FormEvent) => { this.props.onPropChange({ propName: 'query', - propValue: event.target.value, + propValue: event.currentTarget.value, updateOptions: true, }); }; diff --git a/public/app/features/variables/custom/actions.test.ts b/public/app/features/variables/custom/actions.test.ts index 190a4b02de9..c6508944b0d 100644 --- a/public/app/features/variables/custom/actions.test.ts +++ b/public/app/features/variables/custom/actions.test.ts @@ -2,11 +2,10 @@ import { variableAdapters } from '../adapters'; import { updateCustomVariableOptions } from './actions'; import { createCustomVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer } from '../state/helpers'; +import { getRootReducer, RootReducerType } from '../state/helpers'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types'; import { toVariablePayload } from '../state/types'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; -import { TemplatingState } from '../state/reducers'; import { createCustomOptionsFromQuery } from './reducer'; describe('custom actions', () => { @@ -48,19 +47,15 @@ describe('custom actions', () => { includeAll: false, }; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(updateCustomVariableOptions(toVariablePayload(variable)), true); - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [createAction, setCurrentAction] = actions; - const expectedNumberOfActions = 2; - - expect(createAction).toEqual(createCustomOptionsFromQuery(toVariablePayload(variable))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + createCustomOptionsFromQuery(toVariablePayload(variable)), + setCurrentVariableValue(toVariablePayload(variable, { option })) + ); }); }); }); diff --git a/public/app/features/variables/custom/adapter.ts b/public/app/features/variables/custom/adapter.ts index eb766059435..accd4ccfd73 100644 --- a/public/app/features/variables/custom/adapter.ts +++ b/public/app/features/variables/custom/adapter.ts @@ -4,11 +4,11 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { customVariableReducer, initialCustomVariableModelState } from './reducer'; -import { OptionsPicker } from '../pickers'; import { CustomVariableEditor } from './CustomVariableEditor'; import { updateCustomVariableOptions } from './actions'; import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types'; import { isAllVariable } from '../utils'; +import { optionPickerFactory } from '../pickers'; export const createCustomVariableAdapter = (): VariableAdapter => { return { @@ -17,7 +17,7 @@ export const createCustomVariableAdapter = (): VariableAdapter(), editor: CustomVariableEditor, dependsOn: () => { return false; diff --git a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx index 56b377b844a..3bbe23d8b6e 100644 --- a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx +++ b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FocusEvent, PureComponent } from 'react'; +import React, { FormEvent, PureComponent } from 'react'; import { MapDispatchToProps, MapStateToProps } from 'react-redux'; import { InlineFieldRow, VerticalGroup } from '@grafana/ui'; @@ -34,17 +34,17 @@ export class DataSourceVariableEditorUnConnected extends PureComponent { this.props.initDataSourceVariableEditor(); } - onRegExChange = (event: ChangeEvent) => { + onRegExChange = (event: FormEvent) => { this.props.onPropChange({ propName: 'regex', - propValue: event.target.value, + propValue: event.currentTarget.value, }); }; - onRegExBlur = (event: FocusEvent) => { + onRegExBlur = (event: FormEvent) => { this.props.onPropChange({ propName: 'regex', - propValue: event.target.value, + propValue: event.currentTarget.value, updateOptions: true, }); }; diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index be8b0da2f87..01cc09e79a1 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -1,6 +1,7 @@ +import { DataSourceInstanceSettings } from '@grafana/data'; + import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from '../state/reducers'; -import { getRootReducer } from '../state/helpers'; +import { getRootReducer, RootReducerType } from '../state/helpers'; import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { variableAdapters } from '../adapters'; import { createDataSourceVariableAdapter } from './adapter'; @@ -9,12 +10,12 @@ import { initDataSourceVariableEditor, updateDataSourceVariableOptions, } from './actions'; -import { DataSourceInstanceSettings, DataSourceJsonData, DataSourcePluginMeta } from '@grafana/data'; import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; import { createDataSourceOptions } from './reducer'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { changeVariableEditorExtended } from '../editor/reducer'; import { datasourceBuilder } from '../shared/testing/builders'; +import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; interface Args { sources?: DataSourceInstanceSettings[]; @@ -47,7 +48,7 @@ describe('data source actions', () => { query: 'mock-data-id', }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) @@ -62,10 +63,7 @@ describe('data source actions', () => { toVariablePayload( { type: 'datasource', id: '0' }, { - sources: [ - { name: 'first-name', value: 'first-name', meta }, - { name: 'second-name', value: 'second-name', meta }, - ], + sources, regex: (undefined as unknown) as RegExp, } ) @@ -98,7 +96,7 @@ describe('data source actions', () => { regex: '/.*(second-name).*/', }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) @@ -113,10 +111,7 @@ describe('data source actions', () => { toVariablePayload( { type: 'datasource', id: '0' }, { - sources: [ - { name: 'first-name', value: 'first-name', meta }, - { name: 'second-name', value: 'second-name', meta }, - ], + sources, regex: /.*(second-name).*/, } ) @@ -146,7 +141,7 @@ describe('data source actions', () => { const { dependencies, getListMock, getDatasourceSrvMock } = getTestContext({ sources }); - await reduxTester<{ templating: TemplatingState }>() + await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(initDataSourceVariableEditor(dependencies)) .thenDispatchedActionsShouldEqual( @@ -165,14 +160,3 @@ describe('data source actions', () => { }); }); }); - -function getDataSourceInstanceSetting(name: string, meta: DataSourcePluginMeta): DataSourceInstanceSettings { - return { - id: 1, - uid: '', - type: '', - name, - meta, - jsonData: ({} as unknown) as DataSourceJsonData, - }; -} diff --git a/public/app/features/variables/datasource/actions.ts b/public/app/features/variables/datasource/actions.ts index 9ff15393d78..f4265c43194 100644 --- a/public/app/features/variables/datasource/actions.ts +++ b/public/app/features/variables/datasource/actions.ts @@ -1,13 +1,14 @@ +import _ from 'lodash'; +import { getTemplateSrv } from '@grafana/runtime'; +import { stringToJsRegex } from '@grafana/data'; + import { toVariablePayload, VariableIdentifier } from '../state/types'; import { ThunkResult } from '../../../types'; import { createDataSourceOptions } from './reducer'; import { validateVariableSelectionState } from '../state/actions'; -import { DataSourceInstanceSettings, stringToJsRegex } from '@grafana/data'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { getVariable } from '../state/selectors'; import { DataSourceVariableModel } from '../types'; -import { getTemplateSrv } from '@grafana/runtime'; -import _ from 'lodash'; import { changeVariableEditorExtended } from '../editor/reducer'; export interface DataSourceVariableActionDependencies { @@ -18,10 +19,7 @@ export const updateDataSourceVariableOptions = ( identifier: VariableIdentifier, dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } ): ThunkResult => async (dispatch, getState) => { - const sources = dependencies - .getDatasourceSrv() - .getList({ metrics: true, variables: false }) - .map(toDataSourceSelectItem); + const sources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: false }); const variableInState = getVariable(identifier.id, getState()); let regex; @@ -37,10 +35,7 @@ export const updateDataSourceVariableOptions = ( export const initDataSourceVariableEditor = ( dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } ): ThunkResult => (dispatch) => { - const dataSources = dependencies - .getDatasourceSrv() - .getList({ metrics: true, variables: true }) - .map(toDataSourceSelectItem); + const dataSources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: true }); const dataSourceTypes = _(dataSources) .uniqBy('meta.id') .map((ds: any) => { @@ -57,11 +52,3 @@ export const initDataSourceVariableEditor = ( }) ); }; - -function toDataSourceSelectItem(setting: DataSourceInstanceSettings) { - return { - name: setting.name, - value: setting.name, - meta: setting.meta, - }; -} diff --git a/public/app/features/variables/datasource/adapter.ts b/public/app/features/variables/datasource/adapter.ts index 968f85af25b..603a80070b2 100644 --- a/public/app/features/variables/datasource/adapter.ts +++ b/public/app/features/variables/datasource/adapter.ts @@ -4,11 +4,11 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { dataSourceVariableReducer, initialDataSourceVariableModelState } from './reducer'; -import { OptionsPicker } from '../pickers'; import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types'; import { DataSourceVariableEditor } from './DataSourceVariableEditor'; import { updateDataSourceVariableOptions } from './actions'; import { containsVariable, isAllVariable } from '../utils'; +import { optionPickerFactory } from '../pickers'; export const createDataSourceVariableAdapter = (): VariableAdapter => { return { @@ -17,7 +17,7 @@ export const createDataSourceVariableAdapter = (): VariableAdapter(), editor: DataSourceVariableEditor, dependsOn: (variable, variableToTest) => { if (variable.regex) { diff --git a/public/app/features/variables/datasource/reducer.test.ts b/public/app/features/variables/datasource/reducer.test.ts index 3f2dcae5aa2..e8fd3f89678 100644 --- a/public/app/features/variables/datasource/reducer.test.ts +++ b/public/app/features/variables/datasource/reducer.test.ts @@ -1,3 +1,5 @@ +import { DataSourceInstanceSettings } from '@grafana/data'; + import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { VariablesState } from '../state/variablesReducer'; import { createDataSourceOptions, dataSourceVariableReducer } from './reducer'; @@ -5,20 +7,15 @@ import { DataSourceVariableModel } from '../types'; import { getVariableTestContext } from '../state/helpers'; import cloneDeep from 'lodash/cloneDeep'; import { createDataSourceVariableAdapter } from './adapter'; -import { DataSourceSelectItem } from '@grafana/data'; import { toVariablePayload } from '../state/types'; import { getMockPlugins } from '../../plugins/__mocks__/pluginMocks'; +import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; describe('dataSourceVariableReducer', () => { const adapter = createDataSourceVariableAdapter(); describe('when createDataSourceOptions is dispatched', () => { const plugins = getMockPlugins(3); - const sources: DataSourceSelectItem[] = plugins.map((p) => ({ - name: p.name, - value: `${p.name} value`, - meta: p, - sort: '', - })); + const sources: DataSourceInstanceSettings[] = plugins.map((p) => getDataSourceInstanceSetting(p.name, p)); it.each` query | regex | includeAll | expected @@ -49,4 +46,32 @@ describe('dataSourceVariableReducer', () => { } ); }); + + describe('when createDataSourceOptions is dispatched and item is default data source', () => { + it('then the state should include an extra default option', () => { + const plugins = getMockPlugins(3); + const sources: DataSourceInstanceSettings[] = plugins.map((p) => getDataSourceInstanceSetting(p.name, p)); + sources[1].isDefault = true; + + const { initialState } = getVariableTestContext(adapter, { + query: sources[1].meta.id, + includeAll: false, + }); + const payload = toVariablePayload({ id: '0', type: 'datasource' }, { sources, regex: undefined }); + + reducerTester() + .givenReducer(dataSourceVariableReducer, cloneDeep(initialState)) + .whenActionIsDispatched(createDataSourceOptions(payload)) + .thenStateShouldEqual({ + ...initialState, + ['0']: ({ + ...initialState['0'], + options: [ + { text: 'pretty cool plugin-1', value: 'pretty cool plugin-1', selected: false }, + { text: 'default', value: 'default', selected: false }, + ], + } as unknown) as DataSourceVariableModel, + }); + }); + }); }); diff --git a/public/app/features/variables/datasource/reducer.ts b/public/app/features/variables/datasource/reducer.ts index 8918991fea4..82fc20e137d 100644 --- a/public/app/features/variables/datasource/reducer.ts +++ b/public/app/features/variables/datasource/reducer.ts @@ -1,8 +1,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { DataSourceInstanceSettings } from '@grafana/data'; + import { DataSourceVariableModel, initialVariableModelState, VariableOption, VariableRefresh } from '../types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, getInstanceState, VariablePayload } from '../state/types'; import { initialVariablesState, VariablesState } from '../state/variablesReducer'; -import { DataSourceSelectItem } from '@grafana/data'; export interface DataSourceVariableEditorState { dataSourceTypes: Array<{ text: string; value: string }>; @@ -26,7 +27,7 @@ export const dataSourceVariableSlice = createSlice({ reducers: { createDataSourceOptions: ( state: VariablesState, - action: PayloadAction> + action: PayloadAction> ) => { const { sources, regex } = action.payload.data; const options: VariableOption[] = []; @@ -43,6 +44,10 @@ export const dataSourceVariableSlice = createSlice({ } options.push({ text: source.name, value: source.name, selected: false }); + + if (source.isDefault) { + options.push({ text: 'default', value: 'default', selected: false }); + } } if (options.length === 0) { diff --git a/public/app/features/variables/editor/SelectionOptionsEditor.tsx b/public/app/features/variables/editor/SelectionOptionsEditor.tsx index 3dce3cbd11a..dc3952ebff3 100644 --- a/public/app/features/variables/editor/SelectionOptionsEditor.tsx +++ b/public/app/features/variables/editor/SelectionOptionsEditor.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useCallback } from 'react'; +import React, { ChangeEvent, FormEvent, FunctionComponent, useCallback } from 'react'; import { InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; @@ -16,22 +16,22 @@ export interface SelectionOptionsEditorProps = (props) => { const onMultiChanged = useCallback( - (event: React.ChangeEvent) => { + (event: ChangeEvent) => { props.onMultiChanged(toVariableIdentifier(props.variable), event.target.checked); }, [props.onMultiChanged, props.variable] ); const onIncludeAllChanged = useCallback( - (event: React.ChangeEvent) => { + (event: ChangeEvent) => { props.onPropChange({ propName: 'includeAll', propValue: event.target.checked }); }, [props.onPropChange] ); const onAllValueChanged = useCallback( - (event: React.ChangeEvent) => { - props.onPropChange({ propName: 'allValue', propValue: event.target.value }); + (event: FormEvent) => { + props.onPropChange({ propName: 'allValue', propValue: event.currentTarget.value }); }, [props.onPropChange] ); diff --git a/public/app/features/variables/editor/VariableEditorContainer.tsx b/public/app/features/variables/editor/VariableEditorContainer.tsx index 4ab1f83d7f6..e39cb63ef61 100644 --- a/public/app/features/variables/editor/VariableEditorContainer.tsx +++ b/public/app/features/variables/editor/VariableEditorContainer.tsx @@ -5,34 +5,34 @@ import { selectors } from '@grafana/e2e-selectors'; import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; import { StoreState } from '../../../types'; import { VariableEditorEditor } from './VariableEditorEditor'; -import { connect, MapStateToProps, MapDispatchToProps } from 'react-redux'; +import { connect, ConnectedProps } from 'react-redux'; import { getEditorVariables } from '../state/selectors'; -import { VariableModel } from '../types'; import { switchToEditMode, switchToListMode, switchToNewMode } from './actions'; import { changeVariableOrder, duplicateVariable, removeVariable } from '../state/sharedReducer'; import { VariableEditorList } from './VariableEditorList'; -import { DashboardModel } from '../../dashboard/state'; import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable'; import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton'; +const mapStateToProps = (state: StoreState) => ({ + variables: getEditorVariables(state), + idInEditor: state.templating.editor.id, + dashboard: state.dashboard.getModel(), +}); + +const mapDispatchToProps = { + changeVariableOrder, + duplicateVariable, + removeVariable, + switchToNewMode, + switchToEditMode, + switchToListMode, +}; + interface OwnProps {} -interface ConnectedProps { - idInEditor: string | null; - variables: VariableModel[]; - dashboard: DashboardModel | null; -} +const connector = connect(mapStateToProps, mapDispatchToProps); -interface DispatchProps { - changeVariableOrder: typeof changeVariableOrder; - duplicateVariable: typeof duplicateVariable; - removeVariable: typeof removeVariable; - switchToNewMode: typeof switchToNewMode; - switchToEditMode: typeof switchToEditMode; - switchToListMode: typeof switchToListMode; -} - -type Props = OwnProps & ConnectedProps & DispatchProps; +type Props = OwnProps & ConnectedProps; class VariableEditorContainerUnconnected extends PureComponent { componentDidMount(): void { @@ -48,7 +48,7 @@ class VariableEditorContainerUnconnected extends PureComponent { this.props.switchToEditMode(identifier); }; - onNewVariable = (event: MouseEvent) => { + onNewVariable = (event: MouseEvent) => { event.preventDefault(); this.props.switchToNewMode(); }; @@ -124,19 +124,4 @@ class VariableEditorContainerUnconnected extends PureComponent { } } -const mapStateToProps: MapStateToProps = (state) => ({ - variables: getEditorVariables(state), - idInEditor: state.templating.editor.id, - dashboard: state.dashboard.getModel(), -}); - -const mapDispatchToProps: MapDispatchToProps = { - changeVariableOrder, - duplicateVariable, - removeVariable, - switchToNewMode, - switchToEditMode, - switchToListMode, -}; - -export const VariableEditorContainer = connect(mapStateToProps, mapDispatchToProps)(VariableEditorContainerUnconnected); +export const VariableEditorContainer = connector(VariableEditorContainerUnconnected); diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index a2c32289b4e..857c01bed08 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FormEvent, PureComponent } from 'react'; +import React, { FormEvent, PureComponent } from 'react'; import isEqual from 'lodash/isEqual'; import { AppEvents, LoadingState, SelectableValue, VariableType } from '@grafana/data'; import { Button, Icon, InlineFieldRow, VerticalGroup } from '@grafana/ui'; @@ -62,9 +62,9 @@ export class VariableEditorEditorUnConnected extends PureComponent { this.props.variableEditorUnMount(this.props.identifier); } - onNameChange = (event: ChangeEvent) => { + onNameChange = (event: FormEvent) => { event.preventDefault(); - this.props.changeVariableName(this.props.identifier, event.target.value); + this.props.changeVariableName(this.props.identifier, event.currentTarget.value); }; onTypeChange = (option: SelectableValue) => { @@ -74,16 +74,16 @@ export class VariableEditorEditorUnConnected extends PureComponent { this.props.changeVariableType(toVariablePayload(this.props.identifier, { newType: option.value })); }; - onLabelChange = (event: ChangeEvent) => { + onLabelChange = (event: FormEvent) => { event.preventDefault(); this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'label', propValue: event.target.value }) + toVariablePayload(this.props.identifier, { propName: 'label', propValue: event.currentTarget.value }) ); }; - onDescriptionChange = (event: ChangeEvent) => { + onDescriptionChange = (event: FormEvent) => { this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'description', propValue: event.target.value }) + toVariablePayload(this.props.identifier, { propName: 'description', propValue: event.currentTarget.value }) ); }; diff --git a/public/app/features/variables/editor/VariableEditorList.tsx b/public/app/features/variables/editor/VariableEditorList.tsx index bb5a85676d8..f157164984a 100644 --- a/public/app/features/variables/editor/VariableEditorList.tsx +++ b/public/app/features/variables/editor/VariableEditorList.tsx @@ -15,7 +15,7 @@ import { VariableUsagesButton } from '../inspect/VariableUsagesButton'; export interface Props { variables: VariableModel[]; dashboard: DashboardModel | null; - onAddClick: (event: MouseEvent) => void; + onAddClick: (event: MouseEvent) => void; onEditClick: (identifier: VariableIdentifier) => void; onChangeVariableOrder: (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => void; onDuplicateVariable: (identifier: VariableIdentifier) => void; diff --git a/public/app/features/variables/guard.ts b/public/app/features/variables/guard.ts index 701f6c8c41a..19b2e281690 100644 --- a/public/app/features/variables/guard.ts +++ b/public/app/features/variables/guard.ts @@ -176,7 +176,7 @@ export function isQueryEditor< >( component: VariableQueryEditorType, datasource: DataSourceApi -): component is ComponentType> { +): component is ComponentType, TQuery, TOptions, any>> { if (!component) { return false; } diff --git a/public/app/features/variables/interval/IntervalVariableEditor.tsx b/public/app/features/variables/interval/IntervalVariableEditor.tsx index d25f6bcd61f..9bb81f29f72 100644 --- a/public/app/features/variables/interval/IntervalVariableEditor.tsx +++ b/public/app/features/variables/interval/IntervalVariableEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, FocusEvent, PureComponent } from 'react'; +import React, { ChangeEvent, FormEvent, PureComponent } from 'react'; import { InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { IntervalVariableModel } from '../types'; @@ -20,17 +20,17 @@ export class IntervalVariableEditor extends PureComponent { }); }; - onQueryChanged = (event: ChangeEvent) => { + onQueryChanged = (event: FormEvent) => { this.props.onPropChange({ propName: 'query', - propValue: event.target.value, + propValue: event.currentTarget.value, }); }; - onQueryBlur = (event: FocusEvent) => { + onQueryBlur = (event: FormEvent) => { this.props.onPropChange({ propName: 'query', - propValue: event.target.value, + propValue: event.currentTarget.value, updateOptions: true, }); }; @@ -43,10 +43,10 @@ export class IntervalVariableEditor extends PureComponent { }); }; - onAutoMinChanged = (event: ChangeEvent) => { + onAutoMinChanged = (event: FormEvent) => { this.props.onPropChange({ propName: 'auto_min', - propValue: event.target.value, + propValue: event.currentTarget.value, updateOptions: true, }); }; diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index 2154a6d4aa3..242ca54036f 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,6 +1,5 @@ -import { getRootReducer } from '../state/helpers'; +import { getRootReducer, RootReducerType } from '../state/helpers'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from '../state/reducers'; import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { updateAutoValue, UpdateAutoValueDependencies, updateIntervalVariableOptions } from './actions'; import { createIntervalOptions } from './reducer'; @@ -26,7 +25,7 @@ describe('interval actions', () => { it('then correct actions are dispatched', async () => { const interval = intervalBuilder().withId('0').withQuery('1s,1m,1h,1d').withAuto(false).build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) .whenAsyncActionIsDispatched(updateIntervalVariableOptions(toVariableIdentifier(interval)), true); @@ -64,7 +63,7 @@ describe('interval actions', () => { .withAutoMin('1xyz') // illegal interval string .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); @@ -121,7 +120,7 @@ describe('interval actions', () => { } as unknown) as TemplateSrv, }; - await reduxTester<{ templating: TemplatingState }>() + await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) @@ -165,7 +164,7 @@ describe('interval actions', () => { } as unknown) as TemplateSrv, }; - await reduxTester<{ templating: TemplatingState }>() + await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) diff --git a/public/app/features/variables/interval/adapter.ts b/public/app/features/variables/interval/adapter.ts index 77d7bdf0b53..619d5dc6f7d 100644 --- a/public/app/features/variables/interval/adapter.ts +++ b/public/app/features/variables/interval/adapter.ts @@ -4,10 +4,10 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { initialIntervalVariableModelState, intervalVariableReducer } from './reducer'; -import { OptionsPicker } from '../pickers'; import { toVariableIdentifier } from '../state/types'; import { IntervalVariableEditor } from './IntervalVariableEditor'; import { updateAutoValue, updateIntervalVariableOptions } from './actions'; +import { optionPickerFactory } from '../pickers'; export const createIntervalVariableAdapter = (): VariableAdapter => { return { @@ -16,7 +16,7 @@ export const createIntervalVariableAdapter = (): VariableAdapter(), editor: IntervalVariableEditor, dependsOn: () => { return false; diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx index f27a7ca8cf3..6249fd72cd6 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx @@ -1,119 +1,124 @@ -import React, { PureComponent } from 'react'; -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; +import React, { ComponentType, PureComponent } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; import { ClickOutsideWrapper } from '@grafana/ui'; import { LoadingState } from '@grafana/data'; import { StoreState } from 'app/types'; -import { VariableLink } from '../shared/VariableLink'; import { VariableInput } from '../shared/VariableInput'; import { commitChangesToVariable, filterOrSearchOptions, navigateOptions, toggleAndFetchTag } from './actions'; import { OptionsPickerState, showOptions, toggleAllOptions, toggleOption } from './reducer'; import { VariableOption, VariableTag, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { VariableOptions } from '../shared/VariableOptions'; -import { isQuery } from '../../guard'; +import { isMulti, isQuery } from '../../guard'; import { VariablePickerProps } from '../types'; import { formatVariableLabel } from '../../shared/formatVariable'; import { toVariableIdentifier } from '../../state/types'; import { getVariableQueryRunner } from '../../query/VariableQueryRunner'; +import { VariableLink } from '../shared/VariableLink'; -interface OwnProps extends VariablePickerProps {} - -interface ConnectedProps { - picker: OptionsPickerState; -} - -interface DispatchProps { - showOptions: typeof showOptions; - commitChangesToVariable: typeof commitChangesToVariable; - toggleAllOptions: typeof toggleAllOptions; - toggleOption: typeof toggleOption; - toggleAndFetchTag: typeof toggleAndFetchTag; - filterOrSearchOptions: typeof filterOrSearchOptions; - navigateOptions: typeof navigateOptions; -} - -type Props = OwnProps & ConnectedProps & DispatchProps; - -export class OptionsPickerUnconnected extends PureComponent { - onShowOptions = () => this.props.showOptions(this.props.variable); - onHideOptions = () => this.props.commitChangesToVariable(); - - onToggleOption = (option: VariableOption, clearOthers: boolean) => { - const toggleFunc = this.props.variable.multi ? this.onToggleMultiValueVariable : this.onToggleSingleValueVariable; - toggleFunc(option, clearOthers); +export const optionPickerFactory = (): ComponentType< + VariablePickerProps +> => { + const mapDispatchToProps = { + showOptions, + commitChangesToVariable, + filterOrSearchOptions, + toggleAllOptions, + toggleOption, + toggleAndFetchTag, + navigateOptions, }; - onToggleSingleValueVariable = (option: VariableOption, clearOthers: boolean) => { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); - this.onHideOptions(); - }; + const mapStateToProps = (state: StoreState) => ({ + picker: state.templating.optionsPicker, + }); - onToggleMultiValueVariable = (option: VariableOption, clearOthers: boolean) => { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); - }; + const connector = connect(mapStateToProps, mapDispatchToProps); - render() { - const { variable, picker } = this.props; - const showOptions = picker.id === variable.id; + interface OwnProps extends VariablePickerProps {} - return ( -
    - {this.renderLink(showOptions, variable)} - {this.renderOptions(showOptions, picker)} -
    - ); - } + type Props = OwnProps & ConnectedProps; - renderLink(showOptions: boolean, variable: VariableWithMultiSupport) { - if (showOptions) { - return null; + class OptionsPickerUnconnected extends PureComponent { + onShowOptions = () => this.props.showOptions(this.props.variable); + onHideOptions = () => this.props.commitChangesToVariable(this.props.onVariableChange); + + onToggleOption = (option: VariableOption, clearOthers: boolean) => { + const toggleFunc = + isMulti(this.props.variable) && this.props.variable.multi + ? this.onToggleMultiValueVariable + : this.onToggleSingleValueVariable; + toggleFunc(option, clearOthers); + }; + + onToggleSingleValueVariable = (option: VariableOption, clearOthers: boolean) => { + this.props.toggleOption({ option, clearOthers, forceSelect: false }); + this.onHideOptions(); + }; + + onToggleMultiValueVariable = (option: VariableOption, clearOthers: boolean) => { + this.props.toggleOption({ option, clearOthers, forceSelect: false }); + }; + + render() { + const { variable, picker } = this.props; + const showOptions = picker.id === variable.id; + + return ( +
    + {showOptions ? this.renderOptions(picker) : this.renderLink(variable)} +
    + ); } - const linkText = formatVariableLabel(variable); - const tags = getSelectedTags(variable); - const loading = variable.state === LoadingState.Loading; + renderLink(variable: VariableWithOptions) { + const linkText = formatVariableLabel(variable); + const tags = getSelectedTags(variable); + const loading = variable.state === LoadingState.Loading; - return ( - - ); - } - - onCancel = () => { - getVariableQueryRunner().cancelRequest(toVariableIdentifier(this.props.variable)); - }; - - renderOptions(showOptions: boolean, picker: OptionsPickerState) { - if (!showOptions) { - return null; + return ( + + ); } - return ( - - - - - ); + onCancel = () => { + getVariableQueryRunner().cancelRequest(toVariableIdentifier(this.props.variable)); + }; + + renderOptions(picker: OptionsPickerState) { + return ( + + + + + ); + } } -} + + const OptionsPicker = connector(OptionsPickerUnconnected); + OptionsPicker.displayName = 'OptionsPicker'; + + return OptionsPicker; +}; const getSelectedTags = (variable: VariableWithOptions): VariableTag[] => { if (!isQuery(variable) || !Array.isArray(variable.tags)) { @@ -121,20 +126,3 @@ const getSelectedTags = (variable: VariableWithOptions): VariableTag[] => { } return variable.tags.filter((t) => t.selected); }; - -const mapDispatchToProps: MapDispatchToProps = { - showOptions, - commitChangesToVariable, - filterOrSearchOptions, - toggleAllOptions, - toggleOption, - toggleAndFetchTag, - navigateOptions, -}; - -const mapStateToProps: MapStateToProps = (state) => ({ - picker: state.templating.optionsPicker, -}); - -export const OptionsPicker = connect(mapStateToProps, mapDispatchToProps)(OptionsPickerUnconnected); -OptionsPicker.displayName = 'OptionsPicker'; diff --git a/public/app/features/variables/pickers/OptionsPicker/actions.test.ts b/public/app/features/variables/pickers/OptionsPicker/actions.test.ts index 70b27d870fc..d1f51c6ee23 100644 --- a/public/app/features/variables/pickers/OptionsPicker/actions.test.ts +++ b/public/app/features/variables/pickers/OptionsPicker/actions.test.ts @@ -1,6 +1,5 @@ import { reduxTester } from '../../../../../test/core/redux/reduxTester'; -import { getRootReducer } from '../../state/helpers'; -import { TemplatingState } from '../../state/reducers'; +import { getRootReducer, RootReducerType } from '../../state/helpers'; import { initialVariableModelState, QueryVariableModel, VariableRefresh, VariableSort } from '../../types'; import { hideOptions, @@ -53,7 +52,7 @@ describe('options picker actions', () => { const clearOthers = false; const key = NavigationKey.cancel; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -86,7 +85,7 @@ describe('options picker actions', () => { const clearOthers = false; const key = NavigationKey.select; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -109,7 +108,7 @@ describe('options picker actions', () => { const clearOthers = true; const key = NavigationKey.select; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -128,7 +127,7 @@ describe('options picker actions', () => { const clearOthers = true; const key = NavigationKey.select; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -149,7 +148,7 @@ describe('options picker actions', () => { const clearOthers = true; const key = NavigationKey.select; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -171,7 +170,7 @@ describe('options picker actions', () => { const clearOthers = false; const key = NavigationKey.selectAndClose; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -205,7 +204,7 @@ describe('options picker actions', () => { const variable = createMultiVariable({ options, current: createOption(['A'], ['A'], true), includeAll: false }); const filter = 'A'; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -220,7 +219,7 @@ describe('options picker actions', () => { const options = [createOption('A', 'A', true), createOption('B'), createOption('C')]; const variable = createMultiVariable({ options, current: createOption(['A'], ['A'], true), includeAll: false }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -247,7 +246,7 @@ describe('options picker actions', () => { const variable = createMultiVariable({ options, current: createOption(['A'], ['A'], true), includeAll: false }); const clearOthers = false; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -278,7 +277,7 @@ describe('options picker actions', () => { const variable = createMultiVariable({ options, current: createOption(['A'], ['A'], true), includeAll: false }); const clearOthers = false; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -310,7 +309,7 @@ describe('options picker actions', () => { const variable = createMultiVariable({ options, current: createOption(['A'], ['A'], true), includeAll: false }); const clearOthers = false; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -329,7 +328,7 @@ describe('options picker actions', () => { const variable = createMultiVariable({ options, current: createOption(['A'], ['A'], true), includeAll: false }); const clearOthers = false; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -365,7 +364,7 @@ describe('options picker actions', () => { tags: [tag], }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) @@ -391,7 +390,7 @@ describe('options picker actions', () => { // @ts-ignore strict null error TS2345: Argument of type '() => Promise<{ value: string; text: string; }[]>' is not assignable to parameter of type '() => Promise' datasource.metricFindQuery.mockImplementation(() => Promise.resolve(values)); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(showOptions(variable)) diff --git a/public/app/features/variables/pickers/OptionsPicker/actions.ts b/public/app/features/variables/pickers/OptionsPicker/actions.ts index 9a4c471d197..42c89affba7 100644 --- a/public/app/features/variables/pickers/OptionsPicker/actions.ts +++ b/public/app/features/variables/pickers/OptionsPicker/actions.ts @@ -72,7 +72,13 @@ export const filterOrSearchOptions = (searchQuery = ''): ThunkResult => { }; }; -export const commitChangesToVariable = (): ThunkResult => { +const setVariable = async (updated: VariableWithMultiSupport) => { + const adapter = variableAdapters.get(updated.type); + await adapter.setValue(updated, updated.current, true); + return; +}; + +export const commitChangesToVariable = (callback?: (updated: any) => void): ThunkResult => { return async (dispatch, getState) => { const picker = getState().templating.optionsPicker; const existing = getVariable(picker.id, getState()); @@ -88,9 +94,11 @@ export const commitChangesToVariable = (): ThunkResult => { return; } - const adapter = variableAdapters.get(updated.type); - await adapter.setValue(updated, updated.current, true); - return; + if (callback) { + return callback(updated); + } + + return setVariable(updated); }; }; @@ -159,7 +167,7 @@ const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreSt const searchForOptionsWithDebounce = debounce(searchForOptions, 500); -function mapToCurrent(picker: OptionsPickerState): VariableOption | undefined { +export function mapToCurrent(picker: OptionsPickerState): VariableOption | undefined { const { options, selectedValues, queryValue: searchQuery, multi } = picker; if (options.length === 0 && searchQuery && searchQuery.length > 0) { diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.ts index 3178c56ba3c..0fceef963cd 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.ts @@ -1,8 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { cloneDeep, isString, trim } from 'lodash'; -import { VariableOption, VariableTag, VariableWithMultiSupport } from '../../types'; +import { VariableOption, VariableTag, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { ALL_VARIABLE_VALUE } from '../../state/types'; -import { isQuery } from '../../guard'; +import { isMulti, isQuery } from '../../guard'; import { applyStateChanges } from '../../../../core/utils/applyStateChanges'; import { containsSearchFilter } from '../../utils'; @@ -119,15 +119,19 @@ const optionsPickerSlice = createSlice({ name: 'templating/optionsPicker', initialState, reducers: { - showOptions: (state, action: PayloadAction): OptionsPickerState => { - const { query, options, multi } = action.payload; + showOptions: (state, action: PayloadAction): OptionsPickerState => { + const { query, options } = action.payload; state.highlightIndex = -1; state.options = cloneDeep(options); - state.tags = getTags(action.payload); - state.multi = multi ?? false; state.id = action.payload.id; state.queryValue = ''; + state.multi = false; + + if (isMulti(action.payload)) { + state.tags = getTags(action.payload); + state.multi = action.payload.multi ?? false; + } if (isQuery(action.payload)) { const { queryValue } = action.payload; diff --git a/public/app/features/variables/pickers/index.ts b/public/app/features/variables/pickers/index.ts index f03c7623d9a..2cd6110e303 100644 --- a/public/app/features/variables/pickers/index.ts +++ b/public/app/features/variables/pickers/index.ts @@ -1 +1 @@ -export { OptionsPicker } from './OptionsPicker/OptionsPicker'; +export { optionPickerFactory } from './OptionsPicker/OptionsPicker'; diff --git a/public/app/features/variables/pickers/types.ts b/public/app/features/variables/pickers/types.ts index 6a8ef8e9695..b992f303b9e 100644 --- a/public/app/features/variables/pickers/types.ts +++ b/public/app/features/variables/pickers/types.ts @@ -2,6 +2,7 @@ import { VariableModel } from '../types'; export interface VariablePickerProps { variable: Model; + onVariableChange?: (variable: Model) => void; } export enum NavigationKey { diff --git a/public/app/features/variables/query/QueryVariableEditor.tsx b/public/app/features/variables/query/QueryVariableEditor.tsx index 739175be96a..6650fa91499 100644 --- a/public/app/features/variables/query/QueryVariableEditor.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, PureComponent } from 'react'; +import React, { ChangeEvent, FormEvent, PureComponent } from 'react'; import { css } from 'emotion'; import { MapDispatchToProps, MapStateToProps } from 'react-redux'; import { InlineField, InlineFieldRow, VerticalGroup } from '@grafana/ui'; @@ -92,34 +92,34 @@ export class QueryVariableEditorUnConnected extends PureComponent } }; - onRegExChange = (event: ChangeEvent) => { - this.setState({ regex: event.target.value }); + onRegExChange = (event: FormEvent) => { + this.setState({ regex: event.currentTarget.value }); }; - onRegExBlur = async (event: ChangeEvent) => { - const regex = event.target.value; + onRegExBlur = async (event: FormEvent) => { + const regex = event.currentTarget.value; if (this.props.variable.regex !== regex) { this.props.onPropChange({ propName: 'regex', propValue: regex, updateOptions: true }); } }; - onTagsQueryChange = async (event: ChangeEvent) => { - this.setState({ tagsQuery: event.target.value }); + onTagsQueryChange = async (event: FormEvent) => { + this.setState({ tagsQuery: event.currentTarget.value }); }; - onTagsQueryBlur = async (event: ChangeEvent) => { - const tagsQuery = event.target.value; + onTagsQueryBlur = async (event: FormEvent) => { + const tagsQuery = event.currentTarget.value; if (this.props.variable.tagsQuery !== tagsQuery) { this.props.onPropChange({ propName: 'tagsQuery', propValue: tagsQuery, updateOptions: true }); } }; - onTagValuesQueryChange = async (event: ChangeEvent) => { - this.setState({ tagValuesQuery: event.target.value }); + onTagValuesQueryChange = async (event: FormEvent) => { + this.setState({ tagValuesQuery: event.currentTarget.value }); }; - onTagValuesQueryBlur = async (event: ChangeEvent) => { - const tagValuesQuery = event.target.value; + onTagValuesQueryBlur = async (event: FormEvent) => { + const tagValuesQuery = event.currentTarget.value; if (this.props.variable.tagValuesQuery !== tagValuesQuery) { this.props.onPropChange({ propName: 'tagValuesQuery', propValue: tagValuesQuery, updateOptions: true }); } diff --git a/public/app/features/variables/query/actions.test.ts b/public/app/features/variables/query/actions.test.ts index b85eb8a975e..749ef563a42 100644 --- a/public/app/features/variables/query/actions.test.ts +++ b/public/app/features/variables/query/actions.test.ts @@ -3,7 +3,7 @@ import { getDefaultTimeRange, LoadingState } from '@grafana/data'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer } from '../state/helpers'; +import { getRootReducer, RootReducerType } from '../state/helpers'; import { QueryVariableModel, VariableHide, VariableRefresh, VariableSort } from '../types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, toVariablePayload } from '../state/types'; import { @@ -14,7 +14,6 @@ import { variableStateFailed, variableStateFetching, } from '../state/sharedReducer'; -import { TemplatingState } from '../state/reducers'; import { changeQueryVariableDataSource, changeQueryVariableQuery, @@ -87,7 +86,7 @@ describe('query actions', () => { mockDatasourceMetrics(variable, optionsMetrics, tagsMetrics); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); @@ -111,7 +110,7 @@ describe('query actions', () => { mockDatasourceMetrics(variable, optionsMetrics, tagsMetrics); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); @@ -138,7 +137,7 @@ describe('query actions', () => { mockDatasourceMetrics(variable, optionsMetrics, []); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); @@ -160,7 +159,7 @@ describe('query actions', () => { mockDatasourceMetrics(variable, optionsMetrics, []); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); @@ -186,7 +185,7 @@ describe('query actions', () => { mockDatasourceMetrics(variable, optionsMetrics, []); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) @@ -214,7 +213,7 @@ describe('query actions', () => { mockDatasourceMetrics(variable, optionsMetrics, []); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) @@ -241,7 +240,7 @@ describe('query actions', () => { mocks[variable.datasource!].metricFindQuery = jest.fn(() => Promise.reject(error)); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) @@ -277,7 +276,7 @@ describe('query actions', () => { components: { VariableQueryEditor: editor }, }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); @@ -306,7 +305,7 @@ describe('query actions', () => { components: { VariableQueryEditor: editor }, }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); @@ -334,7 +333,7 @@ describe('query actions', () => { components: { VariableQueryEditor: editor }, }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); @@ -356,7 +355,7 @@ describe('query actions', () => { it('then correct actions are dispatched', async () => { const variable = createVariable({ datasource: undefined }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); @@ -380,7 +379,7 @@ describe('query actions', () => { components: { VariableQueryEditor: editor }, }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(changeQueryVariableDataSource(toVariablePayload(variable), 'datasource'), true); @@ -410,7 +409,7 @@ describe('query actions', () => { components: {}, }); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(changeQueryVariableDataSource(toVariablePayload(variable), 'datasource'), true); @@ -442,7 +441,7 @@ describe('query actions', () => { mockDatasourceMetrics({ ...variable, query }, optionsMetrics, tagsMetrics); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); @@ -473,7 +472,7 @@ describe('query actions', () => { mockDatasourceMetrics({ ...variable, query }, optionsMetrics, []); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); @@ -502,7 +501,7 @@ describe('query actions', () => { mockDatasourceMetrics({ ...variable, query }, optionsMetrics, []); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); @@ -528,7 +527,7 @@ describe('query actions', () => { const query = `$${variable.name}`; const definition = 'depends on datasource variable'; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); diff --git a/public/app/features/variables/query/actions.ts b/public/app/features/variables/query/actions.ts index 9bd4f84f510..22e24c2fb62 100644 --- a/public/app/features/variables/query/actions.ts +++ b/public/app/features/variables/query/actions.ts @@ -1,8 +1,7 @@ -import { toDataQueryError } from '@grafana/runtime'; +import { toDataQueryError, getDataSourceSrv } from '@grafana/runtime'; import { updateOptions } from '../state/actions'; import { QueryVariableModel } from '../types'; import { ThunkResult } from '../../../types'; -import { getDataSourceSrv } from '@grafana/runtime'; import { getVariable } from '../state/selectors'; import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer'; import { changeVariableProp } from '../state/sharedReducer'; diff --git a/public/app/features/variables/query/adapter.ts b/public/app/features/variables/query/adapter.ts index e4e62134dcd..fdafc29a081 100644 --- a/public/app/features/variables/query/adapter.ts +++ b/public/app/features/variables/query/adapter.ts @@ -5,11 +5,11 @@ import { initialQueryVariableModelState, queryVariableReducer } from './reducer' import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; -import { OptionsPicker } from '../pickers'; import { QueryVariableEditor } from './QueryVariableEditor'; import { updateQueryVariableOptions } from './actions'; import { ALL_VARIABLE_TEXT, toVariableIdentifier } from '../state/types'; import { containsVariable, isAllVariable } from '../utils'; +import { optionPickerFactory } from '../pickers'; export const createQueryVariableAdapter = (): VariableAdapter => { return { @@ -18,7 +18,7 @@ export const createQueryVariableAdapter = (): VariableAdapter(), editor: QueryVariableEditor, dependsOn: (variable, variableToTest) => { return containsVariable(variable.query, variable.datasource, variable.regex, variableToTest.name); diff --git a/public/app/features/variables/query/reducer.test.ts b/public/app/features/variables/query/reducer.test.ts index 73efcf43cf0..fd061ddba3c 100644 --- a/public/app/features/variables/query/reducer.test.ts +++ b/public/app/features/variables/query/reducer.test.ts @@ -1,6 +1,6 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { - getAllMatches, + metricNamesToVariableValues, queryVariableReducer, sortVariableValues, updateVariableOptions, @@ -12,7 +12,7 @@ import { VariablesState } from '../state/variablesReducer'; import { getVariableTestContext } from '../state/helpers'; import { toVariablePayload } from '../state/types'; import { createQueryVariableAdapter } from './adapter'; -import { MetricFindValue, stringToJsRegex } from '@grafana/data'; +import { MetricFindValue } from '@grafana/data'; describe('queryVariableReducer', () => { const adapter = createQueryVariableAdapter(); @@ -307,37 +307,79 @@ describe('sortVariableValues', () => { }); }); -describe('getAllMatches', () => { - it.each` - str | regex | expected - ${'A{somelabel="atext",somevalue="avalue"}'} | ${'/unknown/gi'} | ${{}} - ${'A{somelabel="atext",somevalue="avalue"}'} | ${'/unknown/i'} | ${{}} - ${'A{somelabel="atext",somevalue="avalue"}'} | ${'/some(\\w+)/gi'} | ${{ 0: 'somevalue', 1: 'value', index: 20, input: 'A{somelabel="atext",somevalue="avalue"}' }} - ${'A{somelabel="atext",somevalue="avalue"}'} | ${'/some(\\w+)/i'} | ${{ 0: 'somelabel', 1: 'label', index: 2, input: 'A{somelabel="atext",somevalue="avalue"}' }} - ${'A{somelabel="atext",somevalue="avalue"}'} | ${'/somevalue="(?[^"]+)|somelabel="(?[^"]+)/gi'} | ${{ - 0: 'somevalue="avalue', - 1: 'avalue', - 2: 'atext', - groups: { - text: 'atext', - value: 'avalue', - }, - index: 20, - input: 'A{somelabel="atext",somevalue="avalue"}', -}} - ${'A{somelabel="atext",somevalue="avalue"}'} | ${'/somevalue="(?[^"]+)|somelabel="(?[^"]+)/i'} | ${{ - 0: 'somelabel="atext', - 1: undefined, - 2: 'atext', - groups: { - text: 'atext', - }, - index: 2, - input: 'A{somelabel="atext",somevalue="avalue"}', -}} - `('when called with str:{$str}, regex:{$regex} then it should return correct matches', ({ str, regex, expected }) => { - const result = getAllMatches(str, stringToJsRegex(regex)); +describe('metricNamesToVariableValues', () => { + const item = (str: string) => ({ text: str, value: str, selected: false }); + const metricsNames = [ + item('go_info{instance="demo.robustperception.io:9090",job="prometheus",version="go1.15.6"} 1 1613047998000'), + item('go_info{instance="demo.robustperception.io:9091",job="pushgateway",version="go1.15.6"} 1 1613047998000'), + item('go_info{instance="demo.robustperception.io:9093",job="alertmanager",version="go1.14.4"} 1 1613047998000'), + item('go_info{instance="demo.robustperception.io:9100",job="node",version="go1.14.4"} 1 1613047998000'), + ]; + const expected1 = [ + { value: 'demo.robustperception.io:9090', text: 'demo.robustperception.io:9090', selected: false }, + { value: 'demo.robustperception.io:9091', text: 'demo.robustperception.io:9091', selected: false }, + { value: 'demo.robustperception.io:9093', text: 'demo.robustperception.io:9093', selected: false }, + { value: 'demo.robustperception.io:9100', text: 'demo.robustperception.io:9100', selected: false }, + ]; + + const expected2 = [ + { value: 'prometheus', text: 'prometheus', selected: false }, + { value: 'pushgateway', text: 'pushgateway', selected: false }, + { value: 'alertmanager', text: 'alertmanager', selected: false }, + { value: 'node', text: 'node', selected: false }, + ]; + + const expected3 = [ + { value: 'demo.robustperception.io:9090', text: 'prometheus', selected: false }, + { value: 'demo.robustperception.io:9091', text: 'pushgateway', selected: false }, + { value: 'demo.robustperception.io:9093', text: 'alertmanager', selected: false }, + { value: 'demo.robustperception.io:9100', text: 'node', selected: false }, + ]; + + const expected4 = [ + { value: 'demo.robustperception.io:9090', text: 'demo.robustperception.io:9090', selected: false }, + { value: undefined, text: undefined, selected: false }, + { value: 'demo.robustperception.io:9091', text: 'demo.robustperception.io:9091', selected: false }, + { value: 'demo.robustperception.io:9093', text: 'demo.robustperception.io:9093', selected: false }, + { value: 'demo.robustperception.io:9100', text: 'demo.robustperception.io:9100', selected: false }, + ]; + + it.each` + variableRegEx | expected + ${''} | ${metricsNames} + ${'/unknown/'} | ${[]} + ${'/unknown/g'} | ${[]} + ${'/go/'} | ${metricsNames} + ${'/go/g'} | ${metricsNames} + ${'/(go)/'} | ${[{ value: 'go', text: 'go', selected: false }]} + ${'/(go)/g'} | ${[{ value: 'go', text: 'go', selected: false }]} + ${'/(go)?/'} | ${[{ value: 'go', text: 'go', selected: false }]} + ${'/(go)?/g'} | ${[{ value: 'go', text: 'go', selected: false }, { value: undefined, text: undefined, selected: false }]} + ${'/go(\\w+)/'} | ${[{ value: '_info', text: '_info', selected: false }]} + ${'/go(\\w+)/g'} | ${[{ value: '_info', text: '_info', selected: false }, { value: '1', text: '1', selected: false }]} + ${'/.*_(\\w+)\\{/'} | ${[{ value: 'info', text: 'info', selected: false }]} + ${'/.*_(\\w+)\\{/g'} | ${[{ value: 'info', text: 'info', selected: false }]} + ${'/instance="(?[^"]+)/'} | ${expected1} + ${'/instance="(?[^"]+)/g'} | ${expected1} + ${'/instance="(?[^"]+)/'} | ${expected1} + ${'/instance="(?[^"]+)/g'} | ${expected1} + ${'/instancee="(?[^"]+)/'} | ${[]} + ${'/job="(?[^"]+)/'} | ${expected2} + ${'/job="(?[^"]+)/g'} | ${expected2} + ${'/job="(?[^"]+)/'} | ${expected2} + ${'/job="(?[^"]+)/g'} | ${expected2} + ${'/jobb="(?[^"]+)/g'} | ${[]} + ${'/instance="(?[^"]+)|job="(?[^"]+)/'} | ${expected1} + ${'/instance="(?[^"]+)|job="(?[^"]+)/g'} | ${expected3} + ${'/instance="(?[^"]+)|job="(?[^"]+)/'} | ${expected1} + ${'/instance="(?[^"]+)|job="(?[^"]+)/g'} | ${expected4} + ${'/instance="(?[^"]+).*job="(?[^"]+)/'} | ${expected3} + ${'/instance="(?[^"]+).*job="(?[^"]+)/g'} | ${expected3} + ${'/instance="(?[^"]+).*job="(?[^"]+)/'} | ${expected1} + ${'/instance="(?[^"]+).*job="(?[^"]+)/g'} | ${expected1} + `('when called with variableRegEx:$variableRegEx then it return correct options', ({ variableRegEx, expected }) => { + const result = metricNamesToVariableValues(variableRegEx, VariableSort.disabled, metricsNames); expect(result).toEqual(expected); }); }); diff --git a/public/app/features/variables/query/reducer.ts b/public/app/features/variables/query/reducer.ts index 4513e7e6d78..ac3c1d21116 100644 --- a/public/app/features/variables/query/reducer.ts +++ b/public/app/features/variables/query/reducer.ts @@ -88,30 +88,33 @@ export const sortVariableValues = (options: any[], sortOrder: VariableSort) => { return options; }; -export const getAllMatches = (str: string, regex: RegExp): any => { - const results = {}; - let matches; +const getAllMatches = (str: string, regex: RegExp): RegExpExecArray[] => { + const results: RegExpExecArray[] = []; + let matches = null; + + regex.lastIndex = 0; do { matches = regex.exec(str); - _.merge(results, matches); - } while (regex.global && matches); + if (matches) { + results.push(matches); + } + } while (regex.global && matches && matches[0] !== '' && matches[0] !== undefined); return results; }; -const metricNamesToVariableValues = (variableRegEx: string, sort: VariableSort, metricNames: any[]) => { - let regex, i, matches; +export const metricNamesToVariableValues = (variableRegEx: string, sort: VariableSort, metricNames: any[]) => { + let regex; let options: VariableOption[] = []; if (variableRegEx) { regex = stringToJsRegex(variableRegEx); } - for (i = 0; i < metricNames.length; i++) { + for (let i = 0; i < metricNames.length; i++) { const item = metricNames[i]; let text = item.text === undefined || item.text === null ? item.value : item.text; - let value = item.value === undefined || item.value === null ? item.text : item.value; if (_.isNumber(value)) { @@ -123,24 +126,28 @@ const metricNamesToVariableValues = (variableRegEx: string, sort: VariableSort, } if (regex) { - matches = getAllMatches(value, regex); - - if (_.isEmpty(matches)) { + const matches = getAllMatches(value, regex); + if (!matches.length) { continue; } - if (matches.groups && matches.groups.value && matches.groups.text) { - value = matches.groups.value; - text = matches.groups.text; - } else if (matches.groups && matches.groups.value) { - value = matches.groups.value; - text = value; - } else if (matches.groups && matches.groups.text) { - text = matches.groups.text; - value = text; - } else if (matches['1']) { - text = matches['1']; - value = matches['1']; + const valueGroup = matches.find((m) => m.groups && m.groups.value); + const textGroup = matches.find((m) => m.groups && m.groups.text); + const firstMatch = matches.find((m) => m.length > 1); + const manyMatches = matches.length > 1 && firstMatch; + + if (valueGroup || textGroup) { + value = valueGroup?.groups?.value ?? textGroup?.groups?.text; + text = textGroup?.groups?.text ?? valueGroup?.groups?.value; + } else if (manyMatches) { + for (let j = 0; j < matches.length; j++) { + const match = matches[j]; + options.push({ text: match[1], value: match[1], selected: false }); + } + continue; + } else if (firstMatch) { + text = firstMatch[1]; + value = firstMatch[1]; } } diff --git a/public/app/features/variables/shared/testing/helpers.ts b/public/app/features/variables/shared/testing/helpers.ts new file mode 100644 index 00000000000..bf8df912e72 --- /dev/null +++ b/public/app/features/variables/shared/testing/helpers.ts @@ -0,0 +1,12 @@ +import { DataSourceInstanceSettings, DataSourceJsonData, DataSourcePluginMeta } from '@grafana/data'; + +export function getDataSourceInstanceSetting(name: string, meta: DataSourcePluginMeta): DataSourceInstanceSettings { + return { + id: 1, + uid: '', + type: '', + name, + meta, + jsonData: ({} as unknown) as DataSourceJsonData, + }; +} diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index e838f6a3b87..10d5d55680e 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -1,7 +1,12 @@ import { AnyAction } from 'redux'; -import { UrlQueryMap } from '@grafana/data'; -import { getRootReducer, getTemplatingAndLocationRootReducer, getTemplatingRootReducer } from './helpers'; +import { + getRootReducer, + getTemplatingAndLocationRootReducer, + getTemplatingRootReducer, + RootReducerType, + TemplatingAndLocationReducerType, +} from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; @@ -49,7 +54,6 @@ import { initialVariableEditorState, setIdInEditor, } from '../editor/reducer'; -import { DashboardState, LocationState } from '../../../types'; import { TransactionStatus, variablesClearTransaction, @@ -63,6 +67,7 @@ import { ConstantVariableModel, VariableRefresh } from '../types'; import { updateVariableOptions } from '../query/reducer'; import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner'; import { setDataSourceSrv } from '@grafana/runtime'; +import { LocationState } from 'app/types'; variableAdapters.setInit(() => [ createQueryVariableAdapter(), @@ -145,10 +150,12 @@ describe('shared actions', () => { const custom = customBuilder().build(); const textbox = textboxBuilder().build(); const list = [query, constant, datasource, custom, textbox]; + const preloadedState = { + templating: ({} as unknown) as TemplatingState, + location: ({ query: {} } as unknown) as LocationState, + }; - const tester = await reduxTester<{ templating: TemplatingState; location: { query: UrlQueryMap } }>({ - preloadedState: { templating: ({} as unknown) as TemplatingState, location: { query: {} } }, - }) + const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingAndLocationRootReducer()) .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) .whenActionIsDispatched(initDashboardTemplating(list)) @@ -202,9 +209,12 @@ describe('shared actions', () => { const list = [stats, substats]; const query = { orgId: '1', 'var-stats': 'response', 'var-substats': ALL_VARIABLE_TEXT }; - const tester = await reduxTester<{ templating: TemplatingState; location: { query: UrlQueryMap } }>({ - preloadedState: { templating: ({} as unknown) as TemplatingState, location: { query } }, - }) + const preloadedState = { + templating: ({} as unknown) as TemplatingState, + location: ({ query } as unknown) as LocationState, + }; + + const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingAndLocationRootReducer()) .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) .whenActionIsDispatched(initDashboardTemplating(list)) @@ -558,11 +568,6 @@ describe('shared actions', () => { }); describe('initVariablesTransaction', () => { - type ReducersUsedInContext = { - templating: TemplatingState; - dashboard: DashboardState; - location: LocationState; - }; const constant = constantBuilder().withId('constant').withName('constant').build(); const templating: any = { list: [constant] }; const uid = 'uid'; @@ -570,7 +575,7 @@ describe('shared actions', () => { describe('when called and the previous dashboard has completed', () => { it('then correct actions are dispatched', async () => { - const tester = await reduxTester() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); @@ -598,7 +603,7 @@ describe('shared actions', () => { it('then correct actions are dispatched', async () => { const transactionState = { uid: 'previous-uid', status: TransactionStatus.Fetching }; - const tester = await reduxTester({ + const tester = await reduxTester({ preloadedState: ({ templating: { transaction: transactionState, @@ -606,7 +611,7 @@ describe('shared actions', () => { optionsPicker: { ...initialState }, editor: { ...initialVariableEditorState }, }, - } as unknown) as ReducersUsedInContext, + } as unknown) as RootReducerType, }) .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index a67aff8daf1..81f1c760088 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -516,7 +516,7 @@ export const onTimeRangeUpdated = ( } return false; - }); + }) as VariableWithOptions[]; const promises = variablesThatNeedRefresh.map((variable: VariableWithOptions) => dispatch(timeRangeUpdated(toVariableIdentifier(variable))) diff --git a/public/app/features/variables/state/helpers.ts b/public/app/features/variables/state/helpers.ts index 44a71579e43..618aca42c34 100644 --- a/public/app/features/variables/state/helpers.ts +++ b/public/app/features/variables/state/helpers.ts @@ -7,7 +7,8 @@ import { VariablesState } from './variablesReducer'; import { locationReducer } from '../../../core/reducers/location'; import { VariableAdapter } from '../adapters'; import { dashboardReducer } from 'app/features/dashboard/state/reducers'; -import { templatingReducers } from './reducers'; +import { templatingReducers, TemplatingState } from './reducers'; +import { DashboardState, LocationState } from '../../../types'; export const getVariableState = ( noOfVariables: number, @@ -76,6 +77,8 @@ export const getRootReducer = () => templating: templatingReducers, }); +export type RootReducerType = { location: LocationState; dashboard: DashboardState; templating: TemplatingState }; + export const getTemplatingRootReducer = () => combineReducers({ templating: templatingReducers, @@ -86,3 +89,5 @@ export const getTemplatingAndLocationRootReducer = () => templating: templatingReducers, location: locationReducer, }); + +export type TemplatingAndLocationReducerType = { location: LocationState; templating: TemplatingState }; diff --git a/public/app/features/variables/state/onTimeRangeUpdated.test.ts b/public/app/features/variables/state/onTimeRangeUpdated.test.ts index 41aa2010981..e006a69be1b 100644 --- a/public/app/features/variables/state/onTimeRangeUpdated.test.ts +++ b/public/app/features/variables/state/onTimeRangeUpdated.test.ts @@ -10,8 +10,7 @@ import { createConstantVariableAdapter } from '../constant/adapter'; import { VariableRefresh } from '../types'; import { constantBuilder, intervalBuilder } from '../shared/testing/builders'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from './reducers'; -import { getRootReducer } from './helpers'; +import { getRootReducer, RootReducerType } from './helpers'; import { toVariableIdentifier, toVariablePayload } from './types'; import { setCurrentVariableValue, @@ -23,6 +22,7 @@ import { createIntervalOptions } from '../interval/reducer'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { notifyApp } from '../../../core/reducers/appNotification'; import { expect } from '../../../../test/lib/common'; +import { TemplatingState } from './reducers'; variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]); @@ -63,7 +63,7 @@ const getTestContext = () => { } as unknown) as DashboardState; const startRefreshMock = jest.fn(); const adapter = variableAdapters.get('interval'); - const preloadedState = { + const preloadedState = ({ dashboard, location: { query: '' }, templating: ({ @@ -72,7 +72,7 @@ const getTestContext = () => { 'constant-1': { ...constant }, }, } as unknown) as TemplatingState, - }; + } as unknown) as RootReducerType; return { interval, @@ -98,7 +98,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { startRefreshMock, } = getTestContext(); - const tester = await reduxTester<{ templating: TemplatingState }>({ preloadedState }) + const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies)); @@ -133,7 +133,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { startRefreshMock, } = getTestContext(); - const base = await reduxTester<{ templating: TemplatingState }>({ preloadedState }) + const base = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(setOptionAsCurrent(toVariableIdentifier(interval), interval.options[0], false)); @@ -173,7 +173,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { adapter.updateOptions = jest.fn().mockRejectedValue(new Error('Something broke')); - const tester = await reduxTester<{ templating: TemplatingState }>({ preloadedState, debug: true }) + const tester = await reduxTester({ preloadedState, debug: true }) .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); diff --git a/public/app/features/variables/state/transactionReducer.test.ts b/public/app/features/variables/state/transactionReducer.test.ts index 760bd3882a5..9632e9f7cac 100644 --- a/public/app/features/variables/state/transactionReducer.test.ts +++ b/public/app/features/variables/state/transactionReducer.test.ts @@ -2,6 +2,7 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { initialTransactionState, transactionReducer, + TransactionState, TransactionStatus, variablesClearTransaction, variablesCompleteTransaction, @@ -11,7 +12,7 @@ import { describe('transactionReducer', () => { describe('when variablesInitTransaction is dispatched', () => { it('then state should be correct', () => { - reducerTester() + reducerTester() .givenReducer(transactionReducer, { ...initialTransactionState }) .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .thenStateShouldEqual({ ...initialTransactionState, uid: 'a uid', status: TransactionStatus.Fetching }); @@ -21,7 +22,7 @@ describe('transactionReducer', () => { describe('when variablesCompleteTransaction is dispatched', () => { describe('and transaction uid is the same', () => { it('then state should be correct', () => { - reducerTester() + reducerTester() .givenReducer(transactionReducer, { ...initialTransactionState, uid: 'before', @@ -34,7 +35,7 @@ describe('transactionReducer', () => { describe('and transaction uid is not the same', () => { it('then state should be correct', () => { - reducerTester() + reducerTester() .givenReducer(transactionReducer, { ...initialTransactionState, uid: 'before', @@ -48,7 +49,7 @@ describe('transactionReducer', () => { describe('when variablesClearTransaction is dispatched', () => { it('then state should be correct', () => { - reducerTester() + reducerTester() .givenReducer(transactionReducer, { ...initialTransactionState, uid: 'before', diff --git a/public/app/features/variables/state/variablesReducer.ts b/public/app/features/variables/state/variablesReducer.ts index 4a5b03635bc..a328674dd8f 100644 --- a/public/app/features/variables/state/variablesReducer.ts +++ b/public/app/features/variables/state/variablesReducer.ts @@ -1,8 +1,8 @@ -import { createAction, PayloadAction } from '@reduxjs/toolkit'; +import { createAction } from '@reduxjs/toolkit'; import { variableAdapters } from '../adapters'; import { sharedReducer } from './sharedReducer'; import { VariableModel } from '../types'; -import { VariablePayload } from './types'; +import { AnyAction } from 'redux'; export interface VariablesState extends Record {} @@ -10,10 +10,7 @@ export const initialVariablesState: VariablesState = {}; export const cleanVariables = createAction('templating/cleanVariables'); -export const variablesReducer = ( - state: VariablesState = initialVariablesState, - action: PayloadAction -): VariablesState => { +export const variablesReducer = (state: VariablesState = initialVariablesState, action: AnyAction): VariablesState => { if (cleanVariables.match(action)) { const globalVariables = Object.values(state).filter((v) => v.global); if (!globalVariables) { diff --git a/public/app/features/variables/system/adapter.ts b/public/app/features/variables/system/adapter.ts index e8fa51db13d..be90e23dd8e 100644 --- a/public/app/features/variables/system/adapter.ts +++ b/public/app/features/variables/system/adapter.ts @@ -20,8 +20,8 @@ export const createSystemVariableAdapter = (): VariableAdapter state, - picker: (null as unknown) as ComponentType, - editor: (null as unknown) as ComponentType, + picker: (null as unknown) as ComponentType>>, + editor: (null as unknown) as ComponentType>>, dependsOn: () => { return false; }, diff --git a/public/app/features/variables/textbox/TextBoxVariableEditor.tsx b/public/app/features/variables/textbox/TextBoxVariableEditor.tsx index 8f078c2a335..89e775f1e04 100644 --- a/public/app/features/variables/textbox/TextBoxVariableEditor.tsx +++ b/public/app/features/variables/textbox/TextBoxVariableEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, ReactElement, useCallback } from 'react'; +import React, { FormEvent, ReactElement, useCallback } from 'react'; import { VerticalGroup } from '@grafana/ui'; import { TextBoxVariableModel } from '../types'; @@ -11,16 +11,16 @@ export interface Props extends VariableEditorProps {} export function TextBoxVariableEditor({ onPropChange, variable: { query } }: Props): ReactElement { const updateVariable = useCallback( - (event: ChangeEvent, updateOptions: boolean) => { + (event: FormEvent, updateOptions: boolean) => { event.preventDefault(); - onPropChange({ propName: 'originalQuery', propValue: event.target.value, updateOptions: false }); - onPropChange({ propName: 'query', propValue: event.target.value, updateOptions }); + onPropChange({ propName: 'originalQuery', propValue: event.currentTarget.value, updateOptions: false }); + onPropChange({ propName: 'query', propValue: event.currentTarget.value, updateOptions }); }, [onPropChange] ); - const onChange = useCallback((e: ChangeEvent) => updateVariable(e, false), [updateVariable]); - const onBlur = useCallback((e: ChangeEvent) => updateVariable(e, true), [updateVariable]); + const onChange = useCallback((e: FormEvent) => updateVariable(e, false), [updateVariable]); + const onBlur = useCallback((e: FormEvent) => updateVariable(e, true), [updateVariable]); return ( diff --git a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx index b5f3e4a1570..efb3b907268 100644 --- a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx +++ b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx @@ -10,7 +10,7 @@ import { useDispatch } from 'react-redux'; export interface Props extends VariablePickerProps {} -export function TextBoxVariablePicker({ variable }: Props): ReactElement { +export function TextBoxVariablePicker({ variable, onVariableChange }: Props): ReactElement { const dispatch = useDispatch(); const [updatedValue, setUpdatedValue] = useState(variable.current.value); useEffect(() => { @@ -27,6 +27,15 @@ export function TextBoxVariablePicker({ variable }: Props): ReactElement { toVariablePayload({ id: variable.id, type: variable.type }, { propName: 'query', propValue: updatedValue }) ) ); + + if (onVariableChange) { + onVariableChange({ + ...variable, + current: { ...variable.current, value: updatedValue }, + }); + return; + } + variableAdapters.get(variable.type).updateOptions(variable); }, [dispatch, variable, updatedValue]); diff --git a/public/app/features/variables/textbox/actions.test.ts b/public/app/features/variables/textbox/actions.test.ts index 675b8577336..99368dd4276 100644 --- a/public/app/features/variables/textbox/actions.test.ts +++ b/public/app/features/variables/textbox/actions.test.ts @@ -1,9 +1,8 @@ import { variableAdapters } from '../adapters'; import { createTextBoxVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; import { setTextBoxVariableOptionsFromUrl, updateTextBoxVariableOptions } from './actions'; -import { getRootReducer } from '../state/helpers'; +import { getRootReducer, RootReducerType } from '../state/helpers'; import { VariableOption } from '../types'; import { toVariablePayload } from '../state/types'; import { createTextBoxOptions } from './reducer'; @@ -24,7 +23,7 @@ describe('textbox actions', () => { const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toVariablePayload(variable)), true); @@ -42,7 +41,7 @@ describe('textbox actions', () => { const urlValue = 'bB'; const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) .whenAsyncActionIsDispatched(setTextBoxVariableOptionsFromUrl(toVariablePayload(variable), urlValue), true); diff --git a/public/app/plugins/datasource/cloud-monitoring/CloudMonitoringMetricFindQuery.ts b/public/app/plugins/datasource/cloud-monitoring/CloudMonitoringMetricFindQuery.ts index bcc98d03bac..cbcd2a5152c 100644 --- a/public/app/plugins/datasource/cloud-monitoring/CloudMonitoringMetricFindQuery.ts +++ b/public/app/plugins/datasource/cloud-monitoring/CloudMonitoringMetricFindQuery.ts @@ -112,7 +112,7 @@ export default class CloudMonitoringMetricFindQuery { } const refId = 'handleResourceTypeQueryQueryType'; const labels = await this.datasource.getLabels(selectedMetricType, refId, projectName); - return labels['resource.type'].map(this.toFindQueryResult); + return labels['resource.type']?.map(this.toFindQueryResult) ?? []; } async handleAlignersQuery({ selectedMetricType, projectName }: CloudMonitoringVariableQuery) { diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx index 80c3711fecf..a9a4756eb19 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx @@ -1,5 +1,6 @@ import React, { InputHTMLAttributes, FunctionComponent } from 'react'; -import { InlineFormLabel } from '@grafana/ui'; +import { InlineFormLabel, Select, InlineField } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; export interface Props extends InputHTMLAttributes { label: string; @@ -26,3 +27,31 @@ export const QueryInlineField: FunctionComponent = ({ ...props }) => { ); }; + +interface VariableQueryFieldProps { + onChange: (value: string) => void; + options: SelectableValue[]; + value: string; + label: string; + allowCustomValue?: boolean; +} + +export const VariableQueryField: FunctionComponent = ({ + label, + onChange, + value, + options, + allowCustomValue = false, +}) => { + return ( + + - {options.map(({ value, name }, i) => ( - - ))} - - - - ); -}; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/VariableQueryEditor.tsx index 72c0a76782e..9cb564fb573 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/VariableQueryEditor.tsx @@ -1,5 +1,9 @@ import React, { PureComponent } from 'react'; -import { SimpleSelect } from './'; + +import { getTemplateSrv } from '@grafana/runtime'; +import { QueryEditorProps } from '@grafana/data'; + +import { VariableQueryField } from './'; import { extractServicesFromMetricDescriptors, getLabelKeys, getMetricTypes } from '../functions'; import { CloudMonitoringOptions, @@ -10,8 +14,6 @@ import { VariableQueryData, } from '../types'; import CloudMonitoringDatasource from '../datasource'; -import { getTemplateSrv } from '@grafana/runtime'; -import { QueryEditorProps } from '@grafana/data'; export type Props = QueryEditorProps< CloudMonitoringDatasource, @@ -21,19 +23,19 @@ export type Props = QueryEditorProps< >; export class CloudMonitoringVariableQueryEditor extends PureComponent { - queryTypes: Array<{ value: string; name: string }> = [ - { value: MetricFindQueryTypes.Projects, name: 'Projects' }, - { value: MetricFindQueryTypes.Services, name: 'Services' }, - { value: MetricFindQueryTypes.MetricTypes, name: 'Metric Types' }, - { value: MetricFindQueryTypes.LabelKeys, name: 'Label Keys' }, - { value: MetricFindQueryTypes.LabelValues, name: 'Label Values' }, - { value: MetricFindQueryTypes.ResourceTypes, name: 'Resource Types' }, - { value: MetricFindQueryTypes.Aggregations, name: 'Aggregations' }, - { value: MetricFindQueryTypes.Aligners, name: 'Aligners' }, - { value: MetricFindQueryTypes.AlignmentPeriods, name: 'Alignment Periods' }, - { value: MetricFindQueryTypes.Selectors, name: 'Selectors' }, - { value: MetricFindQueryTypes.SLOServices, name: 'SLO Services' }, - { value: MetricFindQueryTypes.SLO, name: 'Service Level Objectives (SLO)' }, + queryTypes: Array<{ value: string; label: string }> = [ + { value: MetricFindQueryTypes.Projects, label: 'Projects' }, + { value: MetricFindQueryTypes.Services, label: 'Services' }, + { value: MetricFindQueryTypes.MetricTypes, label: 'Metric Types' }, + { value: MetricFindQueryTypes.LabelKeys, label: 'Label Keys' }, + { value: MetricFindQueryTypes.LabelValues, label: 'Label Values' }, + { value: MetricFindQueryTypes.ResourceTypes, label: 'Resource Types' }, + { value: MetricFindQueryTypes.Aggregations, label: 'Aggregations' }, + { value: MetricFindQueryTypes.Aligners, label: 'Aligners' }, + { value: MetricFindQueryTypes.AlignmentPeriods, label: 'Alignment Periods' }, + { value: MetricFindQueryTypes.Selectors, label: 'Selectors' }, + { value: MetricFindQueryTypes.SLOServices, label: 'SLO Services' }, + { value: MetricFindQueryTypes.SLO, label: 'Service Level Objectives (SLO)' }, ]; defaults: VariableQueryData = { @@ -68,7 +70,7 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent ({ value: m.service, - name: m.serviceShortName, + label: m.serviceShortName, })); let selectedService = ''; @@ -93,9 +95,9 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent ({ value, name: label })), + projects, ...(await this.getLabels(selectedMetricType, this.state.projectName)), - sloServices: sloServices.map(({ value, label }: any) => ({ value, name: label })), + sloServices, loading: false, }; this.setState(state, () => this.onPropsChange()); @@ -127,14 +129,17 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent ({ value, name: label })), - }); + this.setState( + { + ...labels, + metricTypes, + selectedMetricType, + metricDescriptors, + projectName, + sloServices, + }, + () => this.onPropsChange() + ); } async onServiceChange(service: string) { @@ -150,7 +155,7 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent this.onPropsChange()); } async onMetricTypeChange(metricType: string) { @@ -158,7 +163,7 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent this.onPropsChange()); } onLabelKeyChange(labelKey: string) { @@ -185,31 +190,33 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent ({ - name: `$${v.name}`, - value: `$${v.name}`, - })); - return [...templateVariables, ...options]; - } - renderQueryTypeSwitch(queryType: string) { + const variableOptionGroup = { + label: 'Template Variables', + expanded: false, + options: getTemplateSrv() + .getVariables() + .map((v: any) => ({ + value: `$${v.name}`, + label: `$${v.name}`, + })), + }; + switch (queryType) { case MetricFindQueryTypes.MetricTypes: return ( <> - this.onProjectChange(e.target.value)} + options={[variableOptionGroup, ...this.state.projects]} + onChange={(value) => this.onProjectChange(value)} label="Project" /> - this.onServiceChange(e.target.value)} + options={[variableOptionGroup, ...this.state.services]} + onChange={(value) => this.onServiceChange(value)} label="Service" /> @@ -219,29 +226,33 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent - this.onProjectChange(e.target.value)} + options={[variableOptionGroup, ...this.state.projects]} + onChange={(value) => this.onProjectChange(value)} label="Project" /> - this.onServiceChange(e.target.value)} + options={[variableOptionGroup, ...this.state.services]} + onChange={(value) => this.onServiceChange(value)} label="Service" /> - this.onMetricTypeChange(e.target.value)} + options={[ + variableOptionGroup, + ...this.state.metricTypes.map(({ value, name }) => ({ value, label: name })), + ]} + onChange={(value) => this.onMetricTypeChange(value)} label="Metric Type" /> {queryType === MetricFindQueryTypes.LabelValues && ( - ({ value: l, name: l })))} - onValueChange={(e) => this.onLabelKeyChange(e.target.value)} + options={[variableOptionGroup, ...this.state.labels.map((l) => ({ value: l, label: l }))]} + onChange={(value) => this.onLabelKeyChange(value)} label="Label Key" /> )} @@ -251,16 +262,19 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent - this.onServiceChange(e.target.value)} + options={[variableOptionGroup, ...this.state.services]} + onChange={(value) => this.onServiceChange(value)} label="Service" /> - this.onMetricTypeChange(e.target.value)} + options={[ + variableOptionGroup, + ...this.state.metricTypes.map(({ value, name }) => ({ value, label: name })), + ]} + onChange={(value) => this.onMetricTypeChange(value)} label="Metric Type" /> @@ -268,10 +282,11 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent - this.onProjectChange(e.target.value)} + options={[variableOptionGroup, ...this.state.projects]} + onChange={(value) => this.onProjectChange(value)} label="Project" /> @@ -280,19 +295,20 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent - this.onProjectChange(e.target.value)} + options={[variableOptionGroup, ...this.state.projects]} + onChange={(value) => this.onProjectChange(value)} label="Project" /> - { + options={[variableOptionGroup, ...this.state.sloServices]} + onChange={(value) => { this.setState({ ...this.state, - selectedSLOService: e.target.value, + selectedSLOService: value, }); }} label="SLO Service" @@ -320,10 +336,10 @@ export class CloudMonitoringVariableQueryEditor extends PureComponent - this.onQueryTypeChange(e.target.value)} + onChange={(value) => this.onQueryTypeChange(value)} label="Query Type" /> {this.renderQueryTypeSwitch(this.state.selectedQueryType)} diff --git a/public/app/plugins/datasource/cloud-monitoring/components/index.ts b/public/app/plugins/datasource/cloud-monitoring/components/index.ts index 7cf6d44786a..0d0c0235070 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/index.ts +++ b/public/app/plugins/datasource/cloud-monitoring/components/index.ts @@ -8,10 +8,9 @@ export { Alignments } from './Alignments'; export { AlignmentPeriods } from './AlignmentPeriods'; export { AliasBy } from './AliasBy'; export { Aggregations } from './Aggregations'; -export { SimpleSelect } from './SimpleSelect'; export { MetricQueryEditor } from './MetricQueryEditor'; export { SLOQueryEditor } from './SLOQueryEditor'; export { MQLQueryEditor } from './MQLQueryEditor'; export { QueryTypeSelector } from './QueryType'; -export { QueryInlineField, QueryField } from './Fields'; +export { QueryInlineField, QueryField, VariableQueryField } from './Fields'; export { VisualMetricQueryEditor } from './VisualMetricQueryEditor'; diff --git a/public/app/plugins/datasource/cloud-monitoring/datasource.ts b/public/app/plugins/datasource/cloud-monitoring/datasource.ts index 63ef0db86c7..41bc91442ff 100644 --- a/public/app/plugins/datasource/cloud-monitoring/datasource.ts +++ b/public/app/plugins/datasource/cloud-monitoring/datasource.ts @@ -257,9 +257,9 @@ export default class CloudMonitoringDatasource extends DataSourceWithBackend< async getSLOServices(projectName: string): Promise>> { return this.api.get(`${this.templateSrv.replace(projectName)}/services?pageSize=1000`, { - responseMap: ({ name }: { name: string }) => ({ + responseMap: ({ name, displayName }: { name: string; displayName: string }) => ({ value: name.match(/([^\/]*)\/*$/)![1], - label: name.match(/([^\/]*)\/*$/)![1], + label: displayName || name.match(/([^\/]*)\/*$/)![1], }), }); } diff --git a/public/app/plugins/datasource/cloud-monitoring/functions.ts b/public/app/plugins/datasource/cloud-monitoring/functions.ts index 064f95fad23..0a99e2e114d 100644 --- a/public/app/plugins/datasource/cloud-monitoring/functions.ts +++ b/public/app/plugins/datasource/cloud-monitoring/functions.ts @@ -24,7 +24,8 @@ export const getMetricTypes = ( const metricTypeExistInArray = metricTypes.some( (m: { value: string; name: string }) => m.value === interpolatedMetricType ); - const selectedMetricType = metricTypeExistInArray ? metricType : metricTypes[0].value; + const metricTypeByService = metricTypes.length ? metricTypes[0].value : ''; + const selectedMetricType = metricTypeExistInArray ? metricType : metricTypeByService; return { metricTypes, selectedMetricType, diff --git a/public/app/plugins/datasource/cloud-monitoring/types.ts b/public/app/plugins/datasource/cloud-monitoring/types.ts index fb934837c0d..102e086567b 100644 --- a/public/app/plugins/datasource/cloud-monitoring/types.ts +++ b/public/app/plugins/datasource/cloud-monitoring/types.ts @@ -1,4 +1,4 @@ -import { DataQuery, DataSourceJsonData } from '@grafana/data'; +import { DataQuery, DataSourceJsonData, SelectableValue } from '@grafana/data'; export enum AuthType { JWT = 'jwt', @@ -32,8 +32,8 @@ export interface CloudMonitoringVariableQuery extends DataQuery { selectedMetricType: string; selectedSLOService: string; labelKey: string; - projects: Array<{ value: string; name: string }>; - sloServices: Array<{ value: string; name: string }>; + projects: SelectableValue[]; + sloServices: SelectableValue[]; projectName: string; } @@ -46,9 +46,9 @@ export interface VariableQueryData { labels: string[]; labelKey: string; metricTypes: Array<{ value: string; name: string }>; - services: Array<{ value: string; name: string }>; - projects: Array<{ value: string; name: string }>; - sloServices: Array<{ value: string; name: string }>; + services: SelectableValue[]; + projects: SelectableValue[]; + sloServices: SelectableValue[]; projectName: string; loading: boolean; } @@ -92,7 +92,9 @@ export interface SLOQuery { aliasBy?: string; selectorName: string; serviceId: string; + serviceName: string; sloId: string; + sloName: string; goal?: number; } diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx index 8fa5de1c4c5..2320f3c604d 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx @@ -28,6 +28,7 @@ const setup = (propOverrides?: object) => { url: '', database: '', type: 'cloudwatch', + typeName: 'Cloudwatch', user: '', password: '', basicAuth: false, diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx index ac72a564a3a..879d2710551 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx @@ -3,13 +3,13 @@ import { InlineFormLabel, LegacyForms, Button } from '@grafana/ui'; const { Select, Input } = LegacyForms; import { AppEvents, + SelectableValue, DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOptionSelect, onUpdateDatasourceResetOption, onUpdateDatasourceJsonDataOption, onUpdateDatasourceSecureJsonDataOption, } from '@grafana/data'; -import { SelectableValue } from '@grafana/data'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { CloudWatchDatasource } from '../datasource'; import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../types'; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 3f65353d34a..35124ab30aa 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -552,13 +552,7 @@ export class CloudWatchDatasource extends DataSourceApi ({ - message: o.error, - })) - .reduce((err, error) => { - return err || error; - }, null), + error: Object.values(res.results).reduce((acc, curr) => (curr.error ? { message: curr.error } : acc), null), }; }), catchError((err) => { diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts index aa07231bc71..0df067f7c33 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -9,7 +9,6 @@ import { } from '@grafana/data'; import * as redux from 'app/store/store'; -import '../datasource'; import { CloudWatchDatasource, MAX_ATTEMPTS } from '../datasource'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { CloudWatchLogsQuery, CloudWatchLogsQueryStatus, CloudWatchMetricsQuery, LogAction } from '../types'; diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx index ce9bcef2570..de9dd0ac39d 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -3,11 +3,10 @@ import { LegacyForms, VerticalGroup } from '@grafana/ui'; import { DataQuery, PanelData, SelectableValue } from '@grafana/data'; import { css } from 'emotion'; -import { DashboardQuery, ResultInfo } from './types'; +import { DashboardQuery, ResultInfo, SHARED_DASHBODARD_QUERY } from './types'; import config from 'app/core/config'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PanelModel } from 'app/features/dashboard/state'; -import { SHARED_DASHBODARD_QUERY } from './types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { filterPanelDataToQuery } from 'app/features/query/components/QueryEditorRow'; import { DashboardQueryRow } from './DashboardQueryRow'; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx new file mode 100644 index 00000000000..c3a2afbbcff --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SettingsEditor } from '.'; +import { ElasticsearchProvider } from '../../ElasticsearchQueryContext'; +import { ElasticDatasource } from '../../../../datasource'; +import { ElasticsearchQuery } from '../../../../types'; + +describe('Settings Editor', () => { + describe('Raw Data', () => { + it('Should correctly render the settings editor and trigger correct state changes', () => { + const metricId = '1'; + const initialSize = '500'; + const query: ElasticsearchQuery = { + refId: 'A', + metrics: [ + { + id: metricId, + type: 'raw_data', + settings: { + size: initialSize, + }, + }, + ], + }; + + const onChange = jest.fn(); + + const { rerender } = render( + {}} + > + + + ); + + let settingsButtonEl = screen.getByRole('button', { + name: /Size: \d+$/i, + }); + + // The metric row should have a settings button + expect(settingsButtonEl).toBeInTheDocument(); + expect(settingsButtonEl.textContent).toBe(`Size: ${initialSize}`); + + // Open the settings editor + fireEvent.click(settingsButtonEl); + + // The settings editor should have a Size input + const sizeInputEl = screen.getByLabelText('Size'); + expect(sizeInputEl).toBeInTheDocument(); + + // We change value and trigger a blur event to trigger an update + const newSizeValue = '23'; + fireEvent.change(sizeInputEl, { target: { value: newSizeValue } }); + fireEvent.blur(sizeInputEl); + + // the onChange handler should have been called correctly, and the resulting + // query state should match what expected + expect(onChange).toHaveBeenCalledTimes(1); + rerender( + {}} + > + + + ); + + settingsButtonEl = screen.getByRole('button', { + name: /Size: \d+$/i, + }); + expect(settingsButtonEl).toBeInTheDocument(); + expect(settingsButtonEl.textContent).toBe(`Size: ${newSizeValue}`); + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx index 67923530eaa..0c20b134278 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/index.tsx @@ -16,6 +16,7 @@ import { useDescription } from './useDescription'; import { MovingAverageSettingsEditor } from './MovingAverageSettingsEditor'; import { uniqueId } from 'lodash'; import { metricAggregationConfig } from '../utils'; +import { useQuery } from '../../ElasticsearchQueryContext'; // TODO: Move this somewhere and share it with BucketsAggregation Editor const inlineFieldProps: Partial> = { @@ -30,6 +31,7 @@ interface Props { export const SettingsEditor: FunctionComponent = ({ metric, previousMetrics }) => { const dispatch = useDispatch(); const description = useDescription(metric); + const query = useQuery(); return (